Skip to content

Chapter 6 of 7

Custom Hooks: Reusable Patterns

Custom hooks are just functions whose names start with use and that call other hooks. The prefix is not cosmetic — React's linter and DevTools use it to recognize hook calls and surface their state. The real reason to extract a custom hook is reuse: any time two components want the same combination of state, effect, and cleanup, lifting it into a hook keeps both call sites in sync. A tiny useToggle, a slightly larger useCounter, and a more substantial useFormStatus all share the same shape: state plus bound actions returned from one function. Common reusable hooks include useDebounced, useThrottle, useLocalStorage, useWindowSize, useMediaQuery, useFetch, useScript, useOnClickOutside, and useKeyboard.

The two trickiest families are debouncing and data fetching. A useDebounced(value, ms) hook keeps an internal state, sets a timeout when the value changes, and clears the timeout in cleanup, so the returned value lags behind by ms after the input stops changing. A throttled hook uses a ref to track the last timestamp and either ignores intermediate calls or schedules a trailing one. For fetching, the canonical skeleton uses an "off" flag in the effect: declare let off = false, set it true in cleanup, and guard setData with !off so a slow response can't setState after unmount. In modern code, an AbortController passed as signal to fetch does the same job more explicitly, and a ref-tracked request id lets you ignore responses that have been superseded by a newer request. Combining both gives you a hook that survives StrictMode double-mounts and rapidly changing URLs without leaking listeners.

A few cross-cutting rules apply to nearly every custom hook. Cleanup is non-negotiable: if your hook subscribes, listens, or schedules a timer, the effect must return a function that undoes it; otherwise StrictMode's double-mount will double-fire and unmounted components will leak. For SSR safety, initialize from localStorage lazily inside useState and persist in an effect that only runs after mount; reading browser-only APIs during render causes hydration mismatches. UI-bound hooks follow the same recipe: useScript appends a tag and tracks loaded/error state with cleanup; useOnClickOutside listens on document for mousedown and fires when the target is outside the ref; useHover wires onMouseEnter and onMouseLeave; useMediaQuery listens to matchMedia's change event; a viewport scroll hook uses a requestAnimationFrame ref to throttle. Some side effects belong in custom hooks too: setting document.title on a prop change with cleanup that restores the previous title; sending analytics on route change; or implementing form draft autosave with a debounced effect. Across all of these, the same warning holds: if your hook is not synchronizing with an external system, you probably don't need an effect at all.

All chapters
  1. 1Foundations: Rules, StrictMode, and the Hook Mental Model
  2. 2useEffect and Side Effects: Patterns and Pitfalls
  3. 3Refs, Stale Closures, and Imperative Escapes
  4. 4Memoization and Render Performance
  5. 5State Management: useReducer, Context, and External Stores
  6. 6Custom Hooks: Reusable Patterns
  7. 7Concurrent Features, React 19 Hooks, and Server Architecture

Drill it

Reading is not remembering. These come from the React Hooks Advanced Patterns deck:

Q

Why does useEffect run twice in dev?

Strict Mode mounts → unmounts → remounts to catch bugs from missing cleanups. Production runs effects once per mount.

Q

Common cause of infinite useEffect loop?

Updating state inside the effect with a dep that changes when state updates. Memoize the value or remove from deps if it doesn't need to trigger.

Q

How does useCallback help?

Returns the same function reference unless deps change — avoids re-rendering children that take it as a prop and use memo().

Q

useMemo vs useCallback?

useMemo(f, d): memoizes a value (the result of f()).useCallback(f, d): memoizes the function itself. Equivalent to useMemo(() => f, d).