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
- useCopyToClipboard HookCopy text with navigator.clipboard, fallback execCommand, transient copied state, and permissions that fail in insecure contexts.
- useDebouncedValue HookDebounce a changing value in React without firing effects on every keystroke. Cleanup, leading vs trailing, and the stale timer that updates unmounted state.
- useEventListener HookAttach a window, document, or element listener with a stable handler ref so you do not rebind on every render. Capture option and target changes.
- useFetch With AbortControllerFetch in an effect with a signal, ignore aborted errors, and prevent the slow response from winning a race after the url changes.
- useIdle HookDetect idle users: reset a timer on input events, fire idle after timeout, and decide whether mount starts the timer immediately.