React Custom Hooks: A Practical Best Practices Collection
A practical collection of best practices for building, testing, and shipping robust React custom hooks with patterns and code examples.
Image used for representation purposes only.
Why custom hooks matter
Custom hooks let you package behavior, not just UI. They turn messy effect logic, subscriptions, and async flows into small, composable units you can test and reuse. Done well, they make components declarative, reduce duplication, and set clear contracts for data, side effects, and lifecycle.
This guide is a practical collection of patterns, pitfalls, and code snippets to help you design robust React custom hooks that age well in real codebases.
When you should write a custom hook
- The same stateful logic appears in 2+ components.
- You need to separate “what” from “how” (e.g., “fetch user” vs. “how we fetch”).
- A concern crosses component boundaries (focus management, media queries, feature flags, analytics).
- You want a stable contract for data, status, and actions independent of UI.
Avoid creating hooks for one-off helpers that don’t manage state, subscriptions, or effects—plain functions are faster to read and test.
Design principles
- Single responsibility: one concern per hook. Compose hooks rather than cramming features.
- Clear contract: inputs in, outputs out. Prefer explicit parameters over reading globals.
- Predictable effects: subscribe in an effect, clean up in its return function. Keep effects idempotent.
- Stable references: return stable callbacks and memoized objects to prevent downstream re-renders.
- Escape hatches: allow options (e.g., debounce ms, event target) with sensible defaults.
- SSR-friendly: avoid window/document access during render. Guard in effects or lazy branches.
API shape and naming
- Prefix with use: useThing to signal hook semantics.
- Return tuples when order conveys meaning (e.g., [value, setValue]). Return objects for multiple named values for forward compatibility.
- Keep public return shape small and stable; avoid leaking implementation details.
- Expose state + actions, not internals. Example: return { data, error, isLoading, refetch }.
Dependency management without footguns
- Always enable the react-hooks/exhaustive-deps ESLint rule. Treat warnings as errors.
- Put sources, not derivatives, in dependency arrays. Compute derived values with useMemo.
- Store mutable, non-render-affecting values in refs to avoid triggering re-renders.
- Never conditionally call hooks; branch inside them, not around them.
Choosing state primitives
- useState for simple, local values. Use lazy initialization for expensive defaults.
- useReducer when state transitions are complex or action-driven.
- For external stores (cache, event bus), prefer useSyncExternalStore for consistency across concurrent rendering and to avoid tearing.
Performance patterns
- Return stable callbacks via useCallback or a ref-stabilized handler to avoid resubscribing parents.
- Memoize returned objects with useMemo so consumers don’t re-render on every tick.
- Debounce/throttle inside hooks, not in components, so the behavior is portable and testable.
- Avoid creating timers, observers, or subscriptions during render; do it in effects.
- If a hook subscribes to fast-changing sources, consider useSyncExternalStore to minimize unnecessary renders.
Async hooks: correctness over convenience
- Model state explicitly: { data, error, isLoading, isRefetching }.
- Cancel in-flight work on dependency changes or unmount using AbortController.
- Prevent race conditions by ignoring late results after a newer request starts.
- Surface an imperative refetch action for user-driven updates and retries.
Example: useAsync
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
type AsyncState<T> = {
data: T | null;
error: unknown | null;
isLoading: boolean;
};
type Options<TArgs extends unknown[]> = {
auto?: boolean; // run on mount/dep change
deps?: unknown[]; // re-run when these change (if auto)
onSuccess?: (data: unknown) => void;
onError?: (err: unknown) => void;
};
export function useAsync<T, TArgs extends unknown[]>(
fn: (...args: TArgs) => Promise<T>,
{ auto = true, deps = [], onSuccess, onError }: Options<TArgs> = {}
) {
const [state, setState] = useState<AsyncState<T>>({ data: null, error: null, isLoading: false });
const ctrlRef = useRef<AbortController | null>(null);
const seq = useRef(0); // prevent race conditions
const run = useCallback(async (...args: TArgs) => {
const id = ++seq.current;
ctrlRef.current?.abort();
const ctrl = new AbortController();
ctrlRef.current = ctrl;
setState(s => ({ ...s, isLoading: true, error: null }));
try {
const result = await fn(...args.concat(ctrl.signal) as TArgs);
if (id !== seq.current || ctrl.signal.aborted) return; // ignore stale
setState({ data: result, error: null, isLoading: false });
onSuccess?.(result);
return result;
} catch (err) {
if (id !== seq.current || (err as any)?.name === 'AbortError') return;
setState(s => ({ ...s, error: err, isLoading: false }));
onError?.(err);
throw err;
}
}, [fn, onSuccess, onError]);
useEffect(() => {
if (!auto) return;
void run();
return () => ctrlRef.current?.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
const api = useMemo(() => ({ ...state, run }), [state, run]);
return api; // { data, error, isLoading, run }
}
Notes:
- The AbortController is replaced on each run and aborted on unmount.
- A sequence counter defeats late responses writing stale data.
- The returned object is memoized via useMemo.
Event listeners and subscriptions
Prefer a ref-stabilized handler so consumers don’t re-render when callbacks change.
import { useEffect, useRef } from 'react';
type Target = HTMLElement | Window | Document | null;
export function useEventListener<K extends keyof WindowEventMap>(
target: Target,
type: K,
handler: (e: WindowEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
) {
const saved = useRef(handler);
useEffect(() => { saved.current = handler; }, [handler]);
useEffect(() => {
const tgt: any = target ?? window;
const wrap = (e: Event) => saved.current(e as any);
tgt.addEventListener(type, wrap, options);
return () => tgt.removeEventListener(type, wrap, options);
}, [target, type, options]);
}
Debouncing and derived values
Encapsulate timing inside a hook so the consumer just uses a value.
import { useEffect, useState } from 'react';
export function useDebouncedValue<T>(value: T, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
Simple state helper
Expose a tiny contract and stable actions.
import { useCallback, useState } from 'react';
export function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn(v => !v), []);
const setTrue = useCallback(() => setOn(true), []);
const setFalse = useCallback(() => setOn(false), []);
return { on, toggle, setTrue, setFalse };
}
SSR, DOM access, and safety
- Avoid touching window/document during render. If needed, guard with typeof window !== ‘undefined’ and run DOM reads in useEffect or useLayoutEffect.
- Provide a no-op fallback on the server to keep markup deterministic.
- For visual measurements, prefer ResizeObserver or useLayoutEffect with guards.
TypeScript practices
- Type the return value and callback parameters explicitly. Favor inference for internal locals; be explicit for the public API.
- Use generics for data-bearing hooks (e.g., useFetch
). Provide narrow, well-named types instead of any. - Encode status in the type when practical:
type Idle = { status: 'idle' };
type Loading = { status: 'loading' };
type Success<T> = { status: 'success'; data: T };
type Failure = { status: 'error'; error: unknown };
export type AsyncResult<T> = Idle | Loading | Success<T> | Failure;
- For tuple returns, use const assertions to preserve labels in consumers: return [value, action] as const.
Testing custom hooks
- Test the contract, not implementation details: given inputs/options, assert returned state and side effects.
- Use @testing-library/react with renderHook for ergonomics.
- Control time with modern fake timers when testing debouncing or delays.
- Mock network boundaries; don’t rely on real APIs.
import { renderHook, act } from '@testing-library/react';
import { useDebouncedValue } from './useDebouncedValue';
jest.useFakeTimers();
test('debounces changes', () => {
const { result, rerender } = renderHook(({ v }) => useDebouncedValue(v, 200), {
initialProps: { v: 'a' }
});
expect(result.current).toBe('a');
rerender({ v: 'ab' });
expect(result.current).toBe('a');
act(() => { jest.advanceTimersByTime(199); });
expect(result.current).toBe('a');
act(() => { jest.advanceTimersByTime(1); });
expect(result.current).toBe('ab');
});
Packaging and distribution
- Keep peer dependencies aligned with the React version you target.
- Mark your package as sideEffects: false when safe for tree-shaking.
- Publish ESM first; add CJS only if your consumers need it.
- Document the API, options, and caveats in README with small examples.
- Add JSDoc/TSDoc comments so IDEs surface your contract.
Error handling and invariants
- Validate required options early and fail fast in development with descriptive errors.
- For async hooks, catch errors and set an error field; let consumers decide whether to rethrow.
- Integrate with error boundaries by throwing in render only when truly fatal (rare for hooks).
function invariant(cond: unknown, msg: string): asserts cond {
if (!cond) throw new Error(`[useXYZ] ${msg}`);
}
Accessibility-aware hooks
- If your hook affects focus (e.g., dialogs, typeahead), manage focus shifts in effects and restore previous focus on cleanup.
- Expose escape hatches for ARIA attributes or announcement callbacks when state changes.
- Prefer semantic events and avoid trapping keyboard input without clear exit paths.
Common anti-patterns to avoid
- Mutating state objects in place inside hooks; always create new references.
- Omitting dependencies to “quiet” ESLint; refactor instead.
- Returning freshly created objects/callbacks without memoization, causing consumer re-renders.
- Performing subscriptions or timers during render.
- Overloading one hook with multiple unrelated concerns.
A small checklist before publishing a hook
- Contract is minimal and documented.
- Dependencies are correct; effects are idempotent and cleaned up.
- Returned callbacks and objects are stable.
- Works in SSR and on the client without warnings.
- Tests cover success, error, cancellation, and unmount.
- Types are precise; no any leaks from the public API.
Conclusion
Great custom hooks feel invisible: they disappear behind simple, predictable contracts and let components read like stories. By focusing on single responsibility, stable outputs, careful effect design, and strong typing and tests, you’ll build hooks that scale with your app and your team. Start small—wrap one recurring pattern today—and grow your own reliable hooks toolkit over time.
Related Posts
React Jotai: A Practical Guide to Atomic State Management
A practical guide to React Jotai atomic state: concepts, patterns, async atoms, performance, TypeScript, SSR, testing, and pitfalls.
React Parallel Data Fetching Patterns: From Promise.all to Suspense and Server Components
Master React parallel data fetching with Promise.all, Suspense, React Query, SWR, Next.js, and Router loaders. Avoid waterfalls and ship faster UIs.
React Form Libraries Comparison (2026): The Practical Buyer’s Guide
React form libraries in 2026 compared: React Hook Form, Formik, React Final Form, TanStack Form, RJSF/Uniforms/JSON Forms, and Conform with Server Actions.