rerenders-audit
Re-renders Audit
Profile React components, identify unnecessary re-renders, and apply the right fix — with a clear explanation of what changed and why it works.
Mental Model First
Re-renders are not inherently bad. React is designed to re-render. The goal is to eliminate unnecessary re-renders — ones where the output would be identical to the previous render.
A component re-renders when:
- Its own state changes
- Its parent re-renders (even if props didn't change)
- A context it consumes changes
- Its key changes
Fixes only help when the re-render is truly unnecessary. Always measure before and after.
Step 1: Profile First
Never guess. Before writing any fix, identify what's actually re-rendering and why.
Option A: React DevTools Profiler (recommended)
- Open React DevTools → Profiler tab
- Click Record, interact with the slow part of the UI, click Stop
- Inspect the flame graph — look for components that rendered when they shouldn't have
- Click a component bar → check "Why did this render?" panel on the right
Option B: Highlight Updates (quick visual check)
- React DevTools → ⚙️ Settings → "Highlight updates when components render"
- Interact with the UI — blue/green flashes = re-renders
- Look for components outside the interaction area flashing
Option C: Add render logging (when you can't use DevTools)
// Drop this inside any component to count renders
const renderCount = useRef(0);
console.log(`[MyComponent] render #${++renderCount.current}`, { props });
Option D: React Scan (third-party, excellent DX)
npm install react-scan
// In your entry point
import { scan } from 'react-scan';
scan({ enabled: true, log: true });
Draws red outlines around re-rendering components with render count overlays.
Step 2: Classify the Re-render
Once you've identified the component, classify the cause:
| Cause | Symptom | Fix |
|---|---|---|
| Parent re-renders, child output unchanged | Child re-renders with same props | React.memo on child |
| Inline object/array prop | New reference every render | useMemo for the value |
| Inline function prop | New reference every render | useCallback for the handler |
| Derived value recomputed each render | Expensive calculation runs unnecessarily | useMemo for the computation |
| Context re-render | All consumers re-render on any context change | See context-rerenders-fix skill |
| Key instability | Component unmounts/remounts on each render | Fix key to be stable identifier |
| State too high | Unrelated state change triggers wide re-render | Move state down (co-locate) |
Step 3: Apply the Right Fix
Fix 1: React.memo — Stop child re-renders from parent
When: A child component re-renders because its parent does, but its props haven't changed.
// ❌ Before — re-renders every time Parent re-renders
function UserCard({ user }: { user: User }) {
return <div>{user.name}</div>;
}
// ✅ After — skips render if user prop is the same reference
const UserCard = React.memo(function UserCard({ user }: { user: User }) {
return <div>{user.name}</div>;
});
Why it works: React.memo wraps the component in a shallow equality check on props. If all props are === to their previous values, React skips the render entirely and reuses the last output.
Pitfall: If a prop is an object or function created inline in the parent, it will always be a new reference — memo won't help without also fixing the prop. Pair with useMemo/useCallback.
Custom comparator (use sparingly):
const UserCard = React.memo(UserCardFn, (prev, next) => {
return prev.user.id === next.user.id; // only re-render if ID changes
});
Fix 2: useCallback — Stable function references
When: A function is defined inside a component and passed as a prop. New function reference on every render breaks memo on the child.
// ❌ Before — new onDelete reference every render, breaks memo on Row
function Table({ rows }: { rows: Row[] }) {
const handleDelete = (id: string) => {
deleteRow(id); // or setState(...)
};
return rows.map(row => <Row key={row.id} row={row} onDelete={handleDelete} />);
}
// ✅ After — stable reference, Row's memo check passes
function Table({ rows }: { rows: Row[] }) {
const handleDelete = useCallback((id: string) => {
deleteRow(id);
}, []); // ← deps: add anything from closure that can change
return rows.map(row => <Row key={row.id} row={row} onDelete={handleDelete} />);
}
Why it works: useCallback(fn, deps) returns the same function reference between renders as long as deps haven't changed. Children receiving it via props will see a stable reference and skip re-renders.
Common deps mistake:
// ❌ Stale closure — items is captured from first render only
const handleSubmit = useCallback(() => {
processItems(items); // items may be stale!
}, []);
// ✅ Correct deps
const handleSubmit = useCallback(() => {
processItems(items);
}, [items]);
// ✅ Or use functional updater to avoid the dep entirely
const handleAdd = useCallback((item: Item) => {
setItems(prev => [...prev, item]); // no `items` dep needed
}, []);
Fix 3: useMemo — Stable object/array references and expensive calculations
Two distinct use cases:
3a. Stable reference for object/array props
// ❌ Before — new config object every render, breaks memo on Chart
function Dashboard({ data }: { data: DataPoint[] }) {
const chartConfig = { color: 'blue', animate: true }; // new ref each render
return <Chart data={data} config={chartConfig} />;
}
// ✅ After — same reference until deps change
function Dashboard({ data }: { data: DataPoint[] }) {
const chartConfig = useMemo(() => ({ color: 'blue', animate: true }), []);
return <Chart data={data} config={chartConfig} />;
}
3b. Expensive computation
// ❌ Before — full sort + filter runs on every render
function ProductList({ products, query, sortBy }: Props) {
const results = products
.filter(p => p.name.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => a[sortBy] > b[sortBy] ? 1 : -1);
return results.map(p => <ProductCard key={p.id} product={p} />);
}
// ✅ After — only recomputes when products, query, or sortBy change
function ProductList({ products, query, sortBy }: Props) {
const results = useMemo(() =>
products
.filter(p => p.name.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => a[sortBy] > b[sortBy] ? 1 : -1),
[products, query, sortBy]
);
return results.map(p => <ProductCard key={p.id} product={p} />);
}
Why it works: useMemo(fn, deps) caches the return value and only recomputes when deps change. For objects/arrays, this preserves reference identity. For calculations, it avoids redundant work.
When NOT to use useMemo:
- The computation is trivial (adding two numbers, string concat)
- The value is already a primitive (string, number, boolean)
- The deps change as often as the component renders anyway
Fix 4: Move State Down (Co-location)
When: State lives higher than it needs to, causing broad re-renders when only a small subtree cares.
// ❌ Before — isOpen in parent causes entire parent + siblings to re-render
function Page() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<ExpensiveSection /> {/* re-renders on every toggle! */}
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)} />
<button onClick={() => setIsOpen(true)}>Open</button>
</>
);
}
// ✅ After — modal state lives inside its own component
function Page() {
return (
<>
<ExpensiveSection /> {/* never re-renders from modal state */}
<ModalWithTrigger />
</>
);
}
function ModalWithTrigger() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>Open</button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)} />
</>
);
}
Why it works: State changes only re-render the component that owns the state and its descendants. By moving state down, the blast radius shrinks.
Fix 5: Lift JSX / Children as Props
When: A parent re-renders frequently, but some children don't depend on the changing state. Pass them as children or props — they won't re-render.
// ❌ Before — SlowTree re-renders every time count changes
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<SlowTree /> {/* re-renders on every count change */}
</div>
);
}
// ✅ After — SlowTree is passed as children, doesn't re-render
function CounterWrapper({ children }: { children: React.ReactNode }) {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
{children} {/* SlowTree's JSX is created by the parent, not here */}
</div>
);
}
function App() {
return (
<CounterWrapper>
<SlowTree />
</CounterWrapper>
);
}
Why it works: children is a prop. When CounterWrapper re-renders, React sees the same children reference (created in App, which didn't re-render) and bails out of re-rendering SlowTree.
Step 4: Output Format
Always produce:
4a. Re-render Audit Table
| Component | Re-render Cause | Necessary? | Fix Applied |
|---------------|----------------------------------------|------------|--------------------|
| UserCard | Parent (Dashboard) re-renders | No | React.memo |
| Table | onDelete new ref each render | No | useCallback |
| ProductList | Full filter+sort on every render | No | useMemo |
| ModalTrigger | isOpen state lifted too high | No | Move state down |
| CountDisplay | count state changed | Yes | None needed |
4b. Root Cause Explanation
For each unnecessary re-render, one sentence explaining why it happens — no jargon assumed.
4c. Before/After Code Snippets
Focused diffs for each fix. Include a comment on the fix line explaining the mechanism.
4d. Why Each Fix Works
After each snippet, a 2–3 sentence plain-English explanation:
- What the fix does mechanically
- Why the previous code was triggering a render
- Any caveats or follow-up to watch for
4e. Verification
How to confirm the fix worked — DevTools steps or a render-count log pattern.
Decision Guide: Which Tool to Reach For
Is the component re-rendering because its parent does, with unchanged props?
└─ Wrap component in React.memo
Is a prop that's an object or array created inline in the parent?
└─ useMemo for that value in the parent
Is a prop that's a function created inline in the parent?
└─ useCallback for that function in the parent
Is a derived value expensive to compute and computed every render?
└─ useMemo for that computation
Is state higher than it needs to be?
└─ Move state down to the component that owns it
Does a frequently-updating parent contain stable subtrees?
└─ Pass stable subtrees as children/props
Is a Context causing broad re-renders?
└─ See context-rerenders-fix skill
Anti-patterns to Flag
- Memoizing everything — adds overhead, makes code harder to read, and often doesn't help if deps change as often as the render
- Missing deps in useCallback/useMemo — leads to stale closures and subtle bugs; always include all referenced variables
- React.memo on components with frequently-changing props — the shallow comparison itself costs time with no benefit
- Object/array in useCallback deps —
[{ id }]will never be equal; extract the primitive:[id] - Skipping the profiler — fixing the wrong thing is worse than fixing nothing; always measure first
Reference Files
references/profiling-guide.md— Detailed React DevTools Profiler walkthrough, React Scan setup, reading flame graphsreferences/memoization-tradeoffs.md— When memoization helps vs hurts, benchmark patterns, rules of thumb for large lists
More from blunotech-dev/agents
anti-purple-ui
Enforce a strict monochrome UI with a single high-contrast accent color, removing generic tech gradients and “AI-style” palettes. Use when the user wants minimal, anti-AI, or non-generic aesthetics, or says the UI looks too techy or generic.
9harmonize-whitespace
Align all spacing (padding, margins, gaps) to a consistent 4pt/8pt grid. Use when spacing feels off, inconsistent, cramped, or unbalanced, or when the user asks for a spacing scale or alignment fix.
9typographic-hierarchy
Improve typography by adjusting font sizes, weights, spacing, and contrast to create clear visual hierarchy and readability. Use when text feels flat, unstructured, or when the user asks to refine headings, type scale, or overall readability.
6micro-interaction-adder
Add polished CSS micro-interactions like hover effects, transitions, and feedback states to improve UI feel. Use when the user asks for animations, better UX, or when the interface feels static, plain, or unresponsive.
4consistent-border-radius
Normalizes rounded corners across a file so buttons, inputs, cards, modals, badges, and all UI elements share the exact same curvature. Use this skill whenever the user mentions inconsistent border radii, wants to unify rounded corners, asks to make UI elements look more cohesive, or says things like "make the corners match", "fix the rounding", "unify border radius", "standardize my rounded corners", or "buttons and cards don't match". Also trigger when the user pastes a CSS/HTML/JSX/TSX file and asks for a design consistency pass, border radius is one of the first things to normalize.
4component-split
Analyze a component and determine when and how to split it based on size, responsibility, and reuse signals, producing a refactored structure with clear boundaries. Use when users share large, mixed-concern, or hard-to-test components, or ask about splitting, refactoring, or improving component architecture.
3