skip to content

Common Custom-Hook Patterns

The handful of hooks nearly every codebase reinvents, and what each one teaches about subscriptions, cleanup, and closures. Interviewers frequently ask you to write one live, so the implementations are worth having at your fingertips.

part ofReactoverview, primer and where to startread it →
on this pageshow

explore

questions

5

Write a React `useDebouncedValue(value, delay)` hook using useState and useEffect. Then explain why a `useDebouncedCallback(fn, delay)` variant needs a ref holding the latest `fn`.

level: middleimportance: must knowfreq 62%

answer

  1. cleanup cancels the previous timer
  2. effect lifecycle does the work for you
  3. a stable wrapper is the whole point
  4. stable means frozen closure
  5. read at call time, not at creation time

basics

~20 s

useDebouncedValue keeps a state copy updated by an effect that starts a timeout and clears it in cleanup, so each new value restarts the delay. A callback version must keep the newest fn in a ref, because a debounced wrapper created once would otherwise fire a frozen closure.

solid answer

~50 s

For the value version, hold `debounced` in state and run an effect on `[value, delay]` that does `const id = setTimeout(() => setDebounced(value), delay)` and returns `() => clearTimeout(id)`. Cleanup runs before the next effect and on unmount, so a fast-changing value keeps cancelling and restarting the timer, and nothing fires after the component is gone. The callback version is harder because the debounced wrapper must survive across renders — if you recreate it each render, every keystroke gets a new timer and nothing is ever debounced. So you create the wrapper once, and it can no longer close over the current `fn`: the `fn` it captured belongs to the render that created it, and will read stale props and state when it eventually fires. The fix is a ref updated on every render (`fnRef.current = fn`) that the wrapper reads at call time, so the timer stays stable while the behaviour stays current.

code

javascript · 26 lines
javascript
import { useState, useRef, useEffect, useCallback } from 'react';

export function useDebouncedValue(value, delay) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}

export function useDebouncedCallback(fn, delay) {
  const fnRef = useRef(fn);
  const timerRef = useRef(null);
  useEffect(() => {
    fnRef.current = fn;
  });
  useEffect(() => () => clearTimeout(timerRef.current), []);
  return useCallback(
    (...args) => {
      clearTimeout(timerRef.current);
      timerRef.current = setTimeout(() => fnRef.current(...args), delay);
    },
    [delay],
  );
}

go deeper

for a junior

Be able to write the value version and say out loud that the effect's cleanup clears the previous timeout, which is what makes rapid changes collapse into one update. Know that setTimeout must be cleared on unmount.

for a middle

Explain the effect lifecycle producing the debounce, and articulate the two competing constraints in the callback version: the wrapper must be stable for debouncing to work, and stability is exactly what makes its captured fn go stale.

for a senior

Show the latest-ref pattern as a general technique — anything read at call time rather than depended on goes in a ref refreshed after commit — and be ready to argue debounce versus useDeferredValue versus throttle in terms of where the cost actually is.

for a principal

Decide the policy rather than the snippet: whether debouncing belongs in components at all or behind the data layer, what the delay budget should be across the product, and how to keep a fleet of hand-rolled timing hooks from drifting into inconsistent, untestable behaviour.

## The value version ```js import { useState, useEffect } from 'react'; function useDebouncedValue(value, delay) { const [debounced, setDebounced] = useState(value); useEffect(() => { const id = setTimeout(() => setDebounced(value), delay); return () => clearTimeout(id); }, [value, delay]); return debounced; } ``` The elegance is that the debouncing falls out of the effect lifecycle rather than being coded. Every time `value` changes, React runs the cleanup of the previous effect — cancelling the pending timer — and then runs the new effect, starting a fresh one. Type six characters quickly and you get six starts and five cancellations; only the last timer survives to fire. Note that the timer id is an ordinary local `const`: the cleanup closure captures it, so no ref is needed here at all. Claiming otherwise is a common over-engineering tell. Cleanup also fires on unmount, which matters: without it the callback would run on a component that no longer exists, and in a data-fetching context that is how you get a request fired for a screen the user has already left. The hook returns `value` immediately on the first render (state is initialised from it), which is normally what you want — the delay applies to *changes*, not to the initial paint. ## When to use the value version A debounced value is the right shape when the consumer is declarative: a search box where you render results for `useDebouncedValue(query, 300)` while the input itself stays fully responsive on the undebounced `query`. It is worth naming the alternative here, because interviewers probe it: `useDeferredValue` solves the adjacent problem of keeping the UI responsive under heavy rendering by letting React render the stale value at a lower priority, and it adapts to how fast the device actually is. A debounce is a fixed wall-clock delay — the right tool when the cost is *downstream* (a network request, an expensive external call), not when the cost is React rendering. ## Why the callback version is different ```js import { useRef, useEffect, useCallback } from 'react'; function useDebouncedCallback(fn, delay) { const fnRef = useRef(fn); const timerRef = useRef(null); useEffect(() => { fnRef.current = fn; }); useEffect(() => () => clearTimeout(timerRef.current), []); return useCallback((...args) => { clearTimeout(timerRef.current); timerRef.current = setTimeout(() => fnRef.current(...args), delay); }, [delay]); } ``` Two constraints collide here. **The wrapper must be stable.** Debouncing works by a single pending timer being repeatedly cancelled. If a new wrapper with its own fresh timer variable is produced on each render, each call starts an independent timer and every one of them eventually fires — the opposite of debouncing. So the wrapper is memoized, and the timer id is held in a ref precisely because it must outlive the individual call and be reachable from the next one. **But a stable wrapper freezes its closure.** `useCallback(..., [delay])` means the function is created once and keeps the `fn` from that render. If `fn` is `() => save(draft)`, the wrapper will still be saving the first `draft` an hour later. This is the stale-closure trap, and the standard cure is the latest-ref pattern: keep the changing thing in a ref that is refreshed after every commit, and read `fnRef.current` at *call* time rather than capturing `fn` at *creation* time. Because refs are mutable boxes that do not participate in rendering, updating one does not invalidate the memoized wrapper. The write to `fnRef.current` goes in an effect rather than the body for the same purity reason that applies to any render-time mutation: a render can be abandoned or, in StrictMode, double-invoked. (React has an in-progress `useEffectEvent` hook intended to make this pattern a first-class API; until it is stable in your React version, the latest-ref pattern is the portable way to express "read this at call time, do not depend on it".) ## Unmount and leading edge The second effect returns a cleanup that clears any pending timer on unmount. Without it a debounced save fires after the user navigates away, touching state on a dead component and confusing the user about what got persisted. A leading-edge variant fires immediately on the first call and then suppresses for the delay window; it is the right choice for actions that must feel instant, like a button, while trailing-edge debounce is right for text input. Throttling is the neighbouring idea — trailing debounce fires once after the storm ends, whereas throttling fires at most once per interval *during* the storm. Choosing wrong is a real bug: a scroll handler that only reports after the user stops scrolling is useless for a progress indicator. ## What interviewers listen for That cleanup is what makes debouncing work rather than an afterthought; that the value version needs no refs at all; that the callback version needs exactly two refs and why each one exists; and that a timer must be cancelled on unmount.

  • Would `useMemo` be a correct way to create the debounced wrapper instead of `useCallback`?
    It works mechanically — a memoized factory returns a stable function — but it inherits the same stale-`fn` problem and adds a subtler one: useMemo is a performance hint React is allowed to discard, so a dropped cache would produce a new wrapper and orphan the pending timer. Keeping the timer in a ref rather than inside the memoized closure is what makes either version safe.
  • When would you reach for `useDeferredValue` rather than a debounce?
    When the cost you are avoiding is React rendering a large tree, not an external call. `useDeferredValue` lets React render the previous value at low priority and interrupt that work when new input arrives, so it adapts to the device instead of imposing a fixed wait. A debounce is right when each update triggers something genuinely expensive off-screen, like a network request.
  • Why does the value version need no ref for the timer id when the callback version does?
    Because in the value version each effect run owns exactly one timer, and its cleanup closure captures that local id directly — the id never has to be seen by any other invocation. In the callback version the timer is created by one call and must be cancelled by a later, independent call, so it needs storage that outlives a single invocation.
  • What breaks if the debounced callback is not cancelled on unmount?
    The timer still fires and calls a function closed over a component that no longer exists — typically a state setter that now does nothing, or worse, a save or navigation the user did not expect after leaving the screen. Returning a cleanup that clears the pending timer from a mount-scoped effect is the whole fix.

saying these in an interview costs you the question

  • Says the timer id must live in a ref even in the value version
  • Recreates the debounced wrapper every render and expects debouncing
  • Omits clearTimeout in cleanup and calls it 'just a small leak'
  • Confuses debounce with throttle when describing scroll handling
  • Puts fnRef.current = fn in the component body and calls it safe

context

open as a page

Write a React custom hook `useToggle(initial = false)` that returns a boolean and a function that flips it. Why should the flip call `setOn(v => !v)` instead of `setOn(!on)`?

level: juniorimportance: should knowfreq 52%

basics

~20 s

useToggle holds a boolean in useState and returns it with a toggler that calls setOn(v => !v). The updater form flips whatever the newest value is, so the toggle is still correct when the captured value is stale or two flips land in one event.

open as a page

Implement a React `usePrevious(value)` hook that returns the value this component had on its previous render. Where must the ref be written, and what does the hook return the first time it runs?

level: middleimportance: should knowfreq 46%

basics

~20 s

Store the value in a useRef and overwrite it inside a useEffect that runs after every render, returning ref.current first. Reading before the effect writes yields the previous render's value; on the first render it is undefined.

open as a page

Implement a React `useLocalStorage(key, initialValue)` hook that returns a value and a setter which also writes to localStorage. What does a naive implementation get wrong about reading storage, parsing, and server rendering?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Read storage in a lazy useState initializer, not on every render; wrap JSON.parse and every storage call in try/catch because parsing and quota both throw; support functional updates so the write matches the new state; and on the server, where localStorage does not exist, render the initial value and read storage after mount.

open as a page

Write a React `useOnClickOutside(ref, handler)` hook that closes a dropdown when the user clicks elsewhere. Which document event should it listen for, and how do you stop a new inline `handler` from tearing down and re-attaching the listener on every render?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Attach a document listener for pointerdown, ignore the event when ref.current contains event.target, and remove the listener in effect cleanup. Keep the handler in a ref refreshed each commit so the subscribing effect depends only on the ref, not on a handler identity that changes every render.

open as a page