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.
In depth
When does a React component re-render?
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.
What is the React Profiler and how do you use it?
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.
The <Profiler> component API.
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.
What causes unnecessary re-renders?
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.
When does React.memo actually help?
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.
When does useMemo actually help?
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.”
useCallback vs useMemo for functions?
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.
What’s useSyncExternalStore and when does it matter for perf?
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.
Long renders blocking the main thread — what about?
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).
How does this interact with INP?
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 Core Web Vitals — LCP, INP, CLS.
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 6
- “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): Core Web Vitals — LCP, INP, CLS
- Memoization deeper dive: Memoization Heuristics — When useMemo/useCallback/memo Help vs Hurt
- React internals (Fiber, reconciliation): 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