open atlas
↑ Back to track
React patterns, senior RXP · 04 · 02

Hook design and dependencies

A custom hook is an API: clear inputs, a stable return shape, an honest dependency array, and cleanup. The classic hook bugs are stale closures and missing cleanup — a hook's deps and cleanup ARE its contract.

RXP Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

Extracting logic into a custom hook is easy. Designing one that doesn’t lie is the hard part. The two bugs that haunt custom hooks aren’t exotic: a stale closure that quietly fires with last render’s values, and a missing cleanup that leaks an interval, a listener, or a subscription. Both come from the same place — the dependency array and the effect cleanup, the two things people treat as boilerplate.

They aren’t boilerplate. A hook’s dependency array and its cleanup are its contract: they declare what the hook reads and what it promises to tear down. Get them right and the hook is a black box a teammate can trust. Get them wrong and you’ve shipped a time bomb that only goes off when a prop changes at the wrong moment.

Goal

After this lesson you can design a custom hook as a real API — clear inputs, a stable and predictable return shape, an honest dependency array, and cleanup that matches setup; explain why a missing or lying dep array produces stale closures and leaks; use a ref to keep the latest callback without re-subscribing every render; and decide when a returned function actually needs useCallback and when memoizing it “to be safe” is just noise.

1

Design the hook’s surface first: clear inputs, a predictable return shape. Before any effect, decide what goes in and what comes out — and keep the shape of the return stable across renders so consumers can destructure it once. Return a tuple when order is obvious and small ([value, setValue]), an object when there are several named pieces (so callers pick what they need without positional guessing).

// object return: named, extensible, order-independent
function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((v) => !v), []);
  return { on, toggle, setOn }; // same keys every render → stable shape
}

A predictable shape is part of the contract. If the hook sometimes returns undefined and sometimes an object, every consumer has to guard. Pick a shape and hold it for every render, including the first.

2

The dependency array is a claim: “these are the values this effect reads.” A lying dep array is the bug. exhaustive-deps isn’t nagging — it’s checking your claim against reality. If the effect closes over delay but you write [], you’ve told React “this effect reads nothing,” so it never re-runs when delay changes, and the interval keeps firing on the original delay forever. That’s a stale closure: the effect captured render-zero’s values and was never told to refresh.

// LYING dep array: effect reads `delay` but claims to read nothing
useEffect(() => {
  const id = setInterval(tick, delay); // captures the FIRST delay
  return () => clearInterval(id);
}, []); // ❌ omits `delay` → never updates when delay changes

The fix is never “silence the lint rule.” It’s either list the dependency honestly (and accept the re-subscribe), or restructure so the effect genuinely doesn’t depend on it — which is exactly what a ref buys you in Step 4.

3

Cleanup is the other half of the contract: every subscription you set up, you tear down. An effect that adds a listener, opens an interval, or subscribes to a store must return a function that undoes it. Skip it and you leak — duplicate listeners stack up across re-renders and unmounts, intervals keep ticking after the component is gone, and you get “setState on an unmounted component” warnings or, worse, silent double-fires.

function useEventListener(type: string, handler: (e: Event) => void) {
  useEffect(() => {
    window.addEventListener(type, handler);
    return () => window.removeEventListener(type, handler); // ← the contract
  }, [type, handler]); // re-subscribes if either changes
}

There’s a hidden trap here: handler is in the deps, so an inline handler (new identity every render) makes this re-subscribe on every render. That’s correct but wasteful — and it’s the exact pressure that justifies the ref pattern next.

4

Use a ref to read the latest callback without re-subscribing — the senior fix for the stale-closure / churn tension. You want two things that fight each other: the effect should always call the latest handler (no stale closure), but it shouldn’t tear down and re-create the subscription every render. A ref resolves it: store the newest handler in a ref (updated each render), subscribe once with a stable wrapper that reads ref.current at call time.

function useInterval(callback: () => void, delay: number | null) {
  const savedCallback = useRef(callback);
  useEffect(() => { savedCallback.current = callback; }); // always latest, no churn

  useEffect(() => {
    if (delay === null) return;                       // null = paused
    const id = setInterval(() => savedCallback.current(), delay);
    return () => clearInterval(id);                    // cleanup matches setup
  }, [delay]); // ✅ honest: only delay actually drives re-subscribe
}

Now delay is the only thing that re-subscribes, the callback is never stale, and the dep array tells the truth. That’s the whole pattern: a ref absorbs the “latest value” need so the dependency array can stay both honest and minimal.

Worked example

A useEventListener that leaks and goes stale, fixed into one that doesn’t. A teammate writes a hook to track the latest scroll position and call a handler. The first version looks fine and ships:

// BEFORE: stale handler + re-subscribes every render
function useScroll(onScroll: (y: number) => void) {
  useEffect(() => {
    const fn = () => onScroll(window.scrollY);
    window.addEventListener("scroll", fn);
    return () => window.removeEventListener("scroll", fn);
  }, []); // ❌ deps lie: effect reads onScroll but claims nothing
}

Two bugs from one cause. The [] says “reads nothing,” so onScroll is frozen at the first render’s closure — if the parent passes a new onScroll (say, with fresh state), the listener still calls the old one. And if a teammate “fixes” the lint by writing [onScroll], an inline handler now adds and removes a listener on every single render. The honest, churn-free version uses a ref:

// AFTER: ref absorbs the latest handler; deps tell the truth
function useScroll(onScroll: (y: number) => void) {
  const saved = useRef(onScroll);
  useEffect(() => { saved.current = onScroll; }); // latest every render, no churn

  useEffect(() => {
    const fn = () => saved.current(window.scrollY); // reads latest at call time
    window.addEventListener("scroll", fn);
    return () => window.removeEventListener("scroll", fn);
  }, []); // ✅ honest: the subscribe effect genuinely reads no reactive value
}

The [] here is now true — the subscribe effect really does read nothing reactive; the latest handler arrives through the ref, not the closure. Setup and cleanup are symmetric, the handler is never stale, and the listener is added exactly once. That symmetry — deps that don’t lie, cleanup that mirrors setup — is the contract a custom hook owes its consumers.

Common mistake

The “over-memoize everything to be safe” trap. Wrapping every returned function in useCallback and every returned object in useMemo feels defensive, but most of those memos are pure cost: they run on every render, allocate a deps array, and buy nothing unless an identity-sensitive consumer exists downstream (an effect dependency, a memo’d child, another hook’s dep array). Stable identity is only worth paying for when something actually depends on it. Memoize a returned function because a consumer puts it in a dep array or passes it to React.memo — not reflexively. The mirror-image mistake is worse: a lying [] dep array to dodge the lint warning. Over-memoizing wastes cycles; a lying dep array ships a stale-closure bug.

Edge cases

useInterval with delay === null as a pause signal is a deliberate API choice worth copying: a nullable input that the effect early-returns on lets a consumer pause and resume declaratively (useInterval(tick, isRunning ? 1000 : null)) without you exposing imperative start/stop methods. The cleanup still runs when delay flips to null, so the old interval is cleared — pausing is just “tear down, set up nothing.” Designing the absence of work into the input shape keeps the return surface small and the contract honest.

Check yourself
Quiz

A useInterval hook captures `callback` directly inside the effect and uses [delay] as its dependency array, but the parent passes a fresh inline `callback` every render that reads current state. What's the bug, and what's the right fix?

Recap

A custom hook is an API, and four things make up its contract. Inputs should be clear, and the return shape stable and predictable so consumers destructure once. The dependency array is a claim about what an effect reads — exhaustive-deps checks that claim, and a lying [] produces a stale closure that fires with frozen values. Cleanup mirrors setup: every listener, interval, or subscription you create, you tear down, or you leak. The senior move for the stale-closure-versus-churn tension is a ref: write the latest callback into ref.current each render, then subscribe once with deps that contain only the real config (delay, type) — honest and minimal. And resist the reflex to useCallback/useMemo everything: stable identity is only worth paying for when a downstream consumer actually depends on it. Deps and cleanup are not boilerplate — they’re the promise your hook makes.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.