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

When not to extract a hook

Extracting a custom hook has a carrying cost; do not pay it for a single caller, a one-line wrapper, or to share a file across unrelated state — inline code is often the senior choice, and a codebase of one-use hooks is unnavigable.

RXP Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The last two lessons taught you to extract custom hooks — and they work, so the reflex is to extract everywhere. A component has three useStates and an effect, and the instinct says “pull it into useThing”. You do, and the diff looks tidier. Six months later the repo has forty hooks, each used exactly once, and finding the code that runs a screen means opening five files to reassemble what used to be one readable function.

Extraction is not free. Every hook you create is a new name to learn, a new file to open, a layer of indirection between the reader and the behavior. A hook with one caller usually didn’t earn that cost. This lesson is the counter-pressure: the senior skill isn’t extracting hooks, it’s knowing which extractions pay for themselves and which are the same over-abstraction reflex this whole curriculum warns about, now wearing a use prefix.

Goal

After this lesson you can state that abstraction has a carrying cost and that a custom hook must justify it; recognize the three signals that an extraction is premature — a single caller, a thin one-line wrapper over a built-in, and bundling unrelated state together just to “share a file”; explain why inline code is frequently the clearer, more maintainable choice; and name the failure mode — a codebase of dozens of one-use hooks that nobody can navigate — so you reach for extraction only when reuse or genuine complexity is real, not speculative.

1

A custom hook has a carrying cost: a name, a file, and a layer of indirection — and that cost is real whether or not the hook is reused. Reading inline code, you see the behavior right where it runs. Reading useUserForm(), you see a name and must jump to its definition to learn what it actually does, then jump back. That jump is cheap once; multiplied across a screen built from six one-use hooks, the reader is reassembling a jigsaw. Extraction trades locality (everything in one place) for a name (a label you can reuse). The trade only wins when the name is reused or the inline version is genuinely too big to read.

// inline: behavior is visible exactly where it runs — zero indirection
function ProfileForm() {
  const [name, setName] = useState("");
  const [bio, setBio] = useState("");
  // a reader sees the whole form's state right here
}

The default isn’t “extract or don’t” — it’s “extraction must buy back more than the indirection it costs”.

2

Signal one: a single caller. A hook used in exactly one place is almost always premature. The entire point of a custom hook is to share stateful logic between components. With one caller there is nothing to share — you’ve moved code to another file and gained a name, but paid the indirection and bought no reuse. The honest version keeps it inline until a second caller actually appears, at which point the extraction is motivated by real duplication, not a guess.

// premature: useProfileForm has one caller — this is just ProfileForm's body in a different file
function useProfileForm() {
  const [name, setName] = useState("");
  const [bio, setBio] = useState("");
  return { name, setName, bio, setBio };
}
function ProfileForm() {
  const { name, setName, bio, setBio } = useProfileForm(); // why is this not just here?
}

“I might reuse it later” is YAGNI in hook form. Extract on the second use, not the first guess — the refactor is a two-minute job when the second caller is real, and free of cost until then.

3

Signal two: a thin one-line wrapper. Wrapping a single built-in hook in a custom hook adds a name without adding meaning. useState(false) is already a complete, idiomatic statement that every React developer reads instantly. Renaming it useBoolean buys nothing — the reader now has to learn your name for a thing the platform already named, and look up whether your wrapper added behavior or just forwarded. A custom hook earns its name when it encapsulates non-trivial logic: an effect with cleanup, a subscription, a derived computation, a multi-step state machine. A pass-through is pure tax.

// thin wrapper: adds a name and a file, encapsulates nothing
function useBoolean(initial = false) {
  const [v, setV] = useState(initial);
  return [v, () => setV(true), () => setV(false)] as const; // barely more than useState
}

// at the call site this reads no better than the built-in, and worse for a new reader
const [open, openIt, closeIt] = useBoolean(); // vs: const [open, setOpen] = useState(false)

If the hook body is shorter than its own signature plus the call site, you’ve abstracted nothing.

4

Signal three: bundling unrelated state to “share a file”. Stuffing independent concerns into one hook couples things that have no reason to change together. A hook that returns { user, theme, cartCount } because those values happened to live in one component creates a false dependency: a change to cart logic now touches the hook that the theme code also imports, and any consumer of one field pulls in all of it. Cohesion, not file count, is the metric. Unrelated state should stay separate — three small focused hooks (or three inline useStates) beat one grab-bag hook whose only organizing principle is “these existed near each other”.

// coupled grab-bag: theme, auth, and cart have nothing to do with each other
function usePageState() {
  const [user, setUser] = useState<User | null>(null);
  const [theme, setTheme] = useState<"light" | "dark">("light");
  const [cartCount, setCartCount] = useState(0);
  return { user, setUser, theme, setTheme, cartCount, setCartCount };
}
// now the theme toggle and the cart badge are change-coupled through one hook for no reason

A hook should have one coherent job. “Lives in the same component” is not a job.

Worked example

A “helpful” extraction, and the inline version that reads better. A teammate ships a modal and extracts its open/close state into a reusable hook “so future modals can use it”:

// the extracted hook — looks reusable, used in exactly one component
function useModal() {
  const [isOpen, setIsOpen] = useState(false);
  const open = useCallback(() => setIsOpen(true), []);
  const close = useCallback(() => setIsOpen(false), []);
  return { isOpen, open, close };
}

function SettingsPanel() {
  const { isOpen, open, close } = useModal();
  return (
    <>
      <button onClick={open}>Settings</button>
      {isOpen && <Dialog onClose={close}>{/* … */}</Dialog>}
    </>
  );
}

Count what useModal actually bought here. There is one caller, so no reuse. It is a thin wrapperisOpen/open/close is useState(false) with two trivial setters renamed. To understand SettingsPanel, a reader must now open a second file to confirm open just sets true. The hook added a name, a file, and useCallback ceremony, and encapsulated nothing non-trivial. The inline version is shorter and clearer:

function SettingsPanel() {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <>
      <button onClick={() => setIsOpen(true)}>Settings</button>
      {isOpen && <Dialog onClose={() => setIsOpen(false)}>{/* … */}</Dialog>}
    </>
  );
}

Everything the component does is on screen; nothing to look up. The senior move is to leave it inline — and if a third modal later wants the same behavior plus, say, focus-trapping and Escape-to-close (real, non-trivial logic), then extract useModal, motivated by genuine reuse and complexity rather than a useState rename. Extracting now is the over-abstraction reflex: it makes today’s code worse to make a hypothetical future easier, and that future may never arrive.

Why this works

Why is “I’ll reuse it later” so persuasive and so often wrong? Because the cost is invisible at extraction time — the diff looks cleaner, you feel productive, and the future reuse feels likely. But the cost is paid continuously and by other people: every reader of the one caller now context-switches through your file, and every one-use hook makes the next “should I extract?” decision feel more normal, so they accumulate. The asymmetry is the key: extracting later, when a real second caller appears, costs two minutes and is driven by evidence. Extracting now, speculatively, costs every future reader and is driven by a guess. Cheap-to-defer plus guess-driven means defer is the default.

Common mistake

The reflex mistake is treating “extract a hook” as unconditionally good — the same over-abstraction instinct this curriculum keeps flagging, just in React clothes. Symptoms: useBoolean, useToggle, useString, useCounter wrapping single built-ins; a directory of hooks each imported by exactly one component; a usePageState that returns ten unrelated fields. The fix is not “never extract” — extraction is the right tool for real shared, non-trivial logic. The fix is to demand a reason before paying the cost: a second caller that exists, or logic too complex to read inline. If neither is true, inline is the senior answer, and the cleaner-looking extraction is a regression you’ll pay for at every future read.

Check yourself
Quiz

A teammate extracts a component's single piece of open/close state into a useModal() hook used in exactly one place, returning isOpen/open/close that just wrap useState(false). By this lesson's reasoning, is that a good extraction?

Recap

Custom hooks are a real tool, but extraction has a carrying cost — a name to learn, a file to open, a layer of indirection between reader and behavior — and that cost is paid whether or not the hook is reused. Three signals mark an extraction as premature: a single caller (nothing to share, so it’s just the component’s body in another file — YAGNI), a thin one-line wrapper over a built-in like useState (a rename that encapsulates nothing), and bundling unrelated state into one grab-bag hook just to share a file (false coupling; cohesion, not file count, is the metric). In all three, inline code is clearer because the behavior stays where it runs. The failure mode is treating extract-a-hook as unconditionally good and ending up with a codebase of dozens of one-use hooks nobody can navigate — the same over-abstraction reflex in React form. The senior rule: extract on a real second caller or genuinely complex logic, not a guess. The refactor is cheap to defer and evidence-driven when it’s real; speculative extraction is expensive forever.

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.