| name | optimize-react-rerenders |
| description | Eliminates wasted React re-renders by measuring first then fixing — profile with the React DevTools Profiler (flamegraph + "why did this render") and why-did-you-render to find the actual offenders, then apply the right fix: stable references (hoist constants, useCallback/useMemo only where a referentially-equal prop/dep actually matters), correct list keys (stable id, never index), React.memo with a custom comparator on genuinely-hot leaf components, context splitting + selector subscriptions (useSyncExternalStore / Zustand / use-context-selector) to stop whole-tree re-renders, derive-don't-store to kill redundant state, and list virtualization (TanStack Virtual) for long lists — while knowing when NOT to memo (cheap renders, unstable deps, and the React 19 Compiler which auto-memoizes and makes most manual memo dead weight). |
When to Use
Reach for this skill when the problem is too many React renders / render churn, not load time, not data fetching:
- "Typing in this input lags / the whole form re-renders on every keystroke"
- "Changing one row re-renders the entire list of 500 items"
- "The Profiler shows components re-rendering even though their props didn't change"
- "I added
useMemo/useCallback/React.memo everywhere and it's not faster (or slower)"
- "A context update re-renders half the tree"
- "Should I memoize this?" / "Is this
useMemo worth it?" / "We're on React 19 — do I still need this?"
- "Long list scrolls slowly / mounts thousands of DOM nodes"
NOT this skill:
- Slow initial load, late paint, layout shift, or a red Lighthouse score (LCP/INP/CLS, image/font/JS-bundle strategy) → optimize-core-web-vitals (it owns paint/load metrics; this skill owns render count. Note: cutting re-renders during interaction also improves INP, but go there for the metric-driven workflow)
- Data fetching, cache invalidation, optimistic updates, SSR hydration, or picking a store (TanStack Query / Zustand / Redux) → manage-client-server-state (it architects where state lives and how it's fetched; this skill stops the renders that state changes cause)
- Building a new component's structure/props/a11y from scratch → build-react-component
- A big interactive grid feature (sorting/filtering/column resize/selection) as a unit → build-data-table (it builds the table; this skill makes its rows stop re-rendering)
- Backend/server query latency or a CPU profile of non-React code → performance-profiling
- Using the browser/Chrome DevTools to debug a runtime bug (state, network, errors) generally → debug-frontend-browser (this skill is the render-perf specialization of profiling)
Steps
-
Measure before you touch anything — never memoize on a hunch. Manual memoization is a tradeoff (extra comparisons + cache memory); applied blindly it often makes things slower and always makes them harder to read. Get evidence first:
| Tool | What it tells you | How |
|---|
| React DevTools Profiler | which components rendered, how long, why | record an interaction → flamegraph + ranked chart |
| "Highlight updates when components render" (DevTools → ⚙ → Components) | visual flash on every render — instant "this re-renders on every keystroke" signal | toggle on, interact |
why-did-you-render | logs the exact prop/state/hook that changed (and whether it was a deep-equal-but-referentially-different value) | dev-only, see step 2 |
React 19 <Profiler onRender> / performance.measure | programmatic render timings in tests/CI | wrap a subtree |
In the Profiler, enable "Record why each component rendered" (⚙ → Profiler). Re-renders show a reason: props changed, hooks changed, parent rendered, context changed. That reason picks the fix below — don't guess.
-
Wire up why-did-you-render to catch referential-equality bugs (dev only). It surfaces the classic "props are deep-equal but a new object/array/function identity every render" case that React.memo can't catch.
import React from 'react';
if (process.env.NODE_ENV === 'development') {
const wdyr = require('@welldone-software/why-did-you-render');
wdyr(React, { : , : });
}
Common Errors
React.memo with unstable props. Memo'd child still re-renders because a parent passes a fresh {}/() => {} each render. Fix: stabilize the prop at the source (step 4) — memo without stable props is pure overhead.
useCallback/useMemo feeding a non-memoized consumer. A stable callback handed to a plain <button> or non-memo child changes nothing but adds overhead. Fix: only memoize values that cross into a memo'd child or another hook's deps.
- Dep array that changes every render. The memo never caches (cache miss every time) — all cost, no benefit. Fix: stabilize the deps too, or drop the memo.
- Index as list key. Reorders/inserts reuse the wrong DOM/state and break per-row memo; lost focus, wrong highlight. Fix: stable
item.id key.
Math.random()/uuid() key in render. Remounts every row every render — the opposite of memoization. Fix: derive a stable id once.
- Inline object/array provider value.
<Ctx.Provider value={{a,b}}> re-renders every consumer on every parent render. Fix: useMemo the value, or split contexts.
- One fat context for hot + cold data. A 60fps field re-renders theme/auth consumers. Fix: split by update frequency; use a selector subscription.
useState mirrored from props via useEffect. Extra render + drift. Fix: derive in render (useMemo if pricey).
- Memoizing everything by default. Slower and unreadable; stale-closure bugs from wrong deps. Fix: memoize measured hot paths only; on React 19 let the compiler do it.
- Expecting
memo to stop context/state re-renders. memo compares props only. Fix: address the actual reason the Profiler reports (context → split/selector; state → derive/lift).
Verify
- Profiler shows the offender gone: record the same interaction before/after — the component that flashed/rendered "props changed (but equal)" or "parent rendered" no longer appears in the commit (or its render time drops). Keep the before flamegraph as proof.
why-did-you-render is silent on the fixed path: no "different objects that are equal by value" logs for the props you stabilized.
- Typing/interaction is smooth: the input/list that lagged updates per keystroke without re-rendering unrelated siblings; "Highlight updates" flashes only the changed node.
- One-row change → one row renders: mutating a single list item re-renders that row only, not the whole list (visible in the Profiler ranked chart).
- Context change is scoped: updating a hot context field re-renders only its real consumers; theme/auth consumers stay dark.
- Long list DOM is bounded: the virtualized list mounts ~viewport+overscan rows, and node count stays roughly constant as the dataset grows from 100 → 10,000.
- No memo is dead weight: every remaining
memo/useMemo/useCallback corresponds to a Profiler-confirmed hot path or a real dep/memo-prop boundary; the rest were removed. On React 19, react-compiler-healthcheck passes and manual memo is minimal.
Done = the measured re-render(s) the Profiler flagged are eliminated by the matching fix (stable refs, correct keys, context split/selector, derive-don't-store, or virtualization), every remaining manual memo is justified by evidence (or replaced by the React 19 compiler), and the before/after Profiler traces prove the churn is gone — not added overhead.