I was in the middle of a client call when the UI froze for a split second after a button click. The console showed no errors, the network tab was quiet, but the page just hung. My instinct was to blame the API, yet the request had already resolved. The culprit turned out to be the way React was reconciling the component tree, a tiny inefficiency that blew up because the page rendered a list of 12,000 items.

What actually happens when you call setState or a hook updater is far more involved than “React re-runs the function”. Understanding the steps helps you spot the hidden cost centres and write components that stay snappy even at scale.

The entry point, a state update triggers a render

When you invoke a state setter (setCount(prev => prev + 1)) React schedules an update on the fiber associated with that component. The fiber is a lightweight JavaScript object that mirrors the component hierarchy. Each fiber stores:

  • the component type (function, class, host element)
  • its current props and state
  • a pointer to its parent, child and sibling fibers
  • a lane mask that represents the priority of pending work

React never runs the component immediately. Instead it pushes the update onto a queue and returns control to the browser. This is why you can fire several state changes in a row without seeing intermediate renders, they are batched into a single work loop.

Scheduling and the work loop

The scheduler lives in react-reconciler. It looks at the lane masks of all pending fibers and decides which priority to process first. User-initiated events get a higher lane (e.g., SyncLane) than background data fetches (DefaultLane). The scheduler then calls performSyncWorkOnRoot (or its async counterpart) which walks the fiber tree depth-first.

During this walk React creates a work-in-progress copy of each fiber. This copy is where the new render output is written, while the current fiber remains untouched until the whole tree is ready to be committed. The double-buffering model prevents half-finished UI from leaking into the DOM.

Rendering a function component, the render phase

For a function component the render phase consists of:

  1. Calling the component function with the current props and hook state.
  2. Running hooks, each useState, useEffect, etc., reads or writes its entry in a linked list stored on the fiber. The order matters; changing the order will break the hook chain.
  3. Returning JSX, which React transforms into a tree of React elements (plain objects with type, props, key).

No side effects happen here. Anything that touches the DOM, starts a timer, or fetches data must be scheduled for the commit phase via useEffect or useLayoutEffect.

Reconciliation, diffing the old and new trees

The core of the "under the hood" magic is the reconciliation algorithm. React compares the newly created element tree with the previous one stored on the current fibers. The rules are simple but powerful:

  • If the type and key match, React reuses the existing DOM node and updates its props.
  • If they differ, React discards the old node and creates a new one.
  • For lists, the key attribute is the only reliable way to keep items stable across renders.

Because the algorithm works per sibling list, a change deep in a large list can cause the entire list to be re-created if keys are missing or unstable. That is why the 12,000-item list in my client project froze, the items were rendered without keys, so each update caused a full teardown and rebuild.

The Fiber work loop in code

function workLoopSync() {
  while (nextUnitOfWork !== null) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }
  // When we run out of work, commit the changes
  if (workInProgressRoot !== null) {
    commitRoot(workInProgressRoot);
  }
}
  • nextUnitOfWork is a fiber that still needs its children rendered.
  • performUnitOfWork renders the component (function or class) and returns the next fiber to process.
  • Once the loop finishes, commitRoot mutates the real DOM.

The commit phase, applying changes to the DOM

Only after the whole tree is reconciled does React touch the browser. The commit phase runs in three passes:

  1. Before mutation, run useLayoutEffect cleanup functions.
  2. Mutation, insert, update or delete DOM nodes based on the effect list built during reconciliation.
  3. Layout, run useLayoutEffect setup functions and fire useEffect callbacks asynchronously.

Because mutation is a single batch, the browser paints only once, which is why React feels fast even when many nodes change.

Where performance pitfalls hide

1. Unstable keys in lists

If you generate keys from the index (key={i}) and the list can reorder, React will think items moved rather than stayed, leading to unnecessary DOM work. Always use a stable identifier from the data.

2. Heavy calculations in the render phase

Anything that takes noticeable CPU time inside the component function will block the work loop. Memoize expensive work with useMemo or move it to a Web Worker.

3. Over-rendering sibling sub-trees

When a parent component's state changes, all its children rerender even if they don't depend on that state. Split the UI into smaller components and lift state only as far as needed.

4. Ignoring the priority system

Calling setState inside a useEffect without a cleanup can force a sync render, freezing the UI. Prefer the async scheduler (startTransition) for non-urgent updates.

A practical pattern, selective rendering with React.memo

When you have a list of items that rarely change, wrap the item component in React.memo and provide a custom equality function if the props are complex.

const ListItem = React.memo(function ListItem({item}) {
  return <div>{item.title}</div>;
}, (prev, next) => prev.item.id === next.item.id);
  • React.memo skips the render phase if the props are shallow-equal.
  • The custom comparator ensures deep objects are compared by a stable field.

Place the memoized component inside the list, keep a proper key={item.id}, and you’ll see the render time drop dramatically for large datasets.

Debugging the render pipeline

React DevTools now shows a Profiler tab that visualizes each component’s render duration and why it rendered (props change, state change, context, etc.). Use the "Why did this render?" tooltip to pinpoint unexpected updates.

If you suspect the scheduler, add console.log inside a custom hook that logs the current lane:

function useDebugLane() {
  const lane = ReactCurrentDispatcher.current?.getCurrentLane?.();
  console.log('Current lane:', lane);
  return lane;
}
  • The printed lane will be a number; higher numbers mean lower priority.
  • Spotting a sync lane where you expected an async one tells you where to add startTransition.

When to drop the virtual DOM altogether

For ultra-high-frequency UI (e.g., a stock ticker updating 60 times a second), the overhead of reconciliation can become noticeable. In those cases consider:

  • Using a canvas or WebGL layer for the rapidly changing part.
  • Rendering the static UI with React and letting the canvas handle the live data.

This hybrid approach keeps the ergonomics of React for layout while avoiding the diff on every tick.

TL;DR

  • A state update creates a work-in-progress fiber, not an immediate render.
  • React walks the fiber tree, builds a new element tree, and diffs it against the current one.
  • Keys, memoization and proper state placement are the three biggest levers to keep the diff cheap.
  • The commit phase batches DOM mutations, so the browser paints only once.
  • Use the Profiler and lane debugging to surface hidden performance costs.

FAQ

Why does my component re-render even when its props haven't changed?

React re-renders a component whenever its parent renders. If the parent’s state changes, all children run again unless they are wrapped in React.memo (or shouldComponentUpdate for class components). Check the component tree for unnecessary state hoisting.

How can I tell if the reconciliation is the bottleneck?

Open the React Profiler, record a session, and look at the "Render" bar for each component. A long bar indicates heavy work in the render phase. If the bar is short but the overall frame time is high, the bottleneck is likely in the commit (DOM mutation) phase.

When should I use startTransition instead of plain setState?

Use startTransition for updates that don't need to be reflected immediately, for example, filtering a large list, opening a modal, or loading a new page section. It tells React to treat the update as low priority, allowing urgent user interactions to stay responsive.

Wrapping up

If you ever find a UI freeze, trace it back to the fiber that caused the extra work: missing keys, heavy render logic, or a sync priority that should be async. Fix those three spots and you’ll turn a sluggish page into a smooth experience. Next time you write a component, picture the double-buffered fibers marching through the work loop, that mental model will save you from many performance surprises.