How to Build a Custom Hook That Actually Earns Its Abstraction

Not every custom hook earns its existence.
I've reviewed codebases where a useTheme hook wraps a single useContext call. A useToggle hook wraps a single useState. A useCurrentUser hook wraps another useCurrentUser hook from a different file.
Each of these is a function with a use prefix that adds a layer of indirection without adding any value. The component that calls it is no simpler than if the hook didn't exist.
This is the question worth asking before you extract any hook: does this reduce cognitive load, or does it just reduce line count?
Those are not the same thing.
The test
A custom hook earns its abstraction when it satisfies at least one of these:
- It encapsulates multiple hooks that belong together logically
- It hides implementation complexity that callers shouldn't need to think about
- It's genuinely reusable across components with different contexts
A hook that wraps one other hook satisfies none of these. A hook that orchestrates three hooks, manages a side effect, and exposes a clean interface satisfies all three.
Let's look at three hooks that pass the test.
useDebounce
Debouncing is a pattern that appears constantly — search inputs, form validation, resize handlers. The implementation involves useEffect, a setTimeout, a cleanup function, and careful handling of the dependency array. Every developer gets it slightly wrong the first time.
The caller doesn't see useEffect. Doesn't see setTimeout. Doesn't need to write the cleanup function. They write this:
That's the abstraction earning its place. The complexity is real, it repeats across components, and the hook hides it cleanly.
useLocalStorage
Syncing state to localStorage sounds simple. In practice it involves: checking whether localStorage exists (server-side rendering), parsing JSON safely, handling parse errors, serializing back on update, and keeping the React state in sync with the stored value.
The caller gets an interface that looks exactly like useState:
Same API. Zero boilerplate. The SSR check, the JSON parsing, the error handling — invisible to every component that uses it.
useIntersectionObserver
The IntersectionObserver API is powerful but verbose. Setting it up requires a ref, an effect, a callback, and cleanup. Done inline, it's twelve lines in every component that needs it.
The caller gets a ref and a boolean:
The useEffect, the IntersectionObserver instance, the cleanup — none of it appears at the call site.
What makes a hook interface good
All three hooks above share a design pattern worth naming.
The input is the minimum needed. useDebounce takes a value and a delay — nothing else. useLocalStorage takes a key and an initial value. The caller isn't forced to pass configuration they don't care about.
The output matches the use case. useLocalStorage returns a tuple that mirrors useState — experienced React developers understand it immediately. useIntersectionObserver returns an object because it has two distinct return values that need names.
The hook doesn't reach outside its responsibility. useDebounce debounces. It doesn't also fetch. useLocalStorage syncs state to storage. It doesn't also validate. Hooks that try to do too much are harder to test and harder to reuse.
Naming hooks so the intention is clear
The name of a custom hook is the first thing a reader sees. It should communicate what the hook does, not how it does it.
If you find yourself including implementation details in the hook name — useEffectWithCleanup, useRefCallback, useContextConsumer — the name is a signal that the abstraction isn't quite right yet.
The hooks not worth extracting
For completeness: the hooks that fail the test.
useToggle is two characters of savings. useTheme is just an alias. useCurrentUser hides that you're using TanStack Query — useful if you might swap libraries, actively harmful if you're not planning to.
Extract when the complexity is real and the interface is cleaner. Not before.
Testing the hook
A well-designed hook is straightforward to test with renderHook from React Testing Library:
If a custom hook is hard to test, it's usually a sign the interface is too complex or it's doing too much. Testability is a design signal, not just a quality gate.
A custom hook that earns its abstraction makes the component that uses it simpler, more readable, and easier to change. One that doesn't adds a file, a function call, and a layer of indirection that every future reader has to mentally unwrap.
The complexity has to go somewhere. The question is whether the hook is the right place for it.
Up next: React context vs Zustand: when each one actually makes sense — the performance myth, the real tradeoffs, and the decision rule.
Related: How to test custom hooks with React Testing Library — the full testing guide for the hooks you build.
Alex Chen is a senior frontend engineer who writes about React patterns, JavaScript internals, and the decisions that separate maintainable codebases from ones that fight back. Opinionated by design.
Photo by Barn Images on Unsplash

