Every codebase has that one hooks file. Six hundred lines, one hook, eleven responsibilities, a useEffect nobody has read since 2023. It works, mostly, and everyone routes around it.
Custom hooks are the best abstraction React offers and the easiest one to ruin, because nothing forces them to stay honest. These five rules do.
One job per hook
If you cannot describe what the hook does without the word "and", it is multiple hooks. useUserData fetches. It does not fetch, debounce, cache, and log analytics. Composition happens at the call site, where the component combines useDebounced with useFetch and keeps both legible.
The test: when a bug report arrives, can you name the file from the symptom? With single-purpose hooks, you can. With soup, you open the file and start reading.
Return a shape, not a pile
Return an object with named fields. Arrays only work when the order is obvious and stable, which describes useState and almost nothing else. Ten loose values returned from one hook means every call site is a destructure waiting to break, and the return type needs a paragraph comment. That comment is the hook telling you it does too much.
Make dependencies honest
Every value the hook reads comes in through arguments. Hidden reads of module-level singletons, magic defaults that differ per call site, and "it knows the current route internally" make the hook behave differently depending on where it runs. The whole value of a hook is that its behavior is a function of its inputs. Keep it that way and hook bugs become reproducible instead of mystical.
Handle the race you know is there
If the hook does async work, it needs a cancel path. An AbortController aborted in the effect cleanup, or an ignore flag checked before setState. Without it: user types, dependencies change, the old slow response lands last and overwrites the fresh one. Every unfinished fetching hook ships this bug; the only question is whether a user finds it on a slow network day or you find it in review.
Test it like a component
renderHook from React Testing Library, mocked inputs, assertions across rerenders. The test is what catches rule four's race on purpose instead of in production. And a hook that resists being tested in isolation is announcing that it has too many responsibilities, which makes this rule a design review as much as a test.
Five rules, none of them clever. Small hooks with honest inputs, a clean return shape, a cancel path, and a test. If your codebase has a hooks file everyone fears, these are the five checks that take it apart.