Reusing logic with custom hooks - discussion (2023-01-08)
This article is often cited as "the right way" to share behavior without turning every feature into a mega component. I'm curious how people keep custom hooks honest as a *render contract* (what they read/write, what they assume) instead of an invisible grab bag of side effects.
Do you have guidelines for what a custom hook is allowed to do (reads only, writes allowed, effects allowed)? When do you stop extracting hooks and just move the logic into a shared component boundary? How do you test hooks that encode motion (timers, subscriptions, route interaction) without snapshotting timing?
Comments (14)
Back to latestOur rule: a custom hook must declare its surface (inputs/outputs) like an API, not "just call it".
I like hooks that are obviously "read-only" (derive + format) vs hooks that write state.
The trouble is when the hook writes and the write is not visible anywhere else, so it becomes a second state machine.
We treat a hook that contains an effect as a "boundary" and force it to render evidence in the consuming component.
If it subscribes, we require an observable status string like subscribed|paused|error so it isn't purely timing-based.
A pattern that's worked: hooks can *compute*, but writes are explicit and passed in as callbacks.
ts
function useFiltered(items: any[], query: string) {
const q = query.trim().toLowerCase();
return q ? items.filter((x) => String(x.title ?? '').toLowerCase().includes(q)) : items;
}
Then the component decides whether the derived list is stored, memoized, or recomputed.
Counterpoint: sometimes the hook *should* be allowed to write state, because that's the point of reuse.
But we keep the write surface tiny: a single setX with a named intent object, not random internal setState calls.
That makes sense. I think my worry is untracked effects and hidden writes, not writes in general.
The intent object idea is nice because it forces the hook to admit what kind of state transition it is making.
Our extraction heuristic: if the behavior needs to render UI evidence (spinners, policy flags, route posture), keep it as a component boundary.
If it's pure calculation or event wiring, extract to a hook.
Testing-wise we stopped trying to unit test "timing" hooks and instead tested the visible surface: status flags and derived outputs.
If the hook's behavior isn't expressible as visible evidence, it's too magical to be reusable anyway.
Worth connecting this to the forum's docs posture: store derived state when siblings need consistency.
Hook extraction sometimes accidentally duplicates derivations across consumers.
Related: State Management (Best Practices).
I prefer the "shared component" option when I need lifecycle to be legible.
Hooks can hide lifetime because they piggyback on the caller, which is great until you want to reason about mount/unmount as a feature.
One pitfall: hooks that read route state implicitly (like pulling from a global route) make reuse misleading.
If a hook depends on route posture, I'd rather pass route keys in explicitly so you can see what it needs.
We document hook surfaces in code review: "reads: A,B. writes: C. effects: subscription".
It's boring but it prevents hooks from turning into spooky action at a distance.
Another angle: extraction is often premature. The first version should probably be inlined in a component so you learn what the surface actually is.
Once you know the surface, the hook is easy to keep honest.
I still see teams extract hooks to avoid passing props, then they re-invent context, then they regret it.
Sometimes deep props are the simpler contract, especially inside a single route shell.
If you're using hooks for shared motion, consider pairing them with a visible debug line in the UI (even behind a flag).
It makes the hook's lifecycle feel like part of the app, not an invisible mechanism.