Module 4: Hooks and Lifecycles
Custom Hooks: Reusing Behavior, Not Markup
How to extract logic into your own hooks, what the rules of hooks really protect, and several practical hooks you can build.
Components are how you reuse UI. Custom hooks are how you reuse logic that involves state or effects. If two components both need to know whether the browser is online, or both read and write a value in localStorage, copying the code is wrong, and a component is the wrong shape for it, because there's nothing to render.
A custom hook is just a function whose name starts with use and that calls other hooks.
A first example#
Here's something that starts out duplicated in many components.
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = () => setOn((v) => !v);
return [on, toggle];
}
function Menu() {
const [open, toggleOpen] = useToggle();
return (
<>
<button onClick={toggleOpen}>Menu</button>
{open && <Nav />}
</>
);
}
That's all a custom hook is. No new API, no registration. You pulled a useState and a function out into a named function, and now other components can call it.
Shared logic, not shared state#
This surprises people. If two components call useToggle(), each one gets its own independent state. Calling the hook is like pasting its body into your component. Nothing is shared between callers.
function A() { const [on] = useToggle(); /* its own state */ }
function B() { const [on] = useToggle(); /* a different state */ }
If you want shared state, lift it up and pass it down, or put it in context. A custom hook reuses how something works, not what the value currently is.
<A /> <B />
┌───────────────────────┐ ┌───────────────────────┐
│ useToggle() │ │ useToggle() │
│ useState(false) │ │ useState(false) │
│ state slot: true │ │ state slot: false │
└───────────────────────┘ └───────────────────────┘
same code, two separate pieces of memory
The rules of hooks#
Two rules, both enforced by the lint plugin that comes with Vite:
- Only call hooks at the top level of a component or another hook. Not inside conditions, loops, or nested functions.
- Only call hooks from React functions: components or other hooks.
The use prefix isn't decoration. It's the signal the linter uses to know that this function follows the rules and should be checked.
Why the strictness? Remember from the useState lesson that React matches hook calls to storage slots by call order. A hook that runs sometimes and not others would shift every slot after it.
function Broken({ show }) {
if (show) {
const [a] = useState(1); // on some renders this is slot 0, on others it doesn't exist
}
const [b] = useState(2); // slot 0 or slot 1, depending on the render
}
Practical hook 1: useLocalStorage#
import { useEffect, useState } from "react";
export function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const saved = localStorage.getItem(key);
return saved !== null ? JSON.parse(saved) : initialValue;
} catch {
return initialValue; // private mode or corrupted data
}
});
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
/* storage full or blocked, ignore */
}
}, [key, value]);
return [value, setValue];
}
// usage, same shape as useState
const [theme, setTheme] = useLocalStorage("theme", "light");
Decisions I made there: lazy initial state so we read storage once, a try/catch because JSON.parse and storage access can both throw, and it returns the same [value, setValue] pair as useState so it's a drop-in swap.
Practical hook 2: useDebounce#
Typing into a search box and sending a request for every keystroke is wasteful. A debounced value waits until the user pauses.
export function useDebounce(value, delay = 400) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id); // typing again cancels the pending update
}, [value, delay]);
return debounced;
}
function Search() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query);
useEffect(() => {
if (debouncedQuery) console.log("search for", debouncedQuery);
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
The cleanup is the whole trick. Each keystroke cancels the previous timer and starts a new one, so only the last pause survives.
Practical hook 3: useEventListener#
import { useEffect, useRef } from "react";
export function useEventListener(target, type, handler) {
const handlerRef = useRef(handler);
handlerRef.current = handler; // always point at the latest handler
useEffect(() => {
const el = target ?? window;
const listener = (e) => handlerRef.current(e);
el.addEventListener(type, listener);
return () => el.removeEventListener(type, listener);
}, [target, type]);
}
useEventListener(null, "keydown", (e) => {
if (e.key === "Escape") closeModal();
});
Storing the handler in a ref means callers can pass a fresh inline function every render without re-subscribing. (Assigning to a ref during render is a small exception to the usual rule, and it's accepted for this pattern. React 19.2's useEffectEvent is the newer tool for the same job.)
Practical hook 4: useFetch, a preview#
export function useFetch(url) {
const [state, setState] = useState({ data: null, error: null, loading: true });
useEffect(() => {
let ignore = false;
setState({ data: null, error: null, loading: true });
fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json();
})
.then((data) => { if (!ignore) setState({ data, error: null, loading: false }); })
.catch((error) => { if (!ignore) setState({ data: null, error, loading: false }); });
return () => { ignore = true; };
}, [url]);
return state;
}
This works and it's good for learning. In real apps you'd typically use TanStack Query instead, which does this plus caching and retries. We'll get there.
Designing a good hook#
- Name it for what it gives you, not how it works:
useOnlineStatus, notuseNavigatorEffect. - Return the shape people expect. An array of
[value, setter]mirrorsuseStateand lets callers rename. An object is better when you return many things and want named access. - Keep it focused. A hook called
useAppLogicthat does everything is a component pretending to be a hook. - Don't wrap everything. If the "hook" is a single
useStatewith a fancy name, skip it. Extract a hook when there's real logic worth naming or sharing. - Don't create hooks like
useMountthat wrapuseEffect(fn, []). They hide the dependency array and make it easy to write buggy effects.
Where people get burned#
- Expecting two components that call the same hook to share state.
- Calling a custom hook conditionally.
- Forgetting the
useprefix, so the linter can't check it. - Returning a new object or function every render and then using it as an effect dependency elsewhere.
- Extracting hooks too early, then having to untangle them when the second use case differs.
Practice#
- Write
useToggleand use it in two components. Confirm their states are independent. - Implement
useLocalStorageand use it to persist a to-do list across page reloads. Handle the case where stored data is invalid JSON. - Build
useDebounceand a search box that logs the debounced value. Check how many times the log fires when you type quickly. - Write
useOnlineStatusthat listens for theonlineandofflineevents onwindowand returns a boolean. Show a banner when offline.