React
medium
mid
How would you implement custom hooks to abstract reusable logic in React?
A custom hook is a function named `useXxx` that calls other hooks. Extract reusable stateful logic by composing built-ins: useState, useEffect, useRef, etc. Parameterize inputs, return a stable shape (`{ data, loading, error }` or `[value, setter]`), use useCallback for returned functions, document the contract. Examples: useDebounced, useFetch, useLocalStorage, useMediaQuery.
7 min read·~15 min to think through
Custom hooks are how you extract reusable stateful logic in React. Same rules as built-ins, no special API.
Anatomy
A custom hook:
- Is a function.
- Name starts with
use. - Calls other hooks (built-in or custom).
- Returns whatever the caller needs (a value, an array, an object).
That's it. No special registration.
Example 1: useToggle
tsx
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn(o => !o), []);
const set = useCallback((v: boolean) => setOn(v), []);
return [on, { toggle, set }] as const;
}
// usage
const [open, { toggle }] = useToggle();Example 2: useDebounced
tsx
function useDebounced<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}Example 3: useLocalStorage
tsx
function useLocalStorage<T>(key: string, initial: T) {
const [val, setVal] = useState<T>(() => {
if (typeof window === 'undefined') return initial;
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : initial;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(val));
}, [key, val]);
return [val, setVal] as const;
}Example 4: useFetch
tsx
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const ctrl = new AbortController();
setLoading(true);
fetch(url, { signal: ctrl.signal })
.then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); })
.then(d => { setData(d); setError(null); })
.catch(e => { if (e.name !== 'AbortError') setError(e); })
.finally(() => setLoading(false));
return () => ctrl.abort();
}, [url]);
return { data, loading, error };
}For production, use React Query instead.
Example 5: useMediaQuery
tsx
function useMediaQuery(query: string) {
const [matches, setMatches] = useState(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
const mql = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, [query]);
return matches;
}
const isMobile = useMediaQuery('(max-width: 768px)');Design principles
- Parameterize inputs. Don't hard-code URLs or keys inside.
- Return a stable shape. Predictable destructuring:
{ data, loading, error }or[value, setter]. - Wrap returned functions in useCallback so consumers can put them in deps without infinite loops.
- Side effects with cleanup. Subscriptions, listeners, timers — always return a cleanup.
- No UI. Hooks return data and callbacks, never JSX.
- Generic for flexibility. TypeScript generics on inputs and outputs.
- One responsibility. Don't bundle unrelated concerns.
Anti-patterns
- Hard-coding the URL or key: kills reusability.
- Returning unstable references: every render the consumer sees new functions/objects, breaks downstream memoization.
- God hook: bundles data fetching + UI state + URL parsing.
- Hidden context dep: hook reads from a Context, so it only works under a specific provider — document loudly.
Testing
tsx
import { renderHook, act } from '@testing-library/react';
test('useToggle', () => {
const { result } = renderHook(() => useToggle());
act(() => result.current[1].toggle());
expect(result.current[0]).toBe(true);
});When NOT to extract
If the logic isn't reused, inline it. Premature hook extraction is a real cost — abstractions that fit one caller often don't fit two.
Senior framing
Custom hooks are how React stays composable. Each hook is a contract — parameterize inputs, return a stable shape, encapsulate side-effects with cleanup. Write hooks like small libraries; they survive reuse without surprises.
Follow-up questions
- •Why do returned functions in custom hooks need useCallback?
- •When would you split a custom hook into smaller hooks?
- •How do you test a hook with renderHook?
Common mistakes
- •Hard-coding URLs/keys inside the hook.
- •Returning fresh objects/functions each render — breaks downstream memoization.
- •Bundling unrelated concerns in one mega-hook.
Performance considerations
- •Custom hooks compile to plain function calls — no overhead beyond the built-ins they invoke. The cost model matches inlining the logic.
Edge cases
- •Object/array params must be memoized by the caller or the hook's effects re-run forever.
- •SSR-safe hooks must guard window/localStorage access.
- •StrictMode double-mount means setup/teardown must be idempotent.
Real-world examples
- •react-use, usehooks.com, every library exports custom hooks: TanStack Query (useQuery), React Hook Form (useForm), Zustand (useStore), Mantine, Chakra UI.
Senior engineer discussion
Senior framing: custom hooks are public APIs. Naming, shape stability, error model, and observability matter. The same discipline that makes a good component makes a good hook — parameterize, return stable, encapsulate, document.