React · intermediate

React render-loop checklist

A practical blog-style checklist for diagnosing React render loops, organized around an editorial argument: confirm the loop is real, isolate which update channel is rescheduling renders, then fix the smallest root cause and verify with a regression check.

The symptoms

  • Component logs or profiler traces show renders firing faster than expected, with identical input props across consecutive passes.
  • Browser main thread stays saturated near 100% and React DevTools Profiler commits never settle, even with no user interaction.
  • Network or instrumentation panels show a steady stream of identical effects, fetches, or analytics events emitted each render.
  • React StrictMode in development doubles the observed render cadence because effects are intentionally re-invoked on commit.

Likely causes

  • State update is performed unconditionally inside the render body, so each render schedules the next render in the same tick.
  • Effect dependency array omits a value that the effect itself writes, producing an effect-driven state setter on every commit.
  • Parent passes a new object, array, or function reference each render and a memoized child treats it as changed input.
  • Context provider value is rebuilt inline, so every consumer re-renders and any consumer that calls setState in render or effect restarts the cycle.
  • Subscription or store hooks return a fresh object each call, defeating shallow equality and triggering downstream renders.

First ten minutes

  1. 01Reproduce the loop in isolation by reducing to the smallest subtree that still exhibits the symptom, and capture the render count before any code change.
  2. 02Open React DevTools Profiler, start recording, and let it run for two to three seconds; sort commits by count and identify the component with the highest commits-per-second.
  3. 03Mark the suspect component with a console log guarded by a render counter, then read whether the counter advances on every commit or only on prop changes.
  4. 04Disable StrictMode temporarily to separate development-only double-invocation from a genuine production loop, and note the baseline difference.
  5. 05List every setState, dispatch, and context write reachable from the suspect subtree and tag whether it is gated by an equality check or a dependency.

Evidence to collect

  • Render count per component from a guarded render counter or from the Profiler commits-per-second view.
  • Effect invocations and their dependency array contents, captured from a logged effect body or from a useEffect trace.
  • Identity stability of props passed into memoized children, captured via a shallow equality check on the boundary.
  • StrictMode flag value in the build, and whether the same render cadence appears in a production build with the flag removed.
  • Network and analytics emissions per render, captured by tagging outgoing requests with a render sequence number.

Where to look

  • The render body of the suspect component, specifically the top-to-bottom scan for setState calls outside conditionals or effects.
  • The dependency array of every useEffect and useMemo call reachable from the suspect subtree, compared against the values written inside.
  • The boundary between a parent that rebuilds objects or functions inline and a child wrapped in React.memo or relying on referential equality.
  • The context provider component, where the value prop may be a fresh object literal each render, causing consumer re-renders.
  • Custom hooks and store bindings, where a returned object may be reconstructed each call and downstream equality checks fail.

Diagnostic steps

  1. 01If a setState appears unconditionally in render, wrap it in a guard or move it into a useEffect with a stable dependency, then re-measure render count to confirm the loop ends.
  2. 02If an effect writes to state and the dependency array omits that state, either include the dependency, remove the write, or replace the pattern with a derived value or ref.
  3. 03If a memoized child re-renders despite stable inputs, add referential equality logging on the boundary to identify which prop is changing identity, then stabilize it at the source.
  4. 04If context consumers re-render excessively, memoize the provider value with useMemo and verify the consumer is not itself performing an unguarded setState.
  5. 05If a store hook returns a new object each call, wrap the return in a memoized selector that compares the selected fields, and confirm consumers re-render only on meaningful change.

Common mistakes

  • Removing StrictMode and assuming the loop is gone, when StrictMode only doubles calls and the underlying update is still present.
  • Adding an empty dependency array to silence the loop, which can hide a state-write that should have been derived or ref-based.
  • Wrapping a setState in a setTimeout to break the cycle, which delays symptoms rather than addressing the rescheduling cause.
  • Memoizing every prop by default, which obscures the real boundary where identity is being lost and inflates memory cost.
  • Concluding a loop from a single high render count without checking whether renders are increasing or have reached a plateau after the initial mount.

Safe fixes

  • When state is set unconditionally in render, gate the update behind a comparison against the previous value or move the logic into an event handler or effect with a stable dependency.
  • When an effect writes to a piece of state it also reads, restructure to derive the value from props or use a ref to bridge across renders, then re-measure render count.
  • When a parent rebuilds an object or function each render, stabilize the value with useMemo or useCallback and verify the memoized child no longer re-renders on every parent render.
  • When a context value is rebuilt inline, memoize the value object with useMemo against the actual source state, and confirm consumer commit rate drops in the Profiler.
  • When a custom hook returns a new object, expose only the selected primitive values and let consumers derive locally, then confirm equality checks now pass at the boundary.

Prove the fix

  1. 01Render count per suspect component reaches a stable plateau after the initial mount, with no further commits recorded by an idle Profiler over a ten-second window.
  2. 02Effect invocations for the suspect subtree fire exactly once per meaningful dependency change, verified by a logged effect body and by a stable network or analytics emission pattern.
  3. 03Memoized children re-render only when their inputs change identity, verified by a boundary equality log that shows a hit rate consistent with user actions and not with parent re-renders.
  4. 04A canary production build with the original code re-introduces the loop, confirming the fix is local to the identified boundary rather than a coincidence of environment.

Prevention and next steps

  • Establish a coding rule that setState must never appear unconditionally in a render body, enforced by review and by a lint rule where available.
  • Favor derived values over effect-driven mirrors, so the data flow from props to state stays one-directional and auditable.
  • Stabilize props that cross a memoized boundary at the producer, so consumers can rely on referential equality without redundant memoization.
  • Keep React StrictMode enabled in development to surface effect re-invocation early, and treat its warnings as evidence to investigate rather than noise to suppress.

Safe commands and checks

git log --oneline -n 5 -- <path-to-suspect-component>
grep -RInE 'setState|set[A-Z][A-Za-z0-9]*\(' src --include='*.tsx' --include='*.jsx' --include='*.ts' --include='*.js'
grep -RInE 'useEffect|useLayoutEffect|useMemo|useCallback' src --include='*.tsx' --include='*.jsx' --include='*.ts' --include='*.js'
node -e 'process.stdout.write(require("./package.json").dependencies.react || "unknown")'