DEV Community

Cover image for React, deeply · 1. Rendering and reconciliation: what React does on a state change
Amit chakraborty
Amit chakraborty

Posted on Originally published at amitchakraborty.dev

React, deeply · 1. Rendering and reconciliation: what React does on a state change

React, deeply · Chapter 1 of 12 · Web · new chapter every Monday night

By the end of this chapter: Describe the render, reconcile and commit phases precisely enough to predict behaviour.

The problem

Emma is a front‑end engineer on a two‑month sprint for a real‑time analytics dashboard. The UI pulls live metrics from a WebSocket and updates a table every second. After merging a new “filter by region” feature, the dashboard starts missing updates: the table lags by up to three seconds, the CPU on the user’s laptop spikes to 90 % and the performance monitoring alert fires every time a user opens the page. The sprint deadline is three days away, and the team cannot afford to roll back the feature.

Emma knows that React’s useState call in the filter component is being invoked correctly – the state value changes, the UI shows the new region label – but she cannot see why the rest of the component tree is taking so long to reflect the new data. She has the React docs open, but they only describe the high‑level “React re‑renders when state changes”. She needs a concrete, step‑by‑step mental model of what React actually does after a state change, how the three phases (render, reconciliation, commit) interact, and how to predict whether a change will cause extra work. Only then can she pinpoint the bottleneck and fix the dashboard before the deadline.


Before you start

  • Node ≥ 18.15.0
  • npm ≥ 9.6.0
  • React 18.2.0 and React‑DOM 18.2.0 (the current stable release as of October 2026)
  • A fresh create‑react‑app project (or any existing React 18 app) – run npx create-react-app r18-demo --template cra-template-pwa to get a working baseline.
  • Verify the setup works by starting the dev server and seeing “Edit src/App.js” in the browser:
cd r18-demo
npm start
# Expected output in the terminal:
# > Local:   http://localhost:3000
# Open that URL – you should see the default CRA page.
Enter fullscreen mode Exit fullscreen mode

If the page renders, you have a correct environment and can proceed.


What does a state update trigger?

1. Does React schedule work or run it immediately?

When setState (or the setter returned by useState) is called, React does not synchronously touch the DOM. Instead it creates a update object, pushes it onto the fiber’s update queue, and asks the scheduler to perform work. In React 18 the scheduler always batches updates that happen within the same tick, even if they originate from different event sources.

Why it matters: If you call setState inside a setTimeout, React still batches that call with any other updates that happen before the next paint. Knowing the batching behaviour tells you whether a series of rapid state changes will cause a single render or many.

2. Render phase – building the “virtual” tree

During the render phase React walks the fiber tree depth‑first, calling each component function to produce a new React element tree (the “virtual DOM”). No side‑effects run here – even useEffect callbacks are not executed. The result is a new set of fibers that mirror the element tree.

Key points:

Concept What happens Why it matters
Pure function execution Component bodies run with the current state/props. Guarantees determinism – the same inputs give the same output.
Hooks order React validates that the hook call order matches the previous render. Mismatched order throws “Rendered more hooks than during the previous render”.
Early bail‑out If a component’s props and state are shallow‑equal to the previous render, React may skip rendering its children (the memo optimisation). Prevents unnecessary work.

You can confirm the render phase by adding console.log('render', name) at the top of a component; you’ll see it run before any DOM changes.

3. Reconciliation – diffing the old and new fibers

After the new tree is built, React enters the reconciliation (or “diff”) phase. It compares the new fibers with the previous ones to decide what needs to change.

  • Keyed children – If a list has a stable key prop, React can match old and new items by key, moving existing DOM nodes instead of recreating them. Without keys, React falls back to index‑based matching, which can cause unnecessary re‑mounts.

  • Type change – If the element type changes (e.g., <div> → <span>), React discards the old fiber subtree and creates a new one.

  • Effect list construction – While reconciling, React builds three linked lists: Placement, Update, and Deletion. These lists are later used in the commit phase.

The cost of reconciliation is proportional to the number of fibers examined. A deep tree with many siblings can become expensive if keys are missing or if component props change on every render.

4. Commit phase – mutating the DOM and running effects

The commit phase is the only phase that mutates the host environment (the browser DOM). It proceeds in three sub‑steps:

  1. Before mutation – Runs getSnapshotBeforeUpdate (class components) and prepares layout effects. No DOM changes yet.
  2. Mutation – Applies all changes from the effect lists: inserts, updates, deletions. This is where the browser repaints.
  3. Layout effects – Executes useLayoutEffect callbacks synchronously after the DOM mutation but before the browser paints.
  4. Passive effects – Schedules useEffect callbacks to run after the paint (asynchronously).

Because layout effects run before the browser paints, they can block rendering if they perform heavy work. Passive effects, on the other hand, never block the paint.


How to observe each phase in a real component

1. How can I tell when the render phase starts and ends?

Add a console.time/console.timeEnd pair at the top of the component:

function TableRow({ row }) {
  console.time('TableRow render');
  // component body …
  console.timeEnd('TableRow render');
  return <tr>{/* … */}</tr>;
}
Enter fullscreen mode Exit fullscreen mode

The console will show a line like TableRow render: 0.42ms. If you see many rows each taking > 1 ms, the render phase is a candidate for optimisation.

2. How do I see the reconciliation decisions?

React DevTools’ “Profiler” tab records mount and update timings and shows a “flame graph”. Hover over a component to see whether the update was a “re‑render” (same type, same key) or a “re‑mount” (type changed, key missing). A re‑mount will display a “Mount” badge.

3. How can I verify when effects run relative to the DOM mutation?

Insert logs in both useLayoutEffect and useEffect:

useLayoutEffect(() => {
  console.log('layout effect – DOM ready');
}, [data]);

useEffect(() => {
  console.log('passive effect – after paint');
}, [data]);
Enter fullscreen mode Exit fullscreen mode

Open the console and watch the order. Layout effects will always appear before the passive ones for the same update.


Build it – instrument a component to visualise the three phases

Below is a minimal reproducible example that logs every phase for a component that displays a counter. Follow the steps exactly; each command’s output is shown so you can confirm you’re on track.

  1. Create the component file
   mkdir -p src/diagnostics && touch src/diagnostics/PhaseLogger.jsx
Enter fullscreen mode Exit fullscreen mode
  1. Add the code (src/diagnostics/PhaseLogger.jsx):
   import React, { useState, useLayoutEffect, useEffect } from 'react';

   export default function PhaseLogger() {
     const [count, setCount] = useState(0);

     console.time(`render ${count}`);

     // Render phase ends when the function returns
     console.timeEnd(`render ${count}`);

     useLayoutEffect(() => {
       console.log('🔧 layout effect – count:', count);
     }, [count]);

     useEffect(() => {
       console.log('🌀 passive effect – count:', count);
     }, [count]);

     return (
       <div>
         <p>Count: {count}</p>
         <button onClick={() => setCount(c => c + 1)}>+1</button>
       </div>
     );
   }
Enter fullscreen mode Exit fullscreen mode

Why: console.time/console.timeEnd surround the component body, marking the render phase. The two effects log when they fire.

  1. Render the component – replace the default App content with PhaseLogger:
   // src/App.js
   import React from 'react';
   import PhaseLogger from './diagnostics/PhaseLogger';

   function App() {
     return (
       <div className="App">
         <PhaseLogger />
       </div>
     );
   }

   export default App;
Enter fullscreen mode Exit fullscreen mode
  1. Run the dev server (if not already running):
   npm start
Enter fullscreen mode Exit fullscreen mode
  1. Observe the console – open Chrome DevTools, go to the Console tab, and click the “+1” button three times. You should see a repeating pattern:
   render 0: 0.12ms
   🔧 layout effect – count: 0
   🌀 passive effect – count: 0
   render 1: 0.09ms
   🔧 layout effect – count: 1
   🌀 passive effect – count: 1
   …
Enter fullscreen mode Exit fullscreen mode

Confirmation: The render log appears before any effect logs, proving the order of phases.

  1. Add a heavy computation to the render to see the impact:
   // Inside PhaseLogger, before the return:
   const heavy = Array.from({ length: 1e5 }, (_, i) => i).reduce((a, b) => a + b, 0);
   console.log('heavy sum', heavy);
Enter fullscreen mode Exit fullscreen mode

Reload the page and click the button. The render time will jump (e.g., render 2: 12.4ms). This demonstrates how expensive work in the render phase directly slows the UI.

  1. Move the heavy work to a layout effect:
   useLayoutEffect(() => {
     const heavy = Array.from({ length: 1e5 }, (_, i) => i).reduce((a, b) => a + b, 0);
     console.log('layout heavy sum', heavy);
   }, [count]);
Enter fullscreen mode Exit fullscreen mode

Remove the computation from the render body. Now the render logs stay ~0.1 ms, while the layout effect logs the heavy sum. The UI feels snappier because the render phase is fast, but the layout effect blocks the paint – you’ll notice a brief freeze after each click.

  1. Finally, move the work to a passive effect:
   useEffect(() => {
     const heavy = Array.from({ length: 1e5 }, (_, i) => i).reduce((a, b) => a + b, 0);
     console.log('passive heavy sum', heavy);
   }, [count]);
Enter fullscreen mode Exit fullscreen mode

Now the render is fast, the layout effect does nothing, and the heavy sum appears after the paint. The UI feels responsive, but the heavy work still consumes CPU in the background.

You have built a minimal instrumented component that demonstrates the three phases, their ordering, and the performance impact of doing work in each phase.


When this breaks

Symptom (what you see) Likely cause Fix
“Maximum update depth exceeded” (stack overflow) A state update inside a useEffect (or useLayoutEffect) that depends on the same state, causing an infinite loop. Ensure the effect’s dependency array does not include the state you set, or guard the update with a condition. Example: useEffect(() => { if (ready) setState(...); }, [ready]);
Rows in a list lose their input values after a filter change Missing key on list items, causing React to treat them as new mounts on each filter. Add a stable key prop (e.g., key={row.id}) to every element in the array map.
UI freezes for ~200 ms after a state change, even though the component is tiny Heavy computation performed during the render phase (or inside a useLayoutEffect). Move the work to a useEffect (passive) or, if it must be synchronous, memoise it with useMemo and ensure the memoisation dependencies are correct.
useLayoutEffect logs appear **after the DOM is painted** Running the app in React 18’s concurrent mode with StrictMode enabled, which intentionally runs layout effects twice in development. Remember that the double‑run only happens in dev; it does not affect production. If the double execution is problematic, temporarily disable StrictMode for that subtree.
Profiler shows “mount” instead of “update” for a component that should only re‑render The component’s key changed between renders, or its type changed (e.g., switching from <ComponentA> to <ComponentB>). Keep the component type and key stable; if you need to swap implementations, wrap them in a parent that preserves the key.

Each of these failures can be reproduced by the instrumented PhaseLogger component: add a setState inside an effect that depends on the same state to trigger the infinite loop; remove key from a list to see lost inputs; insert a heavy loop in the render body to cause a freeze.


What it costs

Technique Trade‑off
Running heavy work in the render phase Guarantees the DOM reflects the latest data before the paint, but each render blocks the main thread, leading to jank. Use only for cheap calculations.
Using useLayoutEffect for synchronisation Provides a hook that runs before the paint, useful for measuring layout or synchronising scroll positions. However, it blocks the paint and can cause noticeable pauses on low‑end devices.
Moving work to useEffect (passive) Keeps the UI responsive because the effect runs after the paint. The downside is that any DOM measurement must be deferred, and the UI may briefly show an intermediate state.
Relying on key‑based reconciliation Greatly reduces unnecessary re‑mounts, but requires that every item in a list has a stable, unique identifier. Generating keys on the fly (e.g., using index) defeats the optimisation and can cause state loss.
Batching updates (default in React 18) Reduces the number of renders, but can hide bugs where you expect an immediate DOM update after a setState. If you need the update instantly, use flushSync (cost: forces a synchronous render, defeating the batch).

Understanding these costs lets you decide where to place work in the lifecycle and when to sacrifice immediacy for smoother frames.


In the interview

Typical interview prompt

“Walk me through what happens when you call setState in a function component. Include the phases React goes through and why the order matters.”

Weak answer – “React re‑renders the component and updates the DOM.”

Why it’s weak: It mentions only the render, omits reconciliation and commit, ignores effects, and gives no insight into performance implications.

Strong answer –

  1. Scheduling – React creates an update object, pushes it onto the fiber’s queue, and asks the scheduler to perform work; updates are batched.
  2. Render phase – React calls the component function, builds a new virtual tree, and validates hook order; no side‑effects run.
  3. Reconciliation – The new tree is diffed against the previous one; keys guide matching, and three effect lists (Placement, Update, Deletion) are built.
  4. Commit phase – React first runs getSnapshotBeforeUpdate/layout effects, then mutates the DOM according to the effect lists, then runs passive effects after the paint.
  5. Why order matters – Doing heavy work in render or layout effect blocks the paint, causing jank; passive effects run asynchronously and cannot block the UI.

Senior‑level expectation – In addition to the above, discuss:

  • The role of the Fiber data structure and how it enables interruption (concurrent mode).
  • How strict mode in development double‑invokes render and layout effects, and why that matters for debugging.
  • The cost model: O(N) traversal of the fiber tree, the impact of missing keys, and the memory overhead of keeping two trees simultaneously.

Director/Engineering‑manager angle – The interviewer may ask: “How would you decide whether to optimise a component based on this model?” A good answer references measurable render times, the cost of extra DOM nodes, and the business impact (e.g., latency SLA).

Follow‑up that separates theory from practice – “Can you give an example of a bug you encountered that stemmed from misunderstanding the commit phase?” A candidate who describes an infinite render loop caused by a useEffect that set state without a guard demonstrates real‑world experience.


Your tasks

  1. Log the three phases in an existing component

    Add console.time/console.timeEnd around the component body, and useLayoutEffect/useEffect logs as shown in the “Build it” section.

    Done when you can see the render log, layout effect log, and passive effect log in the correct order for each state change.

  2. Identify a needless re‑mount

    In a list component that renders <input> fields, remove the key prop, type into an input, then trigger a state change that filters the list. Observe that the input loses its value.

    Done when you add a stable key and the input retains its value across filters.

  3. Create and fix an infinite render loop

    Add a useEffect(() => setCount(c => c + 1), [count]); to a component. Observe the “Maximum update depth exceeded” error. Then fix it by adding a guard (if (count < 5) setCount(...)).

    Done when the component updates without crashing and the console no longer shows the error.

  4. Measure the impact of moving work between phases

    Implement a 100 ms for‑loop in the render body, then move it to a useLayoutEffect, then to a useEffect. Use the Chrome Performance tab to record each version and note the CPU time and frame rate differences.

    Done when you can state the measured frame‑time for each version and explain why the passive effect version is the most performant for UI responsiveness.

  5. Optimise a list with missing keys

    Take a component that renders 500 rows, each with a button that toggles a local selected state. Remove the keys, observe that toggling one row causes many rows to re‑mount. Add stable keys and verify that only the clicked row re‑renders (use the React Profiler to confirm).

    Done when the Profiler shows a single “Update” for the clicked row and no unnecessary mounts.



Your tasks this week

Do the exercises above before the next chapter. Reading a tutorial and doing
one are different activities and only one of them changes what you can build.

Stuck on any of them? Say so — describe what you tried and what happened:
tell me where you got stuck. I read every one, and the questions
that come back more than twice get answered in the next chapter.

React, deeply

Chapter 1 of 12. New chapter every Monday night.
Next: State that scales: useState, useReducer and the point they stop working.

· The full syllabus and every chapter so far
· Subscribers also get the condensed notes for this chapter, the running
recap of everything the series has covered, and the extended guidance:
subscribe


Written by Amit Chakraborty — founding engineer and senior architect: React Native, AI and RAG systems, production architecture. Portfolio · LinkedIn · GitHub.

Need this built, reviewed or taught to your team? Get in touch or email amit@devamit.co.in. Available for senior and founding engineering roles, consulting and training, remote worldwide.

Top comments (0)