React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

best-practicessummaryreact-hooks

React Hooks Best Practices

A condensed summary of the 25 most important best practices drawn from every page in this section.

  1. Prefix Custom Hooks With use: Start every custom hook name with 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.
  2. Return Tuples With as const: Returning [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.
  3. Import useActionState From react: In React 19, use useActionState from "react" — not the deprecated useFormState from "react-dom" — and remember the new signature returns [state, formAction, isPending] with pending state built in.
  4. Spread prevState in Action Reducers: The action's (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.
  5. useCallback Only Helps Memoized Consumers: 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.
  6. Prefer Stable dispatch Over useCallback: 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.
  7. Memoize Context value: Passing a fresh object literal as <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.
  8. 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.
  9. Pair useDeferredValue With React.memo: 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.
  10. 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.
  11. Cancel Race-Prone Fetches: Rapid dependency changes can land older responses after newer ones, so pair every fetch effect with an AbortController and abort in cleanup to drop stale results: useEffect(() => { const ac = new AbortController(); fetch(url, { signal: ac.signal }); return () => ac.abort(); }, [url]).
  12. 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.
  13. Use useId for Accessibility IDs Only: 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.
  14. Set identifierPrefix for Multiple Roots: Two React roots on the same page generate colliding useId values by default; pass identifierPrefix to each createRoot/hydrateRoot so micro-frontends or embedded widgets stay distinct.
  15. useMemo Is Not a Guarantee: React may drop memoization entries (for example on offscreen components) and recompute later, so never rely on useMemo for correctness — use it for performance and reference stability, not program semantics.
  16. No Side Effects in useMemo: The factory runs during render, so it must be pure — logging, fetching, or writing to localStorage inside useMemo breaks React's rendering model and misbehaves under Strict Mode and concurrent rendering.
  17. Call addOptimistic Inside a Transition: 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.
  18. Keep Reducers Pure: The reducer runs during render, so never mutate state — 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.
  19. Lazy-Init Expensive State: Write 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.
  20. Ref.current Reads During Render Are Unsafe: Under concurrent rendering, .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.
  21. DOM Refs Are null Until Commit: A 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.
  22. Prefer setState(prev => …): Three direct 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.
  23. 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.
  24. Only React State Is Transitionable: 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.
  25. 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.