React Re-rendering: When and Why Component Trees Update

Render phase vs commit phase, reconciliation and fibers, what triggers re-renders, referential equality traps, context pitfalls, and React 18 batching.

Ashish 12 min read

Related: Long Tasks and Main Thread Blocking heavy React renders are one of the most common sources of Long Tasks.

React’s default re-render behavior is intentionally conservative: when a parent re-renders, all children re-render too. This is correct by default — React prioritizes correctness over performance, and render-phase work (function calls, hook execution) is cheap enough that unnecessary re-renders are often harmless. But “often harmless” is not “always harmless.”

What this covers: The exact four triggers that cause a component to re-render, how reconciliation and the fiber tree work, why referential equality matters for memoization, the context performance trap, and how to read the React DevTools Profiler to find the root cause of unexpected re-renders.

Diagram of React's render phase versus commit phase: reconciliation produces an effect list, then the DOM is updated.

Render phase vs commit phase

React’s work of “updating the UI” is split into two fundamentally different phases. Confusing them is the root of a lot of performance misconceptions.

The render phase

The render phase is when React calls your component functions and figures out what the UI should look like. When you call setState, React schedules a render. During the render phase, React:

  1. Calls the component function (your function component body executes).
  2. Calls all the hooks in order (useState, useEffect, useMemo, etc.).
  3. Gets the returned JSX.
  4. Does this recursively for any child components that also need updating.
  5. Diffs the new output against the previous output (reconciliation).

Critical insight: the render phase is *pure work: React is just computing what the UI should be. It doesn’t touch the DOM yet. Your component function can be called and produce output that React then decides to discard (this is what StrictMode’s double-render exploits see below).

The commit phase

The commit phase is when React actually applies changes to the DOM. It has three sub-phases:

  1. Before mutation: fires getSnapshotBeforeUpdate lifecycle and captures current DOM state.
  2. Mutation: applies DOM insertions, updates, and deletions. This is the only time React directly touches the DOM.
  3. Layout: fires useLayoutEffect and componentDidMount/componentDidUpdate synchronously. This is why useLayoutEffect can measure DOM layout the DOM is updated but the browser hasn’t painted yet.
  4. After the commit, useEffect callbacks are scheduled for after the browser has painted.

Understanding this split matters because: render phase work is cheap to do multiple times (it’s just function calls and object comparisons). Commit phase work touches the DOM and can trigger browser reflow. React is clever about doing as little DOM work as possible.


Reconciliation and the fiber tree

React doesn’t just compare new JSX against a flat list of DOM elements. It maintains an internal data structure called the fiber tree a graph of objects representing every component instance in your app, including their state, hooks, and pending work.

Each fiber corresponds to one component instance. When a state update is triggered, React creates an alternative “work in progress” fiber tree and starts reconciling it against the current tree. This is the reconciliation algorithm.

What reconciliation does:

  • Compares new JSX element type against the existing fiber’s type.
  • If the type changed (e.g., <Button> became <a>), React unmounts the old component tree and mounts a fresh one.
  • If the type is the same, React updates the existing fiber with new props, runs hooks again, and recurses into children.
  • For lists, it uses keys to match new elements to existing fibers.

The key performance insight: reconciliation is proportional to the size of the fiber tree that gets re-rendered. If you trigger a re-render high in the tree, React walks down through all descendants. This is why the settings checkbox caused 47 re-renders the useState was placed in a component that was the parent of almost everything.


What triggers a re-render

There are exactly four causes of a React component re-rendering:

TriggerDescription
setState callCalling the setter from useState or useReducer dispatch
Props changeParent re-renders and passes new prop values (by reference)
Context value changesAny consumer of a context re-renders when the context value changes
Parent re-rendersA component re-renders when its parent re-renders, even if props didn’t change

The fourth one surprises people the most. In React’s default behavior, if a parent re-renders, all children re-render too regardless of whether their props changed. This is the default: without memoization, the component tree re-renders in a cascade from the component that triggered the state change, downward.

This is actually a deliberate design choice. React assumes that re-rendering is cheap (it’s just function calls) and that computing whether to skip a render is sometimes more expensive than just doing the render. The defaults are optimized for correctness, not maximum performance.


Re-renders don’t mean DOM updates

This is a crucial clarification that trips up a lot of performance investigations.

When 47 components re-render, React calls 47 component functions and gets back 47 JSX trees. Then it diffs those against the previous output. If the output is identical, React makes zero DOM changes for that component. No DOM mutations, no browser reflow, nothing.

So “47 components re-rendered” in React DevTools means “47 component functions were called, not “47 DOM nodes were updated.” The actual DOM impact depends on how many of those components produced different output.

This distinction matters because: pure render cost (function calls, hook execution) is usually cheap. DOM mutation cost is what triggers browser layout and paint. If your 47 re-rendering components all produce the same output as before, you might have wasted 2ms of JavaScript time, but you’ve caused zero additional browser rendering work.

That said, 47 function calls isn’t free: if any of those functions do expensive computations inline (without useMemo), or if React itself has to run through complex reconciliation logic, you’ll feel it.


Why referential equality matters

This is where function components differ fundamentally from the mental model of “props changed = new values.” React uses referential equality (===) to compare props and determine if a memoized component should skip its render.

function Parent() {
  const [count, setCount] = useState(0);

  // ⚠️ New object reference created on every render
  const config = { theme: 'dark', size: 'medium' };

  // ⚠️ New function reference created on every render
  const handleClick = () => setCount(c => c + 1);

  return (
    <>
      <Counter value={count} onClick={handleClick} />
      <Settings config={config} />
    </>
  );
}

Every time Parent re-renders, config and handleClick are brand new objects. They’re deeply equal to the previous values (same shape, same content) but config === previousConfig is false because they’re different object references.

If Settings is wrapped in React.memo, it will still re-render because its config prop is a new reference, even though nothing meaningfully changed.

// useMemo stabilizes the reference between renders
const config = useMemo(() => ({ theme: 'dark', size: 'medium' }), []);

// useCallback stabilizes function references
const handleClick = useCallback(() => setCount(c => c + 1), []);

useMemo and useCallback are not about avoiding “expensive computations: they’re primarily about referential stability. Their main job is to prevent downstream re-renders caused by new object/function references.


The context performance problem

Context is often described as a solution for “prop drilling: passing data through many levels of components. It is that. It’s also a performance footgun if you’re not careful about what you put in it.

Context is a broadcast. When the context value changes, every component that consumes that context re-renders, regardless of whether the specific piece of data it reads changed.

// ⚠️ This context re-renders ALL consumers when ANYTHING in the object changes
const AppContext = createContext({
  user: null,
  theme: 'light',
  notifications: [],
  sidebarOpen: false,
});

function App() {
  const [sidebarOpen, setSidebarOpen] = useState(false);

  // New object reference when sidebarOpen changes
  // → Every context consumer re-renders, including deep UI components that only care about `user`
  const value = { user, theme, notifications, sidebarOpen };

  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

Toggling the sidebar causes every consumer of AppContext to re-render, including components that only ever read user. The fix is to split context by update frequency:

// Stable values that rarely change
const UserContext = createContext(null);
// Dynamic values that change often
const UIStateContext = createContext({ sidebarOpen: false });

// Now sidebar state changes only affect UIStateContext consumers

A useful mental model: each context should have one reason to change. If your context value object contains both stable auth data and frequently-changing UI state, you’ll cause unnecessary re-renders across the entire consumer tree.


Keys in lists: what they actually do

Keys serve a specific mechanical purpose during reconciliation. React uses keys to match new list elements to existing fibers when the list changes. Without keys (or with incorrect keys), React falls back to matching by position.

// Without keys  React matches by index
// Adding an item to the beginning: React thinks EVERY item changed
{items.map((item, index) => (
  <Card key={index} {...item} />
))}

// With stable IDs: React correctly identifies which item was added
{items.map(item => (
  <Card key={item.id} {...item} />
))}

When you use key={index} and prepend an item to the list, React sees:

  • Position 0: had {id: 1}, now has {id: 0} → update this fiber
  • Position 1: had {id: 2}, now has {id: 1} → update this fiber
  • etc.

Every fiber gets updated because positions changed, even though only one item was added. With stable IDs, React sees that existing fibers just shifted position and correctly reconciles with minimal work.

The random-key anti-pattern is even worse:

// ⚠️ Generates a new key on every render  destroys all reconciliation benefits
{items.map(item => (
  <Card key={Math.random()} {...item} />
))}

With random keys, every render unmounts all existing Card components and mounts fresh ones. You lose all component state, all DOM node reuse, and get maximum mount/unmount work on every render.


React 18 automatic batching

Before React 18, state updates inside setTimeout, Promise.then, or native event handlers were processed individually: one setState = one re-render.

// React 17: 2 renders
setTimeout(() => {
  setCount(c => c + 1); // Render 1
  setLoading(false);    // Render 2
}, 1000);

// React 18: 1 render (automatic batching)
setTimeout(() => {
  setCount(c => c + 1); // Batched
  setLoading(false);    // Batched → single render
}, 1000);

React 18’s automatic batching extends the existing batching behavior (which previously only worked in React event handlers) to all asynchronous contexts. This is a free performance improvement that many apps benefit from immediately after upgrading.

The cases where this matters most: fetch callbacks that update multiple state values, async event handlers that set loading + data states together, and any code that does multiple setState calls in a row in async code.

If you need to explicitly opt out of batching (rare), flushSync from react-dom forces synchronous processing:

import { flushSync } from 'react-dom';

// Forces immediate render after each setState
flushSync(() => setCount(1));
flushSync(() => setLoading(false));

StrictMode’s double-render in development

If you’re using React.StrictMode (you should be in development), your component functions are called twice during the render phase in development mode. This is intentional and a source of confusion for developers who see their console.log appearing twice.

What StrictMode is doing: it deliberately calls your component function twice to check that the function is pure that calling it multiple times with the same inputs produces the same output. If your component has side effects in the render phase (network requests, direct DOM mutations, setting external variables), the double-render will expose them because those effects will fire twice.

function BadComponent() {
  // ⚠️ Side effect in render  this fires twice in StrictMode development
  someGlobalCounter++;

  return <div>{someGlobalCounter}</div>;
}

The double-render only happens in development mode. Production builds render once. If something works in development but breaks in production differently, StrictMode’s double-render is not the cause but the bugs it exposes in development might surface as subtle issues in production.


Reading the React Profiler

The React DevTools Profiler is the right tool for understanding re-renders. Open DevTools → Components → Profiler. Hit Record, do the interaction that feels slow, stop recording.

The flame chart shows every component that rendered, how long it took, and crucially why it rendered:

  • Props changed one or more props have a different reference than the previous render.
  • State changed a useState or useReducer hook in this component changed.
  • Context changed a context this component subscribes to changed.
  • Parent re-rendered no local reason, but the parent rendered so this did too.
  • Hooks changed a hook produced a different value than before.

The “why did this render?” information is invaluable for diagnosing unnecessary re-renders. When you see “parent re-rendered” for a component that shouldn’t care about its parent’s state change, that’s the target for React.memo. When you see “props changed” for a memoized component that’s receiving an object prop, that’s the target for useMemo.

Profiler “Why”Likely fix
”Parent rendered” on a pure display componentWrap with React.memo
”Props changed” on a memoized componentStabilize prop reference with useMemo/useCallback
”Context changed” but component only reads one fieldSplit context by update frequency
”State changed” unexpectedCheck if the state is co-located at the right level

The Profiler also shows total render duration per component. If a single component consistently takes 15ms+ to render, that’s a component-level optimization opportunity either memoizing expensive calculations inside it or splitting it into smaller pieces.