React Hooks Best Practices
A condensed summary of the 25 most important best practices drawn from every page in this section.
- Prefix Custom Hooks With use: Start every custom hook name with
useso the ESLint rules-of-hooks plugin actually enforces the rules; without the prefix, illegal conditional calls still break React but no lint error catches them. - Return Tuples With as const: Returning
[value, setValue] as constgives consumersreadonly [T1, T2]with correct destructuring types; withoutas const, TypeScript widens to(T1 | T2)[]and the tuple order is lost. - Import useActionState From react: In React 19, use
useActionStatefrom"react"— not the deprecateduseFormStatefrom"react-dom"— and remember the new signature returns[state, formAction, isPending]with pending state built in. - Spread prevState in Action Reducers: The action's
(prevState, formData) => newStatesignature replaces state entirely, so spread...prevStatewhen you only want to merge a field; forgetting this wipes every untouched field on each submit. - useCallback Only Helps Memoized Consumers:
useCallbackis useless on its own — it prevents re-renders only when aReact.memochild or an effect/memo dependency actually compares identity, so don't sprinkle it on every handler. - Prefer Stable dispatch Over useCallback:
useReducer'sdispatchis permanently stable across renders, so threading it down is often cleaner than wrapping handlers inuseCallbackevery level; the same applies to state setters. - Memoize Context value: Passing a fresh object literal as
<Context value={{…}}>re-renders every consumer every parent render; wrap the value inuseMemo—<Ctx value={useMemo(() => ({ theme, toggle }), [theme, toggle])}>— or split state and updater into separate contexts to stop the avalanche. - Context Default Is Not Initial State: The argument to
createContext(default)is used only when no Provider wraps the tree, not as the Provider's initial value; rely on this to guard against missing providers with a custom hook that throws if context is null. - Pair useDeferredValue With React.memo:
useDeferredValuedefers the render of consumers, so the heavy child must be wrapped inReact.memoor the parent re-renders it with the current value anyway, making the hook a no-op. - Strict Mode Runs Effects Twice: In development, Strict Mode mounts → unmounts → remounts every component to surface missing cleanup; treat it as a feature and make every effect idempotent with a proper cleanup return.
- Cancel Race-Prone Fetches: Rapid dependency changes can land older responses after newer ones, so pair every fetch effect with an
AbortControllerand abort in cleanup to drop stale results:useEffect(() => { const ac = new AbortController(); fetch(url, { signal: ac.signal }); return () => ac.abort(); }, [url]). - Don't Pass Async Functions to useEffect: An async function returns a Promise, not a cleanup, so define an inner async function and call it:
useEffect(() => { async function load() { await fetch(url) } load(); }, [url])— React warns about this pattern because the returned Promise is never awaited for cleanup. - Use useId for Accessibility IDs Only:
useIdreturns an SSR-stable identifier per tree position — perfect forhtmlFor,aria-describedby, and form wiring — but never for listkeys (one ID per call) and never inside loops or conditionals. - Set identifierPrefix for Multiple Roots: Two React roots on the same page generate colliding
useIdvalues by default; passidentifierPrefixto eachcreateRoot/hydrateRootso micro-frontends or embedded widgets stay distinct. - useMemo Is Not a Guarantee: React may drop memoization entries (for example on offscreen components) and recompute later, so never rely on
useMemofor correctness — use it for performance and reference stability, not program semantics. - No Side Effects in useMemo: The factory runs during render, so it must be pure — logging, fetching, or writing to
localStorageinsideuseMemobreaks React's rendering model and misbehaves under Strict Mode and concurrent rendering. - Call addOptimistic Inside a Transition:
useOptimistic's update silently disappears unlessaddOptimisticruns inside a form action, Server Action handler, or explicitstartTransition; the overlay only persists while a transition is pending, with automatic rollback on error. - Keep Reducers Pure: The reducer runs during render, so never mutate state —
return { ...state, count: state.count + 1 }notstate.count++; return state— never fetch or write to storage, and always include adefault: return statebranch or unknown actions wipe state toundefined. - Lazy-Init Expensive State: Write
useState(() => computeInitial())anduseReducer(reducer, arg, init)so the initializer runs only on mount;useState(computeInitial())runs the computation on every render and throws away the result. - Ref.current Reads During Render Are Unsafe: Under concurrent rendering,
.currentcan reflect a different render pass than the one painting, so read refs inside effects, event handlers, or layout effects — not inline in the component body. - DOM Refs Are null Until Commit: A
useRefattached to a DOM node isnullduring the first render, so attempting to touchref.current.focus()inline throws; do it inuseEffect,useLayoutEffect, or a handler, and remember a conditionally rendered element's ref is null while hidden. - Prefer setState(prev => …): Three direct
setCount(count + 1)calls in the same handler increment only once because they all close over the same stalecount; the updater formsetCount(prev => prev + 1)composes correctly under React 18+ automatic batching. - Lazy useState Needs a Callback:
useState(() => parseJSON(localStorage.x))runs the parse only on mount;useState(parseJSON(localStorage.x))runs it on every render and discards all but the first — which is expensive and can throw during SSR. - Only React State Is Transitionable:
startTransitiononly defers React state setters — refs, external stores, and synchronous DOM writes are unaffected; also remember React 18 requires a synchronous callback while React 19 allowsasynccallbacks natively. - Give use() a Stable Promise:
use(promise)treats a fresh promise object as a new pending request, so creating a promise inline in render causes infinite suspension; the promise must come from props, a parent, a cache, or a stable ref — and always sit under a Suspense plus error boundary.