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

Extracting a custom hook

A custom hook names and isolates a slice of stateful logic so it is reusable and independently testable. Extract when the same state+effect sequence repeats, or has a clear nameable purpose — not for a single-caller useState wearing a new name.

RXP Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

You wrote a search box. It debounces the query, fetches results, tracks loading and error, and cancels the in-flight request on the next keystroke. Twelve lines of useState and useEffect sitting inside the component. It works. Then a second screen — a tag picker — needs the exact same debounce-fetch-cancel dance, and you find yourself copy-pasting those twelve lines. That paste is the signal.

A custom hook is just a function whose name starts with use that calls other hooks. But the point of one isn’t reuse for its own sake — it’s that you can take a tangled slice of stateful logic, give it a name like useDebounce or useUser, and lift it out of the component so it can be reused, read, and tested on its own. The skill isn’t writing the hook. It’s knowing which slices deserve to become one.

Goal

After this lesson you can recognize the two signals that justify extracting a custom hook — repeated state+effect logic across 2+ components, or a slice with a clear nameable purpose — and turn inline useState/useEffect code into a named hook like useDebounce(value) or useUser(id). You can also spot the failure mode: a single-caller hook that is just useState renamed, which adds indirection while gaining no reuse and no clarity.

1

A custom hook names and isolates a slice of stateful logic — that naming is the real product. Components answer “what does this render?”. A custom hook answers “what stateful behavior does this have?”. When you call const debounced = useDebounce(query, 300), the component reads as a sentence: debounce the query. The setTimeout, the cleanup, the dependency array all disappear behind a name that says what they collectively mean.

// inline: the component carries the mechanism
const [debounced, setDebounced] = useState(query);
useEffect(() => {
  const id = setTimeout(() => setDebounced(query), 300);
  return () => clearTimeout(id);
}, [query]);

// extracted: the component carries the intent
const debounced = useDebounce(query, 300);

Nothing about what runs changed. What changed is that the component no longer has to know how debouncing works to read clearly. The hook is a named boundary around stateful behavior.

2

Extract when the same state+effect+computed sequence appears in 2+ components — duplication is the most honest signal. Two components doing the identical debounce-fetch-cancel dance is a guarantee the logic has a shape worth naming. The win is concrete and double: one place to fix a bug (a missing cleanup, a wrong dependency) instead of two, and a name that lets every future reader skip re-deriving the mechanism.

// useUser(id): the same fetch/loading/error slice two screens needed
function useUser(id: string) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  useEffect(() => {
    let active = true;
    setLoading(true);
    fetchUser(id).then((u) => { if (active) { setUser(u); setLoading(false); } });
    return () => { active = false; }; // ignore the stale response
  }, [id]);
  return { user, loading };
}

Now <ProfileHeader> and <AccountMenu> both call useUser(id). The active flag that prevents a stale response from overwriting a newer one lives in exactly one place — fix it once, both callers benefit.

3

Extract when a slice has a clear, nameable purpose — even a single caller — because the name buys clarity, not reuse. Reuse is the common trigger, but it isn’t the only valid one. If a component mixes its rendering concern with a self-contained behavior that has a name — “track whether the window is online”, “read this from localStorage and keep it in sync” — pulling that out makes the component readable even with one caller. The test is whether the slice is a genuine concept, not whether it has two call sites.

// one caller today, but "are we online?" is a real, self-contained concept
function useOnlineStatus() {
  const [online, setOnline] = useState(() => navigator.onLine);
  useEffect(() => {
    const on = () => setOnline(true), off = () => setOnline(false);
    window.addEventListener("online", on);
    window.addEventListener("offline", off);
    return () => {
      window.removeEventListener("online", on);
      window.removeEventListener("offline", off);
    };
  }, []);
  return online;
}

The component that uses it now says const online = useOnlineStatus(); instead of housing four addEventListener lines. The concept earned its name.

4

The failure mode: a single-caller hook that is just useState renamed — pure indirection, no reuse and no clarity gained. This is the trap that makes “extract hooks” feel like cargo-culting. Wrapping one piece of state with no logic in a use-prefixed function reuses nothing (one caller) and clarifies nothing (there was no mechanism to hide). You’ve added a file and a layer to read for zero return.

// ❌ indirection with no payoff: this is useState wearing a costume
function useCount() {
  const [count, setCount] = useState(0);
  return { count, setCount };
}
// caller is now *harder* to read than `const [count, setCount] = useState(0)`

// ✅ this clears the bar: it isolates a real, nameable behavior with logic
function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((v) => !v), []);
  return [on, toggle] as const;
}

useCount hides nothing and reuses nothing — delete it. useToggle is borderline but defensible: it names a recurring intent and bundles the toggle logic so callers stop re-writing the (v) => !v updater. The line is behavior or concept, not I put it in a function.

Worked example

Extracting useDebounce from a real search component. Here is the before — a ProductSearch that debounces its query inline, with the debounce mechanism tangled into the rendering component.

function ProductSearch() {
  const [query, setQuery] = useState("");
  const [debounced, setDebounced] = useState(query);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(query), 300);
    return () => clearTimeout(id);
  }, [query]);

  const { results } = useSearch(debounced); // fetch off the debounced value
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ResultList items={results} />
    </>
  );
}

The trigger to extract arrives the moment a second component — a TagFilter — needs the same debounce. We name the slice useDebounce<T> (generic so it debounces any value, not just strings), and the mechanism leaves the component entirely.

function useDebounce<T>(value: T, delay = 300): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}

function ProductSearch() {
  const [query, setQuery] = useState("");
  const debounced = useDebounce(query, 300);
  const { results } = useSearch(debounced);
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ResultList items={results} />
    </>
  );
}

Three wins fall out. Reuse: TagFilter now calls useDebounce(tag) with no copy-paste. Clarity: ProductSearch shrank to its actual job — render an input and a list off a debounced value. Testability: useDebounce is a pure unit of behavior you can test with React Testing Library’s renderHook and fake timers, advancing time and asserting the returned value updates — without mounting a search UI. None of that was possible while the setTimeout lived inline. Crucially, the extraction was driven by the second caller. Had TagFilter never appeared and useDebounce had only one user, it would still clear the bar — debouncing is a genuine nameable concept — but a single-state slice with no logic would not.

Why this works

Why does extraction make logic testable in a way inline code isn’t? Because a hook is a function with inputs and outputs, you can drive it in isolation: renderHook((p) => useUser(p), { initialProps: "u1" }), mock fetchUser, assert result.current.loading flips to false with the right user. The stale-response guard (the active flag) becomes a thing you can test directly — rerender with a new id, resolve the old promise last, assert the new user still wins. While that logic is inline in a component, the only way to exercise it is to mount the whole component and simulate UI, which couples your behavior test to markup that has nothing to do with the fetch logic.

Common mistake

The seductive mistake is treating “starts with use” as the achievement. A hook that wraps a single useState with no added logic — useName, useCount, useIsOpen returning { value, setValue } — is the over-abstraction this lesson warns against: the caller reads worse than the inline useState, gains no reuse (one caller), and you now maintain an extra file. Don’t extract on novelty or on a vague feeling that “components should be thin”. Extract on a concrete second caller, or on a slice with real logic and a real name. If deleting the hook and inlining it makes the code clearer, it was never worth extracting.

Check yourself
Quiz

A teammate extracts useSelectedTab() from one component — it wraps a single useState<string> and returns { tab, setTab } with no other logic, and no other component uses it. By this lesson's test, is this a good extraction?

Recap

A custom hook names and isolates a slice of stateful logic so it becomes reusable and independently testable. Reach for extraction on one of two signals: the same state+effect+computed sequence appears in 2+ components (real duplication — one place to fix bugs, one name to read), or a single slice has a clear nameable purpose (a genuine concept like useDebounce or useOnlineStatus, where the name buys clarity even with one caller). The payoff is concrete: reuse without copy-paste, a component that reads as intent instead of mechanism, and behavior you can drive with renderHook instead of mounting UI. The failure mode is the mirror image — a single-caller hook that is just useState renamed, with no logic and no second user: it adds a file and a layer of indirection for zero reuse and zero clarity, and the honest move is to inline it. Extraction is driven by real duplication or a real concept, never by novelty or by a feeling that components “should” be thin.

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.