Rendering Performance
Why a component re-renders
Section titled “Why a component re-renders”There are exactly three reasons a component re-renders:
- Its own state changed (a setter was called).
- Its parent re-rendered — by default, when a component renders, React re-renders all of its children.
- A context it consumes changed value.
That second one surprises people: a parent re-rendering re-renders the whole subtree below it, even children whose props did not change. React does this because it cannot know, without checking, whether a child’s output depends on something that changed.
flowchart TB app["App re-renders (state changed)"] --> header["Header re-renders"] app --> main["Main re-renders"] main --> list["List re-renders"] list --> item1["Item re-renders"] list --> item2["Item re-renders"] note["Every descendant re-renders, even if its props are unchanged"]
Re-rendering is not free, but it is usually cheap — remember reconciliation produces zero DOM changes when output is unchanged. The problem only appears when a large subtree, or an expensive component, re-renders needlessly and often.
Fix #1: keep state low and local
Section titled “Fix #1: keep state low and local”The best fix is structural. If a piece of state lives high in the tree but only one small branch uses it, every state change re-renders the whole tree. Move the state down to the component that actually needs it, so a change re-renders only that small branch.
// ❌ Search text lives in App, so typing re-renders the entire app.function App() { const [query, setQuery] = useState(""); return ( <> <SearchBox query={query} onChange={setQuery} /> <HugeExpensiveDashboard /> {/* re-renders on every keystroke! */} </> );}
// ✅ Move the state into SearchBox; typing re-renders only SearchBox.function App() { return ( <> <SearchBox /> <HugeExpensiveDashboard /> {/* never re-renders while typing */} </> );}A related trick: lift content by composition. If a fast-changing parent must wrap a slow child, pass the slow child in as children — children passed as props do not re-render just because the parent’s state changed.
Fix #2: stop propagation with React.memo
Section titled “Fix #2: stop propagation with React.memo”When you cannot move state and a child is genuinely expensive, wrap it in React.memo. A memoized component skips re-rendering when its props are shallow-equal to the previous render — it becomes a wall that a parent re-render does not cross.
const ExpensiveList = React.memo(function ExpensiveList({ items }) { // Only re-renders when `items` (by reference) actually changes. return items.map((it) => <Row key={it.id} item={it} />);});The catch: React.memo only helps if the props are actually stable. Pass a fresh inline object, array, or function as a prop and the shallow comparison always fails — which is why memoization often needs useMemo/useCallback on the props too (next lesson).
Measuring, not guessing: the <Profiler> API
Section titled “Measuring, not guessing: the <Profiler> API”Do not guess where the slowness is. React ships a <Profiler> component built exactly for this: wrap any subtree in it, pass an onRender callback, and React calls it after every commit inside that subtree with the numbers you’d otherwise have to guess at — id, phase ("mount" the first time, "update" every time after), actualDuration, baseDuration, startTime, and commitTime.
import { useState, Profiler, type ProfilerOnRenderCallback } from 'react';import { render, screen, act } from '@testing-library/react';
function Counter() { const [count, setCount] = useState(0); return <button onClick={() => setCount((c) => c + 1)}>count: {count}</button>;}
test('Profiler onRender reports "mount" on first commit and "update" on the next', () => { const phases: string[] = []; const onRender: ProfilerOnRenderCallback = (_id, phase) => { phases.push(phase); };
render( <Profiler id="counter" onRender={onRender}> <Counter /> </Profiler>, ); expect(phases).toEqual(['mount']);
act(() => { screen.getByText(/count: 0/).click(); }); expect(phases).toEqual(['mount', 'update']);});
test('actualDuration is a non-negative number for every commit', () => { const durations: number[] = []; const onRender: ProfilerOnRenderCallback = (_id, _phase, actualDuration) => { durations.push(actualDuration); };
render( <Profiler id="counter2" onRender={onRender}> <Counter /> </Profiler>, ); act(() => { screen.getByText(/count: 0/).click(); });
expect(durations.length).toBe(2); for (const d of durations) expect(d).toBeGreaterThanOrEqual(0);});✓ Profiler onRender reports "mount" on first commit and "update" on the next✓ actualDuration is a non-negative number for every commit
Test Files 1 passed (1) Tests 2 passed (2)actualDuration is the real number to watch in production diagnostics: how long this commit actually took to render the subtree, versus baseDuration (how long it would take to render the whole subtree from scratch, with no memoization). A component that commits often with a non-trivial actualDuration and an "update" phase — for props that did not visibly change — is exactly the re-render you’re hunting for.
React DevTools: Highlight Updates and the Profiler flamegraph
Section titled “React DevTools: Highlight Updates and the Profiler flamegraph”<Profiler onRender> is for gathering numbers programmatically; for an interactive session, the React DevTools browser extension gives you the same information visually:
- “Highlight updates when components render” (a checkbox in the Components tab) flashes a colored outline around any component that just re-rendered — the fastest way to see the propagation problem from “Fix #2” above with your own eyes, live, on a real page.
- The Profiler tab records a session and renders a flamegraph per commit: each bar is a component, its width is roughly proportional to render time, and clicking a bar shows the same
actualDuration/baseDurationnumbers theonRendercallback receives, plus why it rendered (props changed, state changed, parent rendered, hooks changed).
Between the two: use Highlight Updates first to spot that something is re-rendering too much, then the Profiler flamegraph to see how expensive that render actually was before you reach for React.memo.
See it render: Profiler durations and useDeferredValue
Section titled “See it render: Profiler durations and useDeferredValue”import { useState, useDeferredValue } from 'react';
function slowFilter(query: string): string[] { const items: string[] = []; for (let i = 0; i < 2000; i++) { if (String(i).includes(query)) items.push(String(i)); } return items;}
function ResultsList({ query }: { query: string }) { const results = slowFilter(query); return <p>{results.length} matches for "{query}"</p>;}
export default function App() { const [text, setText] = useState(''); const deferredText = useDeferredValue(text); return ( <div> <input value={text} onChange={(e) => setText((e.target as HTMLInputElement).value)} placeholder="type digits, e.g. 12" /> <ResultsList query={deferredText} /> </div> );}| Component | Renders | Last commit (ms) |
|---|---|---|
| Click Run — commits will appear here. | ||
Type a few digits into the input, one keystroke at a time. Open the commit log: each keystroke produces two “update” commits — the urgent one (deferredText still lags, exactly as the test above proved) and a background one right after (deferredText catches up). ResultsList is not memoized, so it re-runs its deliberately slow filter loop on both commits — watch its row in the table climb by 2 per keystroke, and both commits’ duration stay noticeably non-trivial. That combination — a component that re-renders on every commit AND does real work every time — is exactly what <Profiler onRender> and the DevTools flamegraph exist to surface, before you reach for React.memo.
useDeferredValue vs. debouncing
Section titled “useDeferredValue vs. debouncing”useDeferredValue vs. debouncing
Section titled “useDeferredValue vs. debouncing”Both techniques keep a UI responsive while an expensive part lags behind — but they lag for different reasons, and the difference matters when you pick one.
Debouncing delays applying a value until input has been quiet for a fixed amount of time you choose (setTimeout(..., 300), typically). You are guessing at a delay that’s long enough to skip wasted work but short enough to still feel responsive.
useDeferredValue has no fixed delay at all. Per react.dev: “There is no fixed delay caused by useDeferredValue itself. As soon as React finishes the original re-render, React will immediately start working on the background re-render with the new deferred value.” The background render is also interruptible — if another update to the value arrives before it finishes, React throws away the stale background attempt and starts over with the newest value, so it never wastes work rendering an already-obsolete result the way a debounce timer can.
// Debounce: you choose the delay, and it fires on a timer regardless of render cost.function useDebounced(value, delayMs) { const [debounced, setDebounced] = useState(value); useEffect(() => { const id = setTimeout(() => setDebounced(value), delayMs); return () => clearTimeout(id); }, [value, delayMs]); return debounced;}
// useDeferredValue: no delay to tune, no timer, no cleanup — React// schedules the background render at low priority and adapts automatically.const deferredQuery = useDeferredValue(query);Proof that the deferred value really does lag one render behind the urgent input, then catches up on its own:
import { useState, useDeferredValue } from 'react';import { render, screen, fireEvent, act } from '@testing-library/react';
function Demo({ onCommit }: { onCommit: (text: string, deferred: string) => void }) { const [text, setText] = useState('a'); const deferredText = useDeferredValue(text); onCommit(text, deferredText); return ( <div> <input aria-label="urgent" value={text} onChange={(e) => setText(e.target.value)} /> <p data-testid="deferred">{deferredText}</p> </div> );}
test('useDeferredValue commits an intermediate render with the OLD value before catching up to the new one', () => { const commits: Array<{ text: string; deferred: string }> = []; const onCommit = (text: string, deferred: string) => commits.push({ text, deferred });
render(<Demo onCommit={onCommit} />);
act(() => { fireEvent.change(screen.getByLabelText('urgent'), { target: { value: 'ab' } }); });
// react.dev: "React will first attempt a re-render with the old value, and // then try another re-render in the background with the new value." expect(commits).toEqual([ { text: 'a', deferred: 'a' }, // mount { text: 'ab', deferred: 'a' }, // urgent update commits; deferred still lags { text: 'ab', deferred: 'ab' }, // background render catches up ]);
expect(screen.getByLabelText<HTMLInputElement>('urgent').value).toBe('ab'); expect(screen.getByTestId('deferred').textContent).toBe('ab');});✓ useDeferredValue commits an intermediate render with the OLD value before catching up to the new one
Test Files 1 passed (1) Tests 1 passed (1)Reach for useDeferredValue when you only have a value (no setter to wrap in startTransition, e.g. it arrived as a prop); reach for debouncing when you specifically want to throttle a rate of calls — a network request on every keystroke, for instance, where “as soon as possible” is the wrong goal and you genuinely want to wait for a pause.
When the render itself is still the problem: virtualization
Section titled “When the render itself is still the problem: virtualization”Memoization and React.memo stop needless re-renders, but neither one helps when a single, necessary render has to produce thousands of real DOM nodes — a 10,000-row table renders slowly the first time no matter how well it’s memoized afterward. The fix for genuinely huge lists is virtualization: render only the rows currently scrolled into view (plus a small buffer), and swap them as the user scrolls, so the DOM only ever holds a few dozen nodes regardless of the underlying data size. This course does not add a virtualization library as a dependency, but the current standard tool for it is @tanstack/virtual — reach for it once a profiled render shows the cost is proportional to list length, not to anything memoization can remove.
Mental Model
Section titled “Mental Model”A re-render is not “did this component’s output change” — it is “did React re-run this component’s function.” By default it re-runs the entire subtree under any state change, and only then checks (via reconciliation) whether the DOM needs to change. React.memo inserts a checkpoint before that re-run: “are the props shallow-equal to last time? If so, skip re-running this function at all.” The Profiler API and DevTools exist so you never have to guess which of these two things — running the function, or committing a DOM change — is actually where your time goes.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Keep state low/local | Fewer components re-render per change, no extra API | Requires noticing where state actually lives; sometimes means restructuring components |
React.memo | Walls off an expensive subtree from a parent’s re-renders | Only works if every prop is referentially stable; adds a comparison on every render |
useDeferredValue | No delay to tune, automatically interruptible, integrates with Suspense | No isPending-style flag; you compare value !== deferredValue yourself |
| Debouncing | Simple, well-understood, good for reducing call rate (e.g. network requests) | Fixed delay you must guess; wastes the delay even when the render would have been cheap |
| Virtualization | DOM size stays constant regardless of list length | Extra dependency and complexity; breaks assumptions like “select-all” text or Ctrl+F across the whole list |
Common Misconceptions
Section titled “Common Misconceptions”- “Re-rendering means the DOM changed.” Reconciliation still runs a diff; a re-render with identical output produces zero DOM mutations. The cost is re-running the function and diffing, not necessarily touching the browser.
- “
useDeferredValuedelays the update by some amount of time, like debounce.” Proven above: there is no timer. It commits an immediate render with the old value, then starts the new one as soon as the first one finishes — not after any fixed wait. - “
React.memomakes a component fast.” It only skips re-running the function when props are unchanged; it does nothing for a component whose single, necessary render is itself expensive (that’s what virtualization or an algorithmic fix is for). - “I should profile the whole app to find the problem.” Profile the specific interaction that feels slow (React DevTools Profiler records a session) — a global profile buries the one component that matters under everything that re-rendered along the way but cost nothing.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated code that touches rendering performance, check for:
- Is
React.memoapplied to a component whose parent still passes it a fresh inline object, array, or function every render? The memo does nothing in that case (proven in the next lesson). - Does a “debounced search” actually use a fixed
setTimeoutwhereuseDeferredValuewould adapt automatically to real render cost — or vice versa, isuseDeferredValueused to throttle an actual network call (it does not reduce call count, only render priority)? - Is state that’s only read by one small leaf component declared much higher up the tree “just in case,” forcing unrelated siblings to re-render on every change?
- Does a claim of “this component doesn’t re-render often” have any Profiler or DevTools evidence behind it, or is it asserted from reading the code alone?
- Is a large, fixed-size list (thousands of rows) rendered in full with no virtualization, memoization used instead to paper over a render that is expensive by construction?
🐛 Spot the bug
const Row = React.memo(function Row({ label, onSelect }) {return <li onClick={onSelect}>{label}</li>;});
function List({ items, onPick }) {return ( <ul> {items.map((item) => ( <Row key={item.id} label={item.label} onSelect={() => onPick(item.id)} /> ))} </ul>);}Show the answer
Row is wrapped in React.memo, but List passes onSelect={() => onPick(item.id)} — a brand-new arrow function created on every render of List, for every row. React.memo’s shallow prop comparison sees a “changed” onSelect every single time, so every Row re-renders whenever List re-renders, exactly as if memo were not there at all. The fix is to give Row a stable callback: either pass item.id down and call onPick(id) inside Row itself (so List only passes the stable onPick reference), or wrap the per-row callback in useCallback keyed on item.id (only worthwhile if onPick itself is stable).