Custom hooks are functions that start with use and call other hooks. That's it. There's no special API, no registration, no magic — just a function that encapsulates stateful logic so you don't copy-paste the same useState + useEffect combo across ten components.
The heuristic is straightforward: when you see the same combination of hooks duplicated in two or more components, extract it. Custom hooks are React's composition model, and they're the reason hook-based code scales better than class mixins or render props ever did.
Master react custom hooks
Take the Building Custom Hooks course with hands-on lessons and challenges.
This follows the useState tuple pattern so the API feels familiar. The lazy initializer reads from localStorage only on mount. The storage event listener syncs the value when another tab writes to the same key — a detail most implementations miss.
The hook debounces any value, not just strings. Each time value changes, the previous timer is cleared and a new one starts. The consumer doesn't need to know about timeouts — it just uses the debounced value as a dependency. This is the standard pattern for search-as-you-type.
Uses the matchMedia API which fires a change event only when the query result flips, so there's no polling or resize listener overhead. The SSR guard returns false by default — on the server, we assume the non-matching state and let the client hydrate correctly.
The discriminated union for AsyncState means TypeScript narrows the types correctly — when status is 'success', data is guaranteed to be T, not T | null. The deps array re-triggers the fetch when inputs change, similar to useEffect's dependency list. For production apps you'd add AbortController support to cancel in-flight requests on unmount.
The freezeOnceVisible option is the key detail: once the element enters the viewport, the observer disconnects and the state never flips back. This is what you want for lazy loading and entrance animations. The ref is set via a callback (useState) rather than useRef so the observer re-attaches when the DOM element changes.
Returns the ref directly so the consumer just attaches it to the container element. Listens on mousedown instead of click so the dropdown closes before the click event propagates — this prevents ghost clicks on elements that appear under the dropdown. The touchstart listener handles mobile.
Returning a new object or array on every render without memoization — e.g. `return { value, toggle }` where toggle is recreated each time
Wrap action functions in `useCallback` and memoize computed objects with `useMemo` if needed. Consumers will put your return values in dependency arrays, and unstable references cause infinite re-render loops.
Calling hooks conditionally inside a custom hook — e.g. `if (enabled) { useState(...) }` — which violates the Rules of Hooks
Always call every hook unconditionally. Use the condition inside the hook's logic instead: call useState regardless, and gate the effect body with `if (!enabled) return;`.
Accessing browser APIs (localStorage, window.matchMedia, IntersectionObserver) without an SSR guard, causing 'window is not defined' crashes in Next.js
Check `typeof window !== 'undefined'` in your initializer, or move browser API access into a useEffect (which only runs on the client). Provide a sensible default for the server render.
Not cleaning up subscriptions, event listeners, or timers in the effect's return function — causing memory leaks and state updates on unmounted components
Every `addEventListener` needs a matching `removeEventListener`. Every `setTimeout` needs `clearTimeout`. Every `observer.observe()` needs `observer.disconnect()`. Return a cleanup function from useEffect, always.
Custom hooks are React's answer to code reuse. They're plain functions that call other hooks, and each component gets its own isolated state. The practical hooks you'll reach for most often are useLocalStorage (persisted state), useDebounce (rate-limiting), useMediaQuery (responsive logic), useAsync (data fetching states), useIntersectionObserver (viewport detection), and useClickOutside (dismissing overlays). Extract them when you see duplication, keep them focused on one job, and wrap returned functions in useCallback so consumers don't fight dependency arrays.
Interactive lessons and challenges on Stanza, practice in VS Code, Cursor, or the web.
Interactive lessons and challenges, right in your code editor.
Check the free courses. No credit card.