Reusing logic with custom hooks - discussion (2023-02-15)
The custom hooks article is one of those pieces that seems obvious until you see a codebase where every side effect moved into useWhatever() and nobody can answer where truth actually lives. I like hooks as a place to define a small vocabulary (lanes, intent, posture) as long as they don't become invisible controllers.
When a hook returns logic, what do you prefer: data only, actions only, or a contract bundle (lane + data + actions + evidence)? Where do you draw the line between a custom hook and a route document/vault? Do you log hook decisions as contract lines, or do you insist that hooks produce UI evidence keys instead?
Comments (12)
Back to latestI like hooks that return a contract bundle, but only when the bundle is small and named.
If the hook returns 17 fields, it's not reuse—it's hidden coupling.
We stopped fighting about "is this hook too big" when we started logging hook decisions as a stable vocabulary. Not logs for data—logs for decisions:
txt
[tips] hook=useSearchContract lane=pending reason=query:commit qLen=5
[tips] hook=useSearchContract lane=ok reason=fetch:resolve resultCount=27
If the hook can't narrate its own transitions, it's usually doing orchestration that belongs at the route boundary instead.
Counterpoint: console logs are not a substitute for UI evidence.
I prefer hooks that return an evidence string (or write a stable data-*) so the user/support can see truth without devtools.
Agree. I like logs as dev-only, evidence as product.
The best hooks make the evidence inevitable (lane -> evidence line).
Line between hook and vault for us: hooks can derive and adapt; vaults own identity and persistence.
If a hook starts "remembering" selection across navigation, it has become a store and should be treated like one.
Long-form: reusable logic is only useful if it preserves *contract clarity*. I've seen teams extract hooks that hide important boundaries (commit vs draft, pending vs ok, identity changes). The UI becomes calmer in code, but the product becomes more confusing because behavior is no longer narratable.
A hook is good when it makes behavior more consistent across components while still letting you say: - what lane you're in, - why you're in that lane, - what identity boundary you're operating on.
If the hook erases those answers, it isn't reuse—it's amnesia.
We keep hooks boring by insisting on a strict output shape:
ts
type Contract<T> = { lane: 'idle' | 'pending' | 'ok' | 'error'; value?: T; evidence: string; act: { refresh(): void } };
Then the hook cannot "smuggle" in extra responsibilities without reviewers noticing.
Counterpoint: sometimes lifting state to a parent is still the simplest reuse.
Hooks are great, but if the reuse is local and there's a clear parent, lifting can be clearer than introducing a hook surface.
Long-form counterpoint: hooks can become "soft singletons" if they read from module scope or cache across calls. If a hook has hidden global memory, you've created shared state without admitting it. That is a recipe for heisenbugs.
We required one invariant in code review: the hook must be referentially transparent given its inputs (or it must declare its external dependency as a contract and log it).
Docs tie-in: the patterns + composition guidance pairs well with this approach of small, narratable contracts.
Related: Components and Composition and Patterns Library.
We found hook reuse got better once we stopped trying to reuse UI state and started reusing *derived view models*.
View model hooks are less likely to become controllers, because they mostly compute and label.
A good hook makes testing easier because it standardizes evidence.
If every feature uses the same lane/evidence vocabulary, tests can assert on behavior without learning each feature's private language.
If you're unsure whether to extract a hook, try writing a single [tips] line that would explain its behavior.
If you can't, you're probably extracting something that isn't a stable contract yet.