React Re‑render Debugging Playbook: Find the Cause, Fix It
The most common React performance question — and the one most people answer by sprinkling memo/useMemo/useCallback at random until it feels faster — is "why is this re-rendering?" Random memoization is not debugging: it adds complexity, often doesn't help (because the real cause is elsewhere), and can even hurt. The playbook is to find the specific reason a given component rendered using React DevTools, confirm it, and apply the minimal fix that addresses that specific cause. The playbook in one line: use the Profiler's "why did this render" to find whether it was props/state/context/parent, identify the unstable value, and fix that one thing — don't memoize blindly.
First, the mental model that dissolves half the confusion: a component re-renders when (1) its state changes, (2) its parent re-renders, (3) a context it consumes changes, or (4) its props change and it's memoized (an un-memoized child re-renders with its parent regardless of props). "Re-rendering" is also not the same as "slow" — a re-render that produces the same DOM is cheap; the problem is only expensive re-renders or cascades of them. So the real question is usually "why is this expensive component rendering often?"
Symptoms & First Signals
| Symptom | First signal |
|---|---|
| UI feels laggy on interaction/typing | Profiler shows long commits, or many components highlighting |
| a heavy component updates on unrelated changes | React DevTools "Highlight updates when components render" flickers it |
| typing in one input re-renders the whole page | the whole tree flashes in the highlight overlay |
| fixed by "adding memo somewhere" (but which?) | you don't actually know the cause yet — that's the bug |
GIF via GIPHY
The cheapest first signal: turn on React DevTools → Settings → "Highlight updates when components render." Interact with the app and watch what flashes. If a component flashes on changes that shouldn't affect it, you've localized the problem visually before opening the Profiler.
The Differential: Common Causes, Ranked
Most re-render problems are one of these, in roughly this order of frequency:
GIF via GIPHY
| # | Cause | Tell |
|---|---|---|
| 1 | unstable object/array/function prop (style={{}}, onClick={() => …}, data={[]} created inline) | a memoized child still re-renders; the prop is !== its previous value every render |
| 2 | context value changes every render (provider passes a new object) | all consumers re-render on any provider render |
| 3 | state lifted too high (state that changes often lives in a big parent) | a small change re-renders a large subtree |
| 4 | new object identity in state/selector (a selector returns a new array each call) | a store subscriber re-renders on unrelated store changes |
| 5 | key changing (unstable key remounts, not just re-renders) | the component remounts (loses state) |
| 6 | genuinely needs to render — it's not a bug | the render is cheap; you're optimizing nothing (Amdahl) |
The Systematic Hunt
1. CONFIRM IT'S ACTUALLY A PROBLEM (don't optimize a cheap render):
React DevTools → Profiler → record an interaction → look at the flame chart.
Is the commit long (>16ms)? Is an EXPENSIVE component rendering repeatedly?
If renders are <1ms and cheap → STOP. This isn't your bottleneck (Amdahl's Law).
2. FIND WHY THE SPECIFIC COMPONENT RENDERED:
Profiler → Settings → enable "Record why each component rendered while profiling."
Record → click the component in the flame chart → the panel says one of:
• "Props changed: (style)" → an unstable prop (cause #1)
• "Hook 1 changed" → its own state/context (cause #3/#4)
• "The parent component rendered" → it's not memoized; parent drove it (cause #3)
• "Context changed" → a context value changed (cause #2)
→ this SINGLE line tells you which branch of the differential you're in.
3. IF "PROPS CHANGED" → FIND THE UNSTABLE PROP:
the named prop is a new reference each render. Common culprits:
style={{ color }} onClick={() => …} items={data.filter(…)} ← inline, new each render
→ confirm: log the prop's identity across renders, or use why-did-you-render (below).
4. IF "PARENT RENDERED" → the child isn't memoized and re-renders with the parent.
Ask: is the parent's frequent render the real issue? (bisect UP the tree.)
Fix by memoizing the child OR moving the frequently-changing state DOWN (cause #3).
5. IF "CONTEXT CHANGED" → check the provider's value:
<Ctx.Provider value={{ user, setUser }}> ← new object every render → ALL consumers re-render.
6. CONFIRM WITH why-did-you-render (optional but decisive):
the `@welldone-software/why-did-you-render` library logs, per component,
exactly which prop/state/hook changed and whether it was a "re-render caused
by [equal] values" (i.e. avoidable). It turns guessing into a printout.
The decision flow, compressed:
GIF via GIPHY
is the render EXPENSIVE / FREQUENT? ──no──► not a bug. stop. (most renders are fine)
│ yes
▼
Profiler "why did this render?"
├─ "Props changed: X" ──► X is an unstable reference ──► stabilize X (memo the value/callback)
├─ "The parent rendered" ──► child not memoized ──► memo child, OR move state down
├─ "Context changed" ──► provider value is a new object ──► memo the provider value / split context
└─ "Hook N changed" ──► its own state/selector ──► is the selector returning new identity?
Worked Scenario: Typing Lags the Whole Page
Symptom: typing in a search box makes the entire page stutter. Hunt: "Highlight updates" shows the whole tree flashing on each keystroke. Profiler → "why did this render" on a distant, heavy <DataTable> says "The parent component rendered." Bisecting up: the search input's value state lives in the top-level <App>, so every keystroke re-renders <App> and its entire subtree — including the expensive table that has nothing to do with search. Root cause: state lifted too high (cause #3). Fix: move the search value state down into the search component (or a small wrapper), so keystrokes only re-render the input, not the whole app. Colocate state with where it's used (decisions/04).
GIF via GIPHY
Worked Scenario: The Memoized Child That Still Renders
Symptom: a developer wrapped <ExpensiveChild> in React.memo but it still re-renders every time the parent does. Hunt: Profiler "why did this render" on the child says "Props changed: style." The parent passes style={{ marginTop: 8 }} — a new object literal every render, so memo's shallow prop comparison always sees a different style and re-renders. Root cause: unstable object prop (cause #1). Fix: hoist the constant style out of the component (const STYLE = { marginTop: 8 }) or useMemo it if it depends on props. React.memo only helps if the props are stable — an inline object defeats it entirely.
Worked Scenario: Every Consumer Re-Renders
Symptom: a context provides { theme, user }, and every component consuming it re-renders on any state change anywhere in the provider. Hunt: "why did this render" on a consumer says "Context changed." The provider is <Ctx.Provider value={{ theme, user }}> — a new object every render, so all consumers see a changed context. Root cause: unstable context value (cause #2). Fix: useMemo the provider value (useMemo(() => ({ theme, user }), [theme, user])), or split the context (separate ThemeContext and UserContext) so a theme change doesn't re-render user consumers. Context value identity is a classic silent re-render source.
GIF via GIPHY
Fix Patterns (Apply the Minimal One)
- Unstable prop → stabilize the value, not the component. Hoist constant objects/arrays out of the component;
useCallbacka function prop passed to a memoized child;useMemoa derived object/array. Only worth it if the child is memoized (otherwise stabilizing the prop does nothing). - Parent renders → memo the child OR move state down.
React.memothe expensive child so it skips renders when its props are unchanged — but only after ensuring its props are stable. Often the better fix is moving the frequently-changing state down so the expensive subtree isn't in the render path at all. - Context churn → memo the provider value and split contexts. Memoize the value object, and split unrelated concerns into separate contexts so consumers only re-render on the slice they use. For high-frequency updates, consider an external store (Zustand/Redux) with selectors instead of context.
- Selector returns new identity → memoize the selector. A selector that returns
state.items.filter(...)creates a new array each call, so every store update re-renders the subscriber. UseuseMemoor the store's memoized-selector tools (reselect) so the identity is stable when the data hasn't changed. - Don't memoize blindly.
memo/useMemo/useCallbackhave a cost (memory, comparison, complexity). Apply them to fix a confirmed expensive re-render, not preemptively. The React Compiler (auto-memoization) is making much of this automatic — another reason not to hand-scatter it.
GIF via GIPHY
Key Takeaways
- A component re-renders when its state changes, its parent renders, a consumed context changes, or (if memoized) its props change — and re-rendering is only a problem when the render is expensive or frequent (a cheap re-render is fine; don't optimize it — Amdahl's Law).
- Don't memoize blindly — find the specific cause first with React DevTools Profiler → "why did this render", which tells you props / parent / context / hook in one line.
- The differential, ranked: unstable object/function props (defeats
memo), context value that's a new object each render (re-renders all consumers), state lifted too high (small change → big subtree), selector returning new identity, and unstablekey(remounts). - Use "Highlight updates" as the cheap first signal, the Profiler to confirm it's expensive, and why-did-you-render to get a decisive printout of what changed.
- Apply the minimal fix for the confirmed cause: stabilize the unstable value, move state down, memo the provider value / split context, or memoize the selector — not a random scatter of
memoeverywhere.
GIF via GIPHYWhat did you think?