Frontend / Performance / 09_react_profiling.md

React Profiling and the Cost Model of Re-renders

Updated 5 interview angles 7 min read source
On this page14
  • When does a React component re-render?
  • What is the React Profiler and how do you use it?
  • The component API.
  • What causes unnecessary re-renders?
  • When does React.memo actually help?
  • When does useMemo actually help?
  • useCallback vs useMemo for functions?
  • What’s useSyncExternalStore and when does it matter for perf?
  • Long renders blocking the main thread — what about?
  • How does this interact with INP?
  • Gotchas
  • Related
  • Further reading
  • Interview angle
  • React Profiling and the Cost Model of Re-renders

    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.

    When does a React component re-render?

    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.

    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:

    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.

    The <Profiler> component API.

    For automated/instrumented measurement:

    tsx
    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

    tsx
    // 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

    tsx
    // 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

    tsx
    // 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

    tsx
    // 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:

    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.

    When does useMemo actually help?

    Three cases:

    1. Expensive computation — useMemo(() => 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.”

    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).

    tsx
    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:

    tsx
    // 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.”

    tsx
    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.”
    • 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 Core Web Vitals.

    Gotchas

    • memo doesn’t help if the parent passes new props every render — most components fall here.
    • memo with 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.
    • useMemo does not guarantee cache retention — React can drop memoized values to free memory.
    • useState lazy initializer — useState(() => 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.

    Further reading

    Interview angle 5

    • “Something re-renders too often. How do you find out why?” - the React DevTools Profiler with “record why each component rendered” enabled. It names the prop, state or context that changed, which turns a guess into a fact. Do not start by adding memo.
    • “What causes most surprising re-renders?” - unstable prop identity: a new object, array or inline function created during the parent’s render, so a memoized child compares unequal every time. Context is the same problem at scale, since an unmemoized provider value wakes every consumer.
    • “Is React.memo free?” - no, it is a shallow comparison on every render plus the memory to hold the previous props. On a cheap component that is a net loss. Apply it to components you have measured as expensive, not by reflex.
      • Follow-up: “Why does adding memo often change nothing?” - because the props are still unstable upstream. memo plus stable props is what skips a render; either half alone usually does not.
    • “What structural fix beats memoization?” - splitting state by what changes together. One monolithic state object re-renders every consumer on any change; separate pieces re-render only the components that read them, with no comparison cost at all.
    • “What do you reach for on an INP problem specifically?” - useTransition or useDeferredValue, so the urgent update - the keystroke, the tab highlight - commits immediately and the expensive re-render becomes interruptible work that yields to the next input.