React Hooks Best Practices
A condensed summary of the 25 most important best practices drawn from every page in this section.
Search across all documentation pages
A condensed summary of the 25 most important best practices drawn from every page in this section.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
use so 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.[value, setValue] as const gives consumers readonly [T1, T2] with correct destructuring types; without as const, TypeScript widens to (T1 | T2)[] and the tuple order is lost.useActionState from "react" - not the deprecated useFormState from "react-dom" - and remember the new signature returns [state, formAction, isPending] with pending state built in.(prevState, formData) => newState signature replaces state entirely, so spread ...prevState when you only want to merge a field; forgetting this wipes every untouched field on each submit.useCallback is useless on its own - it prevents re-renders only when a React.memo child or an effect/memo dependency actually compares identity, so don't sprinkle it on every handler.useReducer's dispatch is permanently stable across renders, so threading it down is often cleaner than wrapping handlers in useCallback every level; the same applies to state setters.<Context value={{…}}> re-renders every consumer every parent render; wrap the value in useMemo - <Ctx value={useMemo(() => ({ theme, toggle }), [theme, toggle])}> - or split state and updater into separate contexts to stop the avalanche.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.useDeferredValue defers the render of consumers, so the heavy child must be wrapped in React.memo or the parent re-renders it with the current value anyway, making the hook a no-op.AbortController and abort in cleanup to drop stale results: useEffect(() => { const ac = new AbortController(); fetch(url, { signal: ac.signal }); return () => ac.abort(); }, [url]).useEffect(() => { async function load() { await fetch(url) } load(); }, [url]) - React warns about this pattern because the returned Promise is never awaited for cleanup.useId returns an SSR-stable identifier per tree position - perfect for htmlFor, aria-describedby, and form wiring - but never for list keys (one ID per call) and never inside loops or conditionals.useId values by default; pass identifierPrefix to each createRoot/hydrateRoot so micro-frontends or embedded widgets stay distinct.useMemo for correctness - use it for performance and reference stability, not program semantics.localStorage inside useMemo breaks React's rendering model and misbehaves under Strict Mode and concurrent rendering.useOptimistic's update silently disappears unless addOptimistic runs inside a form action, Server Action handler, or explicit startTransition; the overlay only persists while a transition is pending, with automatic rollback on error.return { ...state, count: state.count + 1 } not state.count++; return state - never fetch or write to storage, and always include a default: return state branch or unknown actions wipe state to undefined.useState(() => computeInitial()) and useReducer(reducer, arg, init) so the initializer runs only on mount; useState(computeInitial()) runs the computation on every render and throws away the result..current can 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.useRef attached to a DOM node is null during the first render, so attempting to touch ref.current.focus() inline throws; do it in useEffect, useLayoutEffect, or a handler, and remember a conditionally rendered element's ref is null while hidden.setCount(count + 1) calls in the same handler increment only once because they all close over the same stale count; the updater form setCount(prev => prev + 1) composes correctly under React 18+ automatic batching.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.startTransition only defers React state setters - refs, external stores, and synchronous DOM writes are unaffected; also remember React 18 requires a synchronous callback while React 19 allows async callbacks natively.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.Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥