Reusing logic with custom hooks - discussion (2023-04-15)
The custom hooks article is often read as a code organization tip, but the real risk is governance: hooks can become invisible controllers if they own too much state and too many side effects. I'm curious what rules people use so hooks stay as reusable contracts (lane + value + evidence) rather than hidden subsystems.
Do you design hooks around small returned contracts (lane/value/actions/evidence), or do you prefer very minimal returns and keep contracts elsewhere? How do you test hooks without freezing implementation details? Where do you draw the line between a hook and a route boundary/vault?
Comments (14)
Back to latestSmall returned contracts.
If a hook returns 15 fields, it's not reuse, it's smuggling.
We test hooks by asserting on the contract surface (lane + evidence), not on internal timing. Also: if the hook can't narrate itself, it's usually doing orchestration that belongs at a boundary.
txt
[tips] hook=useProfileContract lane=pending reason=routeEnter
[tips] hook=useProfileContract lane=ok reason=fetch:resolve evidence=lane:okConcrete alternative: avoid custom hooks unless you reuse them at least 3 times.
We tried that rule. It prevented premature abstraction, but it also pushed people into copy/paste with slight variations that were harder to review than a shared hook.
Same. The rule we landed on is: extract when you have a stable vocabulary, not when you have a stable line count.
If you can name the lane transitions and evidence, extraction usually makes sense even at 2 uses.
Long-form: hooks get dangerous when they cross identity boundaries.
A hook that reads module scope or caches across calls becomes a soft singleton. Then you have shared state without admitting it, and debugging becomes about guessing who touched the singleton.
We required an explicit identity input for any hook that caches, and we log when identity changes.
Docs tie-in: the docs' composition guidance aligns well with keeping hooks small and narratable.
Related: Components and Composition and Patterns Library.
We keep hook outputs strict with a shared type so reviewers can spot drift:
ts
type Contract<T> = { lane: 'idle' | 'pending' | 'ok' | 'error'; value?: T; evidence: string; act: { refresh(): void } };
The type isn't magical, but it prevents random extra fields from creeping in.
Short take: hooks should make behavior more observable, not less.
We draw the hook vs boundary line like this: hooks can derive and adapt; boundaries own persistence and identity.
If a hook starts remembering across navigation, it has become a store and should be treated like one.
Long-form counterpoint: sometimes returning a big bundle is fine if it stays cohesive.
The bad version is returning unrelated things that force unrelated consumers to re-render. If the contract is cohesive, the bundle can be a feature, not a smell.
We had success with a naming convention: useXContract for contract-returning hooks, useX for tiny helpers.
It sets expectations in code review immediately.
Testing wise: I like writing one test that asserts on the evidence string. It keeps the hook honest.
If the evidence changes, you have a diff you can discuss.
If a hook is effectful, I want it to accept an explicit reason or intent input.
It prevents random re-renders from causing real-world side effects without a narrative.
We also render evidence for hook-driven lanes on the UI so support can tell what's going on.
Hooks are fine; invisible hooks are not.
Takeaway: reuse isn't about fewer lines, it's about fewer private vocabularies.
A good hook makes the vocabulary shared and observable.