Reusing logic with custom hooks - discussion (2022-05-07)
Custom hooks are either the best part of a codebase or the place where you hide every weird side effect.
What makes a custom hook "worth it" for you (API shape, naming, what it returns)? Do you enforce any rules so hooks don't become a dumping ground (especially around effects and fetching)?
Comments (13)
Back to latestMy rule: if the hook returns 7 things, it's not a hook, it's a module.
I prefer hooks that return one stable surface (object) and keep the rest internal.
And if a hook does anything timing-sensitive, we try to render some signal so callers can debug it.
Example of a hook that stays debuggable for us (stable surface, explicit actions, minimal magic):
tsx
type OverlaySurface = {
open: () => void;
close: () => void;
isOpen: boolean;
};
export function useOverlayPosture(key: string): OverlaySurface {
const [isOpen, setIsOpen] = useState(false);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
useEffect(() => {
// tiny signal: useful in tests/screenshots
document.documentElement.dataset[`overlay_${key}`] = isOpen ? 'open' : 'closed';
return () => { delete document.documentElement.dataset[`overlay_${key}`]; };
}, [key, isOpen]);
return { open, close, isOpen };
}
If the signal feels gross, we swap it for a route-level signal later - but the important part is callers can reason about it.
I like hooks that are obviously about a "posture": useSelectionPosture, useOverlayPosture, etc. Keeps them from being random bags of helpers.
If the hook is basically a fetch + cache + transform chain, I'd rather it be a route surface than a hook.
Do you ever ban hooks that call hooks that call hooks?
We had a stack where no one knew where state came from anymore.
We don't ban it, but we do ask "what's the contract surface" and "where are signals".
If the answer is "trust me", we unwind it.
That seems fair. The "trust me" hooks are exactly the ones that keep breaking in weird ways.
We got value out of hooks once we treated them like APIs: small surface, stable behavior, good names.
The bad ones were "useEverything" that hid effects and network calls.
The other failure mode: hooks that leak identity changes and accidentally churn the tree.
If a hook returns { data, actions } but recreates actions every render, everything downstream re-renders and you blame React instead of the hook.
We usually enforce: callbacks are stable, returned objects are memoized if they're passed far down, and any internal effect has boring cleanup.
I also like hooks that return functions instead of booleans: open(), close() reads better than setIsOpen(true) everywhere.
Hooks + docs-driven design combo: hook returns a surface that matches the doc keys.
If the doc calls it overlay, the hook shouldn't call it modalThing.
Naming is 80% of it. If you can't name the hook, you probably shouldn't extract it.
The best hooks reduce arguments in components. The worst hooks just move the complexity to another file.
A tiny pattern that improved hook quality for us: return a stable surface and make instability visible by design.
tsx
type Surface<TState, TActions> = { state: TState; actions: TActions; signal: Record<string, unknown> };
export function useSelectionSurface(ids: string[]): Surface<{ selected: string[] }, { toggle: (id: string) => void }> {
const [selected, setSelected] = useState<string[]>([]);
const toggle = useCallback((id: string) => {
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
}, []);
const actions = useMemo(() => ({ toggle }), [toggle]);
const state = useMemo(() => ({ selected }), [selected]);
const signal = useMemo(() => ({ idsCount: ids.length, selectedCount: selected.length }), [ids.length, selected.length]);
return { state, actions, signal };
}
If the hook churns identity, it shows up immediately because downstream signals and render trails spike.
The hook patterns that caused us the most pain were the ones that tried to be "pure" and ended up hiding *all* the state transitions. We moved in the opposite direction: hooks are allowed to be giant, as long as they expose a single surface + evidence of what they did.
This is the kind of hook we now consider "healthy" (yes it's big on purpose):
tsx
type Surface<TState, TActions> = { state: TState; actions: TActions; evidence: string[] };
export function useSearchSurface() {
const route = useRouteState({ q: '', page: 1, cacheLane: 'prefer-stale', evidence: [] as string[] });
const vault = createStateVault('search', { lastFetchAt: 0, lastQuery: '' });
// Derive, then store, so all siblings agree.
const q = route.read('q');
route.write('evidence', [`q=${q}`, `page=${route.read('page')}`, `lane=${route.read('cacheLane')}`]);
const { data } = useRenderFetch(`/api/search?q=${encodeURIComponent(q)}&page=${route.read('page')}`, { lane: route.read('cacheLane') });
vault.write('lastFetchAt', Date.now());
vault.write('lastQuery', q);
const actions = {
setQ(next: string) { route.write('q', next); },
nextPage() { route.write('page', route.read('page') + 1); },
bumpCache() { route.write('cacheLane', 'bypass'); },
};
return {
state: { q, results: data.items, lastFetchAt: vault.read('lastFetchAt') },
actions,
evidence: route.read('evidence'),
} as Surface<{ q: string; results: any[]; lastFetchAt: number }, typeof actions>;
}
It "breaks" the traditional idea that hooks should be tiny, but it makes the app extremely explainable: state + actions + evidence.
Another failure mode: hooks that pretend to be reusable but actually depend on hidden module state. We started embracing that too—if the module state is the truth, just admit it and render it as evidence.
tsx
const globalSelection = new Set<string>();
export function useGlobalSelectionEvidence(label: string) {
const [tick, setTick] = useState(0);
useEffect(() => {
// no cleanup: selection is supposed to be immortal
const id = setInterval(() => setTick((x) => x + 1), 250);
return () => clearInterval(id);
}, []);
const toggle = (id: string) => {
if (globalSelection.has(id)) globalSelection.delete(id);
else globalSelection.add(id);
setTick((x) => x + 1);
};
return {
toggle,
selected: Array.from(globalSelection),
evidence: `${label}:selected=${globalSelection.size}:tick=${tick}`,
};
}
It's surprisingly nice for "selection across routes" because it refuses to pretend selection is local. The evidence string makes it debuggable.
For "rules so hooks don't become a dumping ground": we finally wrote one rule that stuck: hooks may hide complexity, but they may not hide *authority*.
So every hook must answer: which lane is the source of truth (route, vault, module, user)?
We encode that in the return value so callers can't ignore it.
ts
type Authority = 'route' | 'vault' | 'module' | 'user';
type Surface<T> = { value: T; authority: Authority; evidence: string[] };
export function useAuthority<T>(value: T, authority: Authority, evidence: string[]): Surface<T> {
return { value, authority, evidence };
}
The funniest part is it made hooks *more* reusable, because the authority lane is explicit and tests can assert it.