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:
- Its own state (
useStatesetter called,useReducerdispatch). - A
useContextvalue it consumes changes. - 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:
- Open React DevTools → Profiler.
- Click record.
- Perform the slow interaction.
- Stop recording, inspect commits.
- 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:
- The component is expensive to render (large, deep tree, or computes a lot).
- It re-renders more often than necessary (frequent parent renders, but its own props rarely change).
- 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:
- Expensive computation —
useMemo(() => sortBigArray(items), [items]). Skip the work on renders where deps haven’t changed. - Referential stability for downstream
memo/effects — the object/array/function you pass to amemo’d child oruseEffectdep array needs to be referentially stable across renders. - 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
memochild / 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.”
useTransitionto 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
memodoesn’t help if the parent passes new props every render — most components fall here.memowith custom equality —memo(Component, (prev, next) => deepEqual(prev, next)). Hand-roll if shallow isn’t enough; but consider whether your prop shape is the real problem.useMemodoes not guarantee cache retention — React can drop memoized values to free memory.useStatelazy initializer —useState(() => expensiveInit())runsexpensiveInitonly on first render. Forget the function wrap and it runs every render.- Render in
useEffect(callingsetStatein an effect) causes a second render — usually wrong; lift to derived state, oruseMemo. - 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.jsvariants — 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
useMemoor hoisting, not blanketmemo.” - “
React.memois 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/useDeferredValuefor 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
- INP (the metric React perf most affects): 01_core_web_vitals.md
- Memoization deeper dive: 10_memoization_heuristics.md
- React internals (Fiber, reconciliation): ../05_react/
Further reading
- React docs —
<Profiler>: https://react.dev/reference/react/Profiler - React docs —
useTransition: https://react.dev/reference/react/useTransition - React docs —
useDeferredValue: https://react.dev/reference/react/useDeferredValue - Mark Erikson — “When Does setState Cause a Re-render?” and related posts on Redux/React perf