π What You Will Learn
- What a custom hook is: a function that starts with
useand calls other hooks - useToggle: your first custom hook, reused across independent components
- useLocalStorage: syncing state with a browser API via
useEffect - useDebounce: delaying a value update until input settles
- useFetch: composing
useState+useEffectinto a data hook - Rules for composing hooks: what's safe, and the mistakes that break them
What a Custom Hook Actually Is
A custom hook is just a JavaScript function. Nothing magic β no special API to register it. Two conventions make it a "hook" React recognizes: its name starts with use, and it calls other hooks (useState, useEffect, etc.) inside itself.
The entire point is reusing stateful logic β not UI. If you've ever copy-pasted the same useState + useEffect combo into three different components, that logic wanted to be a custom hook.
// This IS a custom hook β starts with "use", calls other hooks
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return width;
}
// Any component can now reuse this logic in one line
function Header() {
const width = useWindowWidth();
return <p>Window is {width}px wide</p>;
}Your First Custom Hook: useToggle
The simplest possible custom hook wraps a single useState call with a bit of convenience logic. It still pays off immediately: every component that needs a boolean toggle gets the exact same, tested behavior.
Same useToggle() hook, two completely independent pieces of state.
useToggle() creates its own, completely independent useState underneath.Syncing with a Browser API: useLocalStorage
A custom hook is the natural place to bridge React state with a non-React API β here, localStorage. Read once on mount (lazily, via useState's initializer function), then write back on every change.
Saved to localStorage as you type. Refresh this page β it'll still be here.
window doesn't exist during server rendering β check typeof window === "undefined" before touching localStorage, or the initializer will crash on the server.Timing Logic: useDebounce
Debouncing β waiting for input to "settle" before acting on it β is exactly the kind of fiddly useEffect + timer logic you don't want to rewrite in every search box.
Live value: (empty)
Debounced (500ms after you stop typing): (empty) β this is what you'd send to a search API
Composing Hooks: useFetch
Custom hooks can call other hooks β including other custom hooks. useFetch below composes useState and useEffect into a single reusable data-fetching hook with loading/error/success states baked in.
cancelled flag matters: if userId changes before the previous fetch resolves, the cleanup function flips cancelled to true so the stale response never overwrites the newer one.Rules for Composing Hooks Safely
Custom hooks still have to follow the same Rules of Hooks as built-in ones, because under the hood they're calling useState/useEffect just like any component would:
- Only call hooks (custom or built-in) at the top level β never inside conditions, loops, or nested functions
- Only call hooks from React function components or other custom hooks β never from a plain utility function
- Name every custom hook starting with
useso linters and other developers can enforce these rules on it too
Common Mistakes
// β BAD: the linter can't tell this calls hooks, rules won't be enforced
function toggle(initial) {
const [value, setValue] = useState(initial); // breaks the rules silently
return [value, () => setValue(v => !v)];
}
// β
GOOD: the "use" prefix is what makes it a recognized hook
function useToggle(initial) {
const [value, setValue] = useState(initial);
return [value, () => setValue(v => !v)];
}// β BAD: every remount adds another listener that's never removed
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
window.addEventListener("resize", () => setWidth(window.innerWidth));
}, []);
return width;
}
// β
GOOD: return the cleanup function from useEffect
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}, []);// β BAD: one hook handling auth AND theme AND cart β hard to reuse or test
function useEverything() { /* ...200 lines... */ }
// β
GOOD: small, focused hooks that compose
function useAuth() { /* ... */ }
function useTheme() { /* ... */ }
function useCart() { /* ... */ }Best Practices
[value, setter]) when it fits β familiar APICommon Custom Hook Reference
| Hook | Wraps | Purpose |
|---|---|---|
| useToggle | useState | Boolean flip-flop state |
| useLocalStorage | useState + useEffect | State synced to a browser storage API |
| useDebounce | useState + useEffect | Delay reacting to a fast-changing value |
| useFetch | useState + useEffect | Data fetching with loading/error/success |
| usePrevious | useRef + useEffect | Read last render's value (see the useRef post) |
Practice Project
Build a useOnlineStatus() custom hook that:
- Returns a boolean tracking
navigator.onLine - Subscribes to the browser's
onlineandofflinewindow events - Cleans up both listeners when the component using it unmounts
- Is reused in two places at once β a header badge and a "you're offline" banner β to prove they share nothing but the pattern
What's Next?
- useMemo / React.memo: optimize hooks and components that do expensive work
- Data Fetching: graduate from a hand-rolled
useFetchto TanStack Query - State Management: when a custom hook isn't enough and you need a real global store
- React Hooks Deep Dive: revisit how every built-in hook fits together
Keep practicing! πͺ Custom hooks are where React composition really shines β once you start noticing repeated useState/useEffect pairs, extracting them makes every component that uses them shorter and easier to trust.