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:
- Calling the component function with the current props and hook state.
- 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. - 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
typeandkeymatch, 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
keyattribute 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);
}
}
nextUnitOfWorkis a fiber that still needs its children rendered.performUnitOfWorkrenders the component (function or class) and returns the next fiber to process.- Once the loop finishes,
commitRootmutates 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:
- Before mutation, run
useLayoutEffectcleanup functions. - Mutation, insert, update or delete DOM nodes based on the effect list built during reconciliation.
- Layout, run
useLayoutEffectsetup functions and fireuseEffectcallbacks 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.memoskips 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.