Custom Hooks Design
Extract reusable stateful logic with use* hooks — API design, composition, testing seams, and footguns that recreate render bugs.
- react
- hooks
- custom-hooks
- architecture
A custom hook is a function whose name starts with use and that calls other hooks. It lets you share stateful logic — not JSX — across components without HOCs or render props.
function useOnlineStatus() {
const [online, setOnline] = useState(true);
// effects…
return online;
}
Good custom hooks feel like language features of your app: useUser(), useDebouncedValue(value, 300), useFetch(url). Bad ones hide effects, return mystery tuples, or break the rules of hooks by calling hooks conditionally inside.
Official guide: Reusing Logic with Custom Hooks.
The problem they solve
Two components need the same subscription + state machine:
// Duplicated in Header and StatusBadge:
useEffect(() => {
const on = () => setOnline(true);
// …
}, []);
Extract once:
function useOnlineStatus() {
const [online, setOnline] = useState(
() => (typeof navigator !== 'undefined' ? navigator.onLine : true)
);
useEffect(() => {
const on = () => setOnline(true);
const off = () => setOnline(false);
window.addEventListener('online', on);
window.addEventListener('offline', off);
return () => {
window.removeEventListener('online', on);
window.removeEventListener('offline', off);
};
}, []);
return online;
}
function Header() {
const online = useOnlineStatus();
return <span>{online ? 'Connected' : 'Offline'}</span>;
}
Each component call gets its own state and effects — hooks are not singletons. Shared global state still needs context or an external store.
Design rules that hold up
1. Name with use and a verb/noun that states the product concept
useAuth,useMediaQuery('(min-width: 800px)'),useLocalStorage(key, initial)- Avoid
useUtils,useStuff,useManager
2. One hook, one job
Split useDashboardDataAndThemeAndWebSocket into focused hooks. Compose them in the component or in a higher-level hook that orchestrates clearly.
3. Return a stable, obvious API
| Style | Example | Use when |
|---|---|---|
| Single value | const online = useOnline() |
One primary result |
| Object | const { data, error, retry } = useQuery(…) |
Named fields, optional bits |
| Tuple | const [value, setValue] = useToggle(false) |
useState-like symmetry |
Objects are friendlier when returning more than two things. Document whether the object identity is stable (useMemo) if consumers depend on referential equality.
4. Accept only what the hook needs
// Prefer
useUser(userId);
// over dumping the whole props bag
useUser(props);
Narrow inputs make the hook testable and tree-shake reasoning easier.
5. Own cleanup
If you subscribe, connect, or set timers, return cleanup from useEffect inside the hook so every consumer gets it free. See useEffect Fundamentals.
Composition example: debounce + fetch
function useDebouncedValue(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
function useSearchResults(query) {
const debounced = useDebouncedValue(query, 300);
const [state, setState] = useState({ status: 'idle', data: [], error: null });
useEffect(() => {
if (!debounced) {
setState({ status: 'idle', data: [], error: null });
return undefined;
}
const controller = new AbortController();
setState((s) => ({ ...s, status: 'loading', error: null }));
fetch(`/api/search?q=${encodeURIComponent(debounced)}`, {
signal: controller.signal,
})
.then(async (res) => {
if (!res.ok) throw new Error(String(res.status));
return res.json();
})
.then((data) => setState({ status: 'success', data, error: null }))
.catch((err) => {
if (err.name === 'AbortError') return;
setState({ status: 'error', data: [], error: err });
});
return () => controller.abort();
}, [debounced]);
return state;
}
Building blocks stay small; the product hook tells a story. Abort ties to Fetch API Fundamentals.
Context-backed hooks
const AuthContext = createContext(null);
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth requires AuthProvider');
return ctx;
}
This is the standard way to package context: private context module + public hook.
What custom hooks are not
- Not a way to share JSX structure — use components for UI.
- Not automatically shared state — two
useOnlineStatus()calls are two states (unless you read one context inside). - Not exempt from rules of hooks — no conditional
useX()inside. - Not dependency-injection containers for every service in the app — keep the surface human.
Testing seams
Custom hooks test well with small React test renderers or by testing a tiny harness component:
function Harness() {
const value = useDebouncedValue('a', 100);
return <output>{value}</output>;
}
Alternatively extract pure helpers from the hook (function nextState(state, action)) and unit test those without DOM. Reducers pair well (useReducer).
API footguns and better shapes
// Hard to evolve
return [data, error, loading, refetch, cancel, lastUpdated];
// Clearer
return { data, error, loading, refetch, cancel, lastUpdated };
// Surprising: sometimes number, sometimes object
return enabled ? count : { count, setCount };
// Stable shape always
return { count, setCount, enabled };
Document whether returned functions are stable (useCallback) so memoized children don’t thrash — useMemo and useCallback.
Conditional logic: branch inside, not around
// ❌
if (id) {
useResource(id);
}
// ✅
useResource(id); // hook no-ops or disables effect when !id
function useResource(id) {
useEffect(() => {
if (!id) return undefined;
// …
}, [id]);
}
Same structural rule as components: hooks always called, behavior branches.
Footguns
- HOC flashbacks — wrapping every component in ten hooks with unclear order. Prefer a few deep hooks.
- Silent shared mutable refs across hook calls without documentation.
- Fetching in hooks without abort — race conditions when inputs change.
- Returning new object literals every render without need, breaking memo children.
- Putting the hook in a condition after a refactor.
- Giant “god hooks” that import half the app — hard to test and tree-shake.
Interview angle
Prompt: “What are custom hooks? When do you extract one?”
Strong answer: “A custom hook is a use* function that composes other hooks to reuse stateful logic across components. I extract when two components share subscription, timing, or fetch patterns — not for sharing markup. Each call has independent state unless the hook reads shared context. I keep hooks pure in structure (always called), clean up effects, and return a clear value or object API.”
Live task: write useLocalStorage(key, initial) with SSR safety (typeof window) and storage event sync as a stretch goal.
Related on this site
- Rules of Hooks — non-negotiable structure
- useEffect Fundamentals — effects inside hooks
- Context API Basics — context + hook pair
- useReducer for Complex State — extract machines
- useRef for Values and DOM — latest-callback refs
- useMemo and useCallback — stable return values
Further reading
Related guides
- Rules of HooksWhy hooks must be top-level and only in React functions — call order, the linter, and the bugs that break when you cheat.
- useEffect FundamentalsWhen useEffect runs, how cleanup works, what belongs in the dependency array, and the bugs from treating it like componentDidMount.
- useReducer for Complex StateWhen useReducer beats useState — action tables, pure reducers, dispatch stability, context pairing, and migration patterns.
- useRef for Values and DOMMutable boxes that survive renders — DOM refs, instance variables, avoiding re-renders, and the ref-vs-state decision.
- Accessibility Patterns in ReactPractical React a11y: labels, focus management, keyboard, live regions, and composition patterns that stay accessible.