Reusing logic with custom hooks - discussion (2022-08-13)
We have a growing pile of custom hooks and the quality variance is getting painful: some are small and clarifying, others are basically mini-frameworks.
What criteria do you use before extracting a hook (API surface, stability, testing story)? How do you keep hooks from hiding effects, network calls, or identity churn that ripples through the tree? Do you treat hook output as a contract surface (and render some observable signal when it matters), or is that too much?
Comments (10)
Back to latestIf a hook returns 8 things, it's usually a module pretending to be a hook.
We got hooks under control once we treated them like APIs: small surface, stable behavior, and observable when they affect the contract.
Related: API Reference and Performance and Rendering Best Practices.
A pattern that avoids identity churn: return a stable surface and make actions stable on purpose.
tsx
type Surface<TState, TActions> = { state: TState; actions: TActions; debug: { version: string } };
export function useFiltersSurface() {
const [q, setQ] = useState('');
const setQuery = useCallback((next: string) => setQ(next), []);
const actions = useMemo(() => ({ setQuery }), [setQuery]);
const state = useMemo(() => ({ q }), [q]);
return { state, actions, debug: { version: 'v1' } } as const;
}
If you need it observable, render debug.version in the shell or in a dev-only panel. The important part is your surface is stable.
Counterpoint: I think teams extract hooks too early.
If you can't name what the hook owns (posture, selection, fetch lane), keep it inline until the surface stabilizes.
Fair. Our problem is more the opposite: people keep adding features to existing hooks because it's convenient.
Then the hook becomes a dumping ground and nobody can reason about it.
Yep, that's the hook-as-framework failure mode. Better to split by surface ownership.
The hooks I ban are the ones that call fetch internally and return data/loading/error with no visibility. If fetch is part of the render contract, I'd rather see it at the route boundary with an explicit cache lane.
ts
function useStableHookSurface() {
const [q, setQ] = useState('');
const actions = useMemo(() => ({ setQ }), [setQ]);
const state = useMemo(() => ({ q }), [q]);
return useMemo(() => ({ state, actions }), [state, actions]);
}We treat the hook's returned shape as a surface contract.
If the shape changes, we call it a contractVersion bump and we update any UI signals/tests that depend on it.
The worst bugs we had were timing bugs hidden inside hooks.
Once we required effects inside hooks to have boring cleanup and a visible signal when they change behavior, the quality jumped.
Naming is 80% of it. If the hook name is vague, the hook surface is usually vague.
If you can't debug the hook from a screenshot + a couple rendered signals, you'll debug it by reading code every time. That doesn't scale.
One practical rule: hooks can't write to global-ish state without an owner and a reason string.
Otherwise you get accidental coupling and nobody knows why the tree is rerendering.