Reusing logic with custom hooks - discussion (2023-01-20)
This page is often used as justification for extracting logic into hooks, but I still see hooks become invisible state machines (hidden effects, hidden writes, hidden assumptions). I'm curious what rules people use to keep hooks honest as a contract surface instead of a grab bag.
What do you allow custom hooks to do (effects, writes, subscriptions), and what do you forbid? When do you stop extracting hooks and move behavior into a component boundary for legibility? How do you test hooks that encode motion without snapshotting timing?
Comments (18)
Back to latestOur rule: hooks can compute and format, but state writes are explicit callbacks passed in.
If a hook writes internally, it needs a really clear contract and evidence output.
A small example of a read-only hook that stays honest:
ts
export function useVisibleIds(items: Array<{ id: string; title: string }>, q: string) {
const query = q.trim().toLowerCase();
return query ? items.filter((x) => x.title.toLowerCase().includes(query)).map((x) => x.id) : items.map((x) => x.id);
}
Then the caller chooses whether to store derived ids or recompute.
Counterpoint: some hooks should write state, otherwise you just end up duplicating wiring everywhere.
But the hook should return a status lane so behavior is observable, not just "it does stuff".
Agree. Hidden writes are the problem, not writes per se.
If the hook returns a lane + evidence, it becomes reviewable.
We prefer component boundaries when the behavior has lifecycle and UI implications (loading, errors, retries).
Hooks are better when they're purely calculation or event glue.
Testing hooks got easier once we stopped asserting on timing and started asserting on evidence outputs (status flags, derived values).
If the hook doesn't expose evidence, it probably isn't reusable.
We also avoid hooks that implicitly read route state/context because it hides dependencies.
If a hook depends on route posture, pass the posture key in explicitly.
A pattern we use for hooks with effects: the hook returns a debug string that the caller can render as evidence.
It keeps behavior visible without leaking implementation details.
Counterpoint: too much hook extraction can be premature; you need the surface to stabilize first.
We try to inline the first version in a component, then extract once the interface is obvious.
Same. If you extract too early, you freeze the wrong abstraction and it becomes painful to change later.
Hooks should be earned, not assumed.
Docs tie-in: hook extraction interacts with state discipline.
If siblings need consistent derived keys, extracting a hook that re-derives them in each consumer can cause drift.
Related: State Management (Best Practices) and Components and Composition.
A practical contract we use in code review: hooks declare reads/writes/effects in the PR description.
It's boring but it prevents "spooky" hooks.
We also limit hook returns: return a small object with named keys, not a giant tuple.
If the surface is named, it stays readable.
Code snippet: a hook that returns a lane (observable) while keeping writes explicit:
ts
type Lane = 'idle' | 'pending' | 'error' | 'ok';
export function useSubmitLane() {
const [lane, setLane] = useState<Lane>('idle');
const submit = async (fn: () => Promise<void>) => {
setLane('pending');
try { await fn(); setLane('ok'); } catch { setLane('error'); }
};
return { lane, submit };
}We found a lot of hooks should really be small modules (pure functions).
If there's no React lifecycle, it doesn't need to be a hook.
A misunderstanding I see: hooks are treated as "free reuse" but they can hide coupling.
Passing inputs explicitly and returning evidence makes the coupling visible.
Counterpoint: sometimes deep props are simpler than inventing hooks/context.
Inside a single route shell, prop threading can be the clearest contract.
Agreed. The more "global" you make a hook, the more careful you need to be about ownership and evidence.
Props are honest about dependencies.
If you want hooks to stay healthy, require two things: small surface area and observable behavior.
Everything else is negotiable.
The best custom hooks in our codebase read like utilities with a small state lane attached. The worst ones are mini frameworks.
We started tagging hooks in our docs by whether they run effects.
That alone made people more careful about where they used them.