frontend / performance / 09_react_profiling.md

React Profiling and the Cost Model of Re-renders

7 min read source

React Profiling and the Cost Model of Re-renders

TL;DR

A React re-render is not free, but rarely the problem — DOM work usually is. The first instinct should be the React DevTools Profiler (or <Profiler> API), which tells you exactly which components re-rendered, why, and how long the render phase took. Common causes of unnecessary re-renders: referentially-unstable props (new object/array/function on every parent render), context whose value identity changes, state shape that triggers more updates than necessary. The fix is rarely React.memo everywhere — it’s usually a single hoist, a useMemo on a stable value, or restructuring the component tree.

Interview Q&A

Q: When does a React component re-render?

A: When any of these change:

  1. Its own state (useState setter called, useReducer dispatch).
  2. A useContext value it consumes changes.
  3. Its parent re-renders — unless the component is React.memo-wrapped and props are referentially equal.

(External stores via useSyncExternalStore also trigger, but that’s a special case.)

The key surprise for many devs: parent re-render causes child re-render by default. Whether or not the child’s props are “the same” — equality isn’t checked unless you opt in with memo.

Q: What is the React Profiler and how do you use it?

A: The React DevTools “Profiler” tab records component renders during an interaction. After recording:

  • A flamegraph shows component render times per commit.
  • A ranked view shows which components took longest.
  • Each commit shows what triggered it (“Why did this render?” panel) — props change, state change, hook change, context change.

Workflow:

  1. Open React DevTools → Profiler.
  2. Click record.
  3. Perform the slow interaction.
  4. Stop recording, inspect commits.
  5. Identify components that re-rendered unnecessarily or rendered too slowly.

The “Why did this render?” feature is enabled via the gear → “Record why each component rendered while profiling.” Turn it on.

Q: The <Profiler> component API.

A: For automated/instrumented measurement:

import { Profiler } from "react";

function onRender(
  id: string,
  phase: "mount" | "update" | "nested-update",
  actualDuration: number,
  baseDuration: number,
  startTime: number,
  commitTime: number,
) {
  // log to analytics, etc.
}

<Profiler id="Dashboard" onRender={onRender}>
  <Dashboard />
</Profiler>

actualDuration — time for this commit (with memoization savings). baseDuration — estimated time without memoization. The gap between them tells you how much memoization is saving.

Use it to ship to production telemetry for specific high-cost trees, alerting on regressions.

Q: What causes unnecessary re-renders?

A: The usual suspects:

Unstable inline objects / arrays

// New object every render → memo'd child still re-renders
<UserCard user={{ id, name }} />

// Better — stable object via useMemo or hoisting
const user = useMemo(() => ({ id, name }), [id, name]);
<UserCard user={user} />

Unstable inline callbacks

// New function every render
<Button onClick={() => save(item.id)} />

// Stable
const onClick = useCallback(() => save(item.id), [item.id]);
<Button onClick={onClick} />

Only matters when the child is memo-wrapped or the prop change triggers an useEffect dependency.

Context value identity change

// Bad — new object every render
<MyContext.Provider value={{ user, setUser }}>

// Better
const value = useMemo(() => ({ user, setUser }), [user]);
<MyContext.Provider value={value}>

Every context consumer re-renders when the value identity changes. With a fresh object every render, all consumers re-render on every parent render.

State shape too coarse

// Coarse — any field change re-renders everyone
const [state, setState] = useState({ count: 0, name: "", theme: "dark" });

// Better — split unrelated state
const [count, setCount] = useState(0);
const [name, setName] = useState("");

The senior framing: state should be split by what changes together.

Q: When does React.memo actually help?

A: When all of these are true:

  1. The component is expensive to render (large, deep tree, or computes a lot).
  2. It re-renders more often than necessary (frequent parent renders, but its own props rarely change).
  3. The props are referentially stable (otherwise memo defeats itself).

In practice, that’s a minority of components. A simple <Button> that renders fast and gets new onClick each parent render: memo can hurt — you’re doing a shallow comparison every render plus passing a stable callback, all to skip a cheap render.

Profile first. Apply memo to measured hot spots, not by reflex.

Q: When does useMemo actually help?

A: Three cases:

  1. Expensive computationuseMemo(() => sortBigArray(items), [items]). Skip the work on renders where deps haven’t changed.
  2. Referential stability for downstream memo/effects — the object/array/function you pass to a memo’d child or useEffect dep array needs to be referentially stable across renders.
  3. Avoiding cascading recomputes in derived data graphs.

When useMemo doesn’t help (and may hurt):

  • Cheap computations — the comparison + memoization cost dwarfs the saved work.
  • Pure value pass-through with no memo child / effect dep — there’s no consumer of the stability.

The senior rule: useMemo is for measured savings or referential stability, not “just in case.”

Q: useCallback vs useMemo for functions?

A: useCallback(fn, deps) is equivalent to useMemo(() => fn, deps). Use useCallback for functions, useMemo for values — both have the same opt-in cost-benefit calculus.

Q: What’s useSyncExternalStore and when does it matter for perf?

A: The official React hook for subscribing to external state stores (Redux, Zustand, browser APIs). Re-renders the component only when the subscribed slice changes (the selector returns a different value).

const count = useSyncExternalStore(
  store.subscribe,
  () => store.getState().count,
  () => initialState.count,    // SSR snapshot
);

Performance value: a Redux store with one component subscribing via useSyncExternalStore(selector) doesn’t re-render on unrelated state changes — only when selector returns a new value. Zustand uses it under the hood.

For Context-based pub/sub, this hook beats useContext for many subscribers reading different slices.

Q: Long renders blocking the main thread — what about?

A: React 18+ concurrent features defer non-urgent updates:

// Urgent — input feels snappy
setQuery(value);

// Non-urgent — search results render can be deferred
startTransition(() => {
  setResults(computeResults(value));
});

useTransition marks an update as non-urgent — React can interrupt it to handle higher-priority work (typing, clicking). Pairs with <Suspense> for lazy data.

useDeferredValue is the “I get a value that lags behind the source by a render or two.”

const deferredQuery = useDeferredValue(query);
const results = useMemo(() => search(deferredQuery), [deferredQuery]);

Used in real apps for: typeahead (input is urgent, list re-render is deferred), tab content (the click is urgent, content load is deferred), filters (filter change is urgent, large list re-render is deferred).

Q: How does this interact with INP?

A: Every re-render is a chunk of JS work on the main thread. A click handler that triggers a 200ms render → 200ms of INP. Tools:

  • Profiler for “what is rendering and why.”
  • Chrome DevTools Performance for “is the main thread blocked, by what.”
  • useTransition to bump non-urgent work off the critical path.
  • Virtualization for big lists (the render time is per-row × visible rows).

INP is the perf metric that React profiling directly affects. See 01_core_web_vitals.md.

Gotchas / edge cases

  • memo doesn’t help if the parent passes new props every render — most components fall here.
  • memo with custom equalitymemo(Component, (prev, next) => deepEqual(prev, next)). Hand-roll if shallow isn’t enough; but consider whether your prop shape is the real problem.
  • useMemo does not guarantee cache retention — React can drop memoized values to free memory.
  • useState lazy initializeruseState(() => expensiveInit()) runs expensiveInit only on first render. Forget the function wrap and it runs every render.
  • Render in useEffect (calling setState in an effect) causes a second render — usually wrong; lift to derived state, or useMemo.
  • Strict Mode double-renders in dev are intentional — surfaces side effects you forgot to make idempotent. Doesn’t affect prod.
  • Profiler in production builds is stripped unless you build with profiling.js variants — keep that in mind for prod telemetry.

What a senior is expected to say

  • “Profile first — React DevTools Profiler with ‘why did this render’ on. Don’t guess at re-render causes.”
  • “Most over-renders are unstable props (new object/function per parent render) or unstable context values. The fix is usually a single useMemo or hoisting, not blanket memo.”
  • React.memo is a tax — shallow-compare per render. Apply to measured hot components, not by reflex.”
  • “Split state by what changes together. A monolithic state object re-renders everyone on every change; split state re-renders only the relevant subscriber.”
  • useTransition/useDeferredValue for INP-sensitive flows — typing, large filter changes, tab switches. The urgent update stays fast; the heavy re-render is interruptible.”
  • “Context value should be memoized or split — every consumer re-renders on identity change.”

Cross-references

Further reading