Skip to content
By Yash KapureFrontend18 min readMay 3, 2026

How React Knows What to Re-render: The Reconciliation & Diffing Algorithm

Why does typing in a React input sometimes reset the field mid-keystroke? Why does switching between two conditionally rendered components silently destroy form state? Why does React.memo fail when the component looks identical? Every one of these bugs has the same root cause: React's reconciliation engine - the algorithm that decides which components live, die, or get reused between renders.

Illustration of React reconciliation: two virtual DOM trees being diffed with changes highlighted, and the resulting minimal set of DOM mutations applied to the real DOM.
Visual reference
Source

Article focus

O(n) diffing

instead of O(n³) brute force - React's core speed trick

Key takeaways

  • React compares previous and next virtual DOM trees and applies only the minimal set of real DOM changes.
  • Two heuristics cut diffing from O(n³) to O(n): different types mean different trees; keys give list items stable identities.
  • When element types differ at the same position, React unmounts the old tree and mounts a fresh one - all state is lost.
  • Keys match list items across renders - use stable unique IDs, never array index for reorderable lists.
  • React.memo, useMemo, and useCallback skip re-renders only when prop references are stable.
  • The key prop can intentionally reset a component's state - cleaner than useEffect + manual state clearing.
  • React 18 auto-batches all state updates (including in setTimeout and Promises) - one re-render per group, not one per setter.
  • The render phase is pure and interruptible; the commit phase is synchronous and applies DOM changes.
  • startTransition marks updates as non-urgent, keeping the UI responsive during expensive re-renders.

The Problem: Why Updating the DOM Directly Is Slow

Every real DOM mutation - changing text, adding a node, toggling a class - forces the browser to recalculate layout and repaint the screen. React's entire architecture exists to minimize how many of those mutations happen.

When you call document.getElementById("title").textContent = "New Title", the browser does not just swap a string. It invalidates the style of that element, recalculates which elements are affected, reflows layout if sizes changed, repaints the affected pixels, and composites layers. For one element that's fast. For dozens of elements on every keystroke or every second, it becomes the bottleneck.

The naive solution is to re-render the entire UI from scratch on every state change - like replacing the whole HTML. Frameworks that do this pay the full DOM mutation cost every time. React's solution: describe what you want, let React figure out the cheapest path to get there. That computation is called reconciliation.

Think of it like a "spot the difference" puzzle. Two images look nearly identical - you don't redraw both from scratch, you find the five differences and fix only those. React does exactly that with UI trees: compare the last snapshot to the new one, find minimal differences, apply surgical DOM mutations.

Try It: Virtual DOM Diff

Change a property and watch React calculate the single DOM attribute that needs updating - no node creation, no subtree rebuild.

Change a property. React patches only the one attribute that changed - zero node creation.

{
  type: 'button',
  props: {
    className: 'btn-cyan',
    disabled:  false,
    children:  'Save'
  }
}
Color:

What Is React Reconciliation?

Reconciliation is the process React uses to decide what changed between renders and what DOM operations are needed. React does not preserve state because components look similar. It preserves state only when identity is stable: same component type, same tree position, same key.

Every time state or props change, React calls your component function (or class component's render). This produces a new virtual DOM tree - a plain JavaScript object description of the UI. React then compares this with the previous tree to find differences.

The result of reconciliation is a list of DOM operations: createNode, updateAttributes, removeNode, moveNode. React batches and applies these in the commit phase. Only after this phase does the real DOM - and thus the screen - change.

Reconciliation has two phases. The render phase (also called the reconciliation phase) is where React calls your components and builds the diff. The commit phase is where React applies the resulting mutations to the real DOM. Understanding this split is key to reasoning about when side effects run, why StrictMode double-invokes render functions, and how concurrent mode works.

text

          State/props change
                  │
                  ▼
┌─────────────────────────────────┐
│        RENDER PHASE             │
│  • Call component functions     │
│  • Build new virtual DOM tree   │
│  • Diff old tree vs new tree    │
│  • Produce list of changes      │
│  (pure, can be interrupted)     │
└─────────────────┬───────────────┘
                  │
                  ▼
┌─────────────────────────────────┐
│        COMMIT PHASE             │
│  • Apply DOM mutations          │
│  • Run useLayoutEffect          │
│  • Paint screen                 │
│  • Run useEffect (async)        │
│ (synchronous, never interrupted)│
└─────────────────────────────────┘

The Virtual DOM: React's Working Copy

The virtual DOM is a plain JavaScript object tree that mirrors the structure of the real DOM. React maintains two copies - the current tree (on screen) and the work-in-progress tree (being calculated) - and diffs them to compute updates.

A React element is just a JavaScript object: { type: "div", props: { className: "card", children: [...] } }. Creating elements is cheap - it's just object allocation in memory. The expensive part is reading and writing to the real DOM.

By keeping a virtual representation and batching DOM mutations, React minimizes expensive real DOM operations. This is the fundamental trade-off of the virtual DOM: pay a small CPU cost to build JavaScript objects, save a larger CPU+GPU cost from unnecessary DOM mutations.

React actually maintains a fiber tree, not just two plain trees. Each fiber node represents one unit of work (one component or DOM element) and holds pointers to parent, child, and sibling fibers. This structure allows React 18's concurrent mode to pause and resume reconciliation - something impossible with a plain recursive tree walk.

javascript

// JSX compiles to React.createElement calls
const element = <div className="card"><h1>Hello</h1></div>;

// Which compiles to:
const element = React.createElement(
  'div',
  { className: 'card' },
  React.createElement('h1', null, 'Hello')
);

// Which produces a plain JavaScript object:
{
  type: 'div',
  props: {
    className: 'card',
    children: {
      type: 'h1',
      props: { children: 'Hello' }
    }
  }
}

// React holds two fiber trees:
// current       - what is on screen right now
// workInProgress - what is being calculated
//
// After commit: workInProgress becomes current.
// The diff between them becomes the DOM mutation list.

Two Heuristics That Make Diffing O(n) Instead of O(n³)

Comparing two arbitrary trees optimally requires O(n³) time - unusable for real apps. React applies two assumptions that reduce this to O(n): elements of different types produce different trees, and keys are stable identifiers for list items.

The math: naive tree diffing compares every node in tree A against every node in tree B, then finds the optimal sequence of mutations. For a tree of 1,000 nodes that is 1,000,000,000 comparisons per render. React needs to diff on every state change, sometimes 60 times per second. That is not viable.

Heuristic 1 - Different types, different trees: if a div becomes a span (or ComponentA becomes ComponentB) at the same position, React does not attempt to match their children. It assumes the subtrees are entirely different and replaces the old one with a fresh mount. This eliminates cross-type comparison entirely.

Heuristic 2 - Keys are stable identities: when rendering lists, React uses the key prop to match items across renders instead of position. This means React can detect reordering, insertion, and deletion without comparing every pair of items. Without keys, React falls back to index-based matching - correct only when order never changes.

text

O(n³) naive approach for 1,000 nodes:
  1,000 × 1,000 × 1,000 = 1,000,000,000 comparisons/render
  At 60 renders/sec = 60,000,000,000 operations/sec
  // Completely unusable

O(n) with React's two heuristics:
  Walk both trees once, compare node-by-node at same position
  For 1,000 nodes = 1,000 comparisons/render
  At 60 renders/sec = 60,000 operations/sec
  // Fast enough for real-time interaction

The two assumptions that make this possible:
  1. Different element type at same position → full remount (skip subtree comparison)
  2. key prop on list items → stable identity (skip position-based matching)

Identity Is Everything

The single mental model shift that makes reconciliation click: React tracks identity, not appearance.

Core Insight

React does not preserve state because components look similar. It preserves state only when identity is stable: same component type, same tree position, same key.

Most developers think:

“If two components render the same output, React reuses the DOM and preserves state.”

React actually does:

Checks the element type and position. If type differs - even because a new function reference was created - full remount. All state is gone.

Diffing Rule 1: Different Element Types Cause Full Remount

Different component type at same position = total subtree destruction. React unmounts the entire old tree and mounts a fresh one - every piece of state is gone, every DOM node recreated. React does not compare their children. It assumes they are entirely different things.

This is a deliberate heuristic - comparing a div's subtree with a span's subtree would rarely produce useful matches, so React skips it entirely and rebuilds. The assumption is that different types mean fundamentally different UI.

This has a critical practical implication: if you conditionally switch between two component types at the same position, both components fully mount and unmount every time the condition changes - losing all internal state including form values, scroll position, and timers.

The most dangerous form of this bug is defining a component inside another component's render function. Every render creates a new function reference, which React treats as a completely new component type - even if it looks the same - causing full remount on every render.

javascript

// PROBLEM: switching component types destroys state
function App({ isLoggedIn }) {
  return (
    <div>
      {isLoggedIn
        ? <UserDashboard />  // switching from GuestView:
        : <GuestView />     // entire subtree unmounts and remounts
      }
    </div>
  );
  // UserDashboard loses ALL state every time isLoggedIn changes
}

// SOLUTION: same component type, different props - state is preserved
function App({ isLoggedIn }) {
  return <Dashboard type={isLoggedIn ? 'user' : 'guest'} />;
}

// ─────────────────────────────────────────────────────────────

// THE MOST COMMON TRIGGER OF THIS BUG:
// Defining components inside a render function

function Parent() {
  // BAD: new function object every render = new type every render
  const Child = () => <div>content</div>; // Different reference each render!

  return <Child />;
  // React sees: "ComponentA" → "ComponentB" (new type!) → full remount
  // Every Parent render = Child unmounts and remounts
  // Any state in Child is destroyed
}

// GOOD: define outside the rendering parent
const Child = () => <div>content</div>; // Stable reference forever

function Parent() {
  return <Child />; // Same type every render - fast path
}

// ─────────────────────────────────────────────────────────────

// WHAT ACTUALLY HAPPENS IN THE DOM:
// div → span at same position:
// Before: <div className="box">Hello</div>
// After:  <span className="box">Hello</span>
//
// React does NOT reuse the div node.
// It calls div.remove() then creates a fresh span.
// Any DOM state (focus, scroll, input value) is lost.

Try It: State Loss on Type Switch

Increment the counter, then switch component types. State resets. Switch to the same component with a different prop - state survives.

Increment the counter, then switch. Watch whether count survives.

Different component types - counter resets

<FormA />

Count: 0

{isA ? <FormA /> : <FormB />}

✓ Same component type, different prop - counter survives

<UnifiedForm variant="a" />

Count: 0

<UnifiedForm variant={variant} />

Diffing Rule 2: Same Type Updates Attributes In-Place

If the element type stays the same across renders, React keeps the DOM node and only updates the changed attributes, event handlers, and children. This is the fast path.

When React finds the same element type at the same position, it compares props and updates only what changed. If className goes from "card" to "card active", React calls setAttribute("class", "card active") - no node creation, no unmounting.

For React components, same type means the component function is called with new props. React merges state updates and re-renders the component, continuing the diff recursively through the subtree.

This is why element type stability is such a powerful performance tool. Keeping your component types stable at the same tree positions means React almost never destroys and recreates DOM nodes - it just patches attributes.

javascript

// Before render:
<button className="btn" disabled={false} onClick={handleA}>Save</button>

// After render:
<button className="btn active" disabled={true} onClick={handleB}>Saving...</button>

// React's minimal DOM operations (no node creation!):
// element.setAttribute('class', 'btn active')
// element.setAttribute('disabled', '')
// element.removeEventListener('click', handleA)
// element.addEventListener('click', handleB)
// element.textContent = 'Saving...'

// ─────────────────────────────────────────────────────────────

// Component update - same type, new props
// Before:
<UserCard name="Alice" role="admin" />

// After:
<UserCard name="Alice" role="editor" />

// React calls UserCard({ name: 'Alice', role: 'editor' })
// UserCard re-renders, React diffs its output recursively
// Only the changed DOM nodes inside UserCard get patched

// ─────────────────────────────────────────────────────────────

// Style object diffing - React only sets changed style properties
// Before: style={{ color: 'red', fontSize: '14px' }}
// After:  style={{ color: 'blue', fontSize: '14px' }}
// React: element.style.color = 'blue'  (only color, not fontSize)

Keys: How React Matches List Items

Keys are not just warning suppressors. They are identity markers. React uses them to match list items across renders. Without stable keys, React matches by position - and when order changes, it reuses the wrong DOM nodes and corrupts state silently.

When rendering a list, React needs to match each item in the previous render to each item in the new render. Without keys, it matches by index. If you insert an item at the beginning, every subsequent item's index shifts - React unnecessarily patches all of them, even though the items themselves didn't change.

With unique, stable keys, React knows exactly which items were added, removed, or moved. It reorders existing DOM nodes instead of patching content, which is both faster and preserves internal state (input values, scroll position, animation state).

Think of the key prop as a name tag for each list item. "I am item #42. If you see me in the next render, reuse my DOM node." Without a name tag, React just goes by seat position - if you shuffle seats, everyone looks like a different person.

The key prop is read by React during reconciliation and is never passed to the component as a prop. Keys must be unique among siblings - not globally unique across the whole app.

javascript

// WITHOUT keys - React matches by index (position)
// Before: ['Alice', 'Bob', 'Carol']
// After:  ['Zara', 'Alice', 'Bob', 'Carol']  (inserted Zara at start)
//
// React compares by position:
//   position 0: 'Alice' → 'Zara'  (update text)
//   position 1: 'Bob'   → 'Alice' (update text)
//   position 2: 'Carol' → 'Bob'   (update text)
//   position 3: -       → 'Carol' (create new node)
// Result: 3 unnecessary updates + 1 create

// WITH keys - React matches by identity
// Before: [Alice(key=1), Bob(key=2), Carol(key=3)]
// After:  [Zara(key=4), Alice(key=1), Bob(key=2), Carol(key=3)]
//
// React sees: key=4 is new → create; key=1,2,3 moved → reorder
// Result: 1 create + 3 DOM moves (much cheaper)

// ─────────────────────────────────────────────────────────────

// CORRECT key usage
function TodoList({ todos }) {
  return (
    <ul>
      {todos.map(todo => (
        <TodoItem
          key={todo.id}   // stable unique ID from your data source
          todo={todo}
        />
      ))}
    </ul>
  );
}

// ─────────────────────────────────────────────────────────────

// WRONG: index as key - breaks when list order changes
function BadList({ items }) {
  return items.map((item, index) => (
    <Item key={index} item={item} />
    // When items reorder, indexes stay in place
    // React reuses the wrong DOM nodes
  ));
}

// ─────────────────────────────────────────────────────────────

// THE STATE BUG: why index keys cause real problems
function InputList({ items }) {
  return items.map((item, i) => (
    // Input's DOM value (what user typed) stays at position i
    // But item.label (React prop) changes to new item at position i
    // Result: wrong label shown with wrong user input
    <input key={i} defaultValue={item.label} />
  ));
}

// FIX: stable ID ensures DOM node travels with its data
function InputList({ items }) {
  return items.map(item => (
    <input key={item.id} defaultValue={item.label} />
  ));
}

Try It: Key vs Index

Toggle between stable keys and index keys, then prepend an item. Count the DOM operations React needs - the difference is dramatic.

Toggle keys, then prepend “Zara” to see how many DOM operations React needs.

key=1Alice
key=2Bob
key=3Carol

The key Prop as a State Reset Button

Changing a key intentionally forces React to unmount the old component and mount a fresh one, resetting all internal state. This is the cleanest way to reset a component when a controlling value changes.

Most of the time, an unmount is something to avoid. But sometimes you want it. Classic example: a user profile form that should reset when the viewed user changes. Without the key trick, you need a useEffect that watches for the user changing and manually resets each piece of state - fragile and easy to forget.

With the key trick, you tie the component's lifecycle to the value you care about. When userId changes, the key changes, React sees a "new" component instance, unmounts the old form (discarding all state), and mounts a fresh one initialized with the new user's data.

This pattern is particularly useful for forms, animations, and any component that initializes from props but manages its own internal state. It eliminates an entire class of "stale state" bugs.

javascript

// PROBLEM: form keeps stale state when user changes
function UserProfile({ userId }) {
  // This form initializes from userId but manages its own state
  const [name, setName] = useState('');
  const [bio, setBio] = useState('');

  // BAD: useEffect approach - need to reset every piece of state
  useEffect(() => {
    setName('');  // easy to forget one of these
    setBio('');   // and bugs are subtle
  }, [userId]);

  // ...
}

// ─────────────────────────────────────────────────────────────

// SOLUTION: key prop as reset button
function App({ userId }) {
  return (
    // When userId changes → key changes → React unmounts old form
    // → mounts fresh UserProfile with clean state
    <UserProfile key={userId} userId={userId} />
  );
}

function UserProfile({ userId }) {
  const [name, setName] = useState('');
  const [bio, setBio] = useState('');
  // No useEffect needed - component always starts fresh for each userId
}

// ─────────────────────────────────────────────────────────────

// OTHER USE CASES for intentional key-based reset

// Reset a wizard/multi-step form
<MultiStepForm key={formVersion} />

// Reset an animation when data changes
<AnimatedCounter key={metricId} value={count} />

// Force a component to re-fetch on demand
const [refreshKey, setRefreshKey] = useState(0);
<DataFetcher key={refreshKey} url={url} />
<button onClick={() => setRefreshKey(k => k + 1)}>Refresh</button>

Render Phase vs Commit Phase

Render phase work can be thrown away. The render phase is pure and interruptible - React may discard it and restart with newer state. The commit phase applies DOM mutations once, synchronously, guaranteed. Side effects in render are bugs because they run before React knows if the work will survive.

During the render phase, React calls your component functions (or class render methods) to produce React elements. It walks the fiber tree, compares old and new nodes, and builds a list of changes. Critically, this phase produces no side effects on the real DOM. In concurrent mode, React can pause mid-way through the render phase, process higher-priority work, then resume.

During the commit phase, React applies all accumulated changes to the real DOM. This phase has three sub-phases: before mutation (snapshot of DOM before changes), mutation (actual DOM insertions/updates/deletions), and layout (useLayoutEffect runs synchronously after DOM update but before paint). The commit phase is always synchronous - React never interrupts it.

After the commit phase, the browser paints. Then React schedules useEffect to run asynchronously. This is why useEffect is the right place for most side effects: the DOM is already updated when it runs, and it doesn't block painting.

This split explains React StrictMode's double-invocation behavior: in development, React intentionally calls your render phase functions twice to expose accidental side effects. If your component function has a side effect (like a console.log that counts renders, an API call, or a mutation), it will run twice in dev but once in production.

javascript

// Understanding render vs commit timing

function Component() {
  // RENDER PHASE - called during diffing, can run multiple times in dev
  const value = expensiveCalculation(); // pure, no side effects

  // BAD: Side effect in render - will run twice in StrictMode
  fetch('/api/data').then(setData); // Don't do this in render!
  localStorage.setItem('key', value); // Or this

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

// ─────────────────────────────────────────────────────────────

// COMMIT PHASE TIMING ORDER:
// 1. useLayoutEffect cleanup (from previous render)
// 2. DOM mutations applied
// 3. useLayoutEffect fires (synchronous, DOM is ready, before paint)
// 4. Browser paints
// 5. useEffect cleanup (from previous render)
// 6. useEffect fires (asynchronous, after paint)

function Example() {
  useLayoutEffect(() => {
    // Runs synchronously after DOM update, before paint
    // Use for: reading DOM measurements, syncing with third-party DOM libs
    const height = ref.current.getBoundingClientRect().height;
    // Set something that would cause visual flicker if done in useEffect
  });

  useEffect(() => {
    // Runs asynchronously after paint
    // Use for: data fetching, subscriptions, analytics, timers
    const id = setInterval(tick, 1000);
    return () => clearInterval(id); // cleanup on unmount or re-run
  });
}

// ─────────────────────────────────────────────────────────────

// WHY STRICTMODE DOUBLE-INVOKES
// In development, React renders each component twice to detect impure renders.
// If your component has the same output both times = pure = safe.
// If output differs = impure = bug waiting to happen in concurrent mode.

// This component is pure (double-invoke safe):
function PureCounter({ count }) {
  return <div>{count * 2}</div>; // Same output every time for same input
}

// This component is impure (double-invoke reveals bug):
let callCount = 0;
function ImpureComponent() {
  callCount++; // mutates external variable during render!
  return <div>Called {callCount} times</div>; // Output differs between calls
}

Render vs Commit: The Key Distinction

Render phase work can be thrown away. Commit phase work cannot. This is the most important thing to understand about React's execution model.

Core Insight

Render phase work can be thrown away. React may discard it and restart with newer state. This is why a side effect in render is not just bad style - it is a bug that will fire at the wrong time.

Render Phase

  • Pure - no side effects
  • Can be interrupted
  • Can be restarted
  • May run multiple times

Commit Phase

  • Applies DOM mutations
  • Always synchronous
  • Never interrupted
  • Runs exactly once

What Triggers a Re-render?

A component re-renders when: its own state changes (useState/useReducer), its props change because a parent re-rendered, it subscribes to a context that changed, or the parent re-renders (even if props are identical, unless the child is memoized).

The most surprising trigger is parent re-render. By default, when a parent re-renders, all its children re-render too - even if their props haven't changed. React calls every child component function and diffs its output. For most components this is fast, but for genuinely expensive renders it's worth optimizing.

Context is another subtle trigger. Any component that calls useContext(MyContext) re-renders whenever MyContext's value changes - even if the specific part of the context it uses didn't change. This is why splitting large contexts into smaller, focused ones matters: a ThemeContext change shouldn't re-render components that only care about UserContext.

The mental model: React pessimistically re-renders everything below a state change by default. This is safe - React checks whether the output actually changed before committing DOM mutations. Optimization means preventing wasted render function calls, not preventing DOM updates (React already handles that part).

javascript

// TRIGGER 1: Own state change
function Counter() {
  const [count, setCount] = useState(0);
  // Clicking button triggers re-render of Counter and all its children
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

// ─────────────────────────────────────────────────────────────

// TRIGGER 2: Parent re-render (even with same props)
function Parent() {
  const [tick, setTick] = useState(0);
  return (
    <div>
      <button onClick={() => setTick(t => t + 1)}>tick</button>
      <Child message="hello" /> {/* Re-renders on every tick - props didn't change! */}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────

// TRIGGER 3: Context change
const ThemeContext = createContext('light');

function ThemedButton() {
  const theme = useContext(ThemeContext); // re-renders when ThemeContext value changes
  return <button className={theme}>Click</button>;
}

// PROBLEM: Large context = many unnecessary re-renders
const AppContext = createContext({ user: null, theme: 'light', locale: 'en' });

function LanguagePicker() {
  const { locale } = useContext(AppContext); // re-renders on user OR theme change too!
}

// SOLUTION: Split context by update frequency
const UserContext = createContext(null);
const ThemeContext2 = createContext('light');
const LocaleContext = createContext('en');

function LanguagePicker2() {
  const locale = useContext(LocaleContext); // only re-renders when locale changes
}

// ─────────────────────────────────────────────────────────────

// TRIGGER 4: useReducer dispatch
function Form() {
  const [state, dispatch] = useReducer(formReducer, initialState);
  // dispatch({ type: 'SET_NAME', payload: 'Alice' }) triggers re-render
}

Try It: Re-render Cascade

Click "Re-render parent" and watch which children actually re-render. The amber child uses React.memo but still re-renders - its object prop breaks memoization.

Click “Re-render parent” repeatedly. Watch the render count (×) on each child. The amber child uses React.memo but still re-renders - its config object is a new reference every render.

Parent (count: 0)

No memo

Always re-renders when parent does

1×

✓ React.memo - stable props

Skips re-render - string prop is stable

1×

React.memo - new object prop each render

config={{ theme: 'dark' }} - new object ref every render

1×

Fix the amber child: lift config out of render with useMemo(() => ({theme: 'dark'}), []). Then React.memo can compare by reference and skip the re-render.

React 18: Automatic Batching

In React 18, all state updates - including those inside setTimeout, Promises, and native event handlers - are automatically batched into a single re-render. Previous versions only batched updates inside React event handlers.

Batching means: multiple setState calls within the same synchronous block are grouped and cause only one re-render. React 17 and earlier did this automatically inside React-managed event handlers (like onClick) but not in async contexts.

React 18 extends automatic batching everywhere. A chain of state setters inside a setTimeout or a fetch callback now produces one re-render, not one per setter. This is a free performance improvement - no code changes needed.

If you have code that depends on reading DOM state between state updates (unusual, but valid), you can opt out of batching using flushSync. This forces React to flush the current update synchronously before moving to the next line.

javascript

// ─── REACT 17 BEHAVIOR ───────────────────────────────────────

// Inside React event handler - batched (1 re-render)
function handleClick() {
  setCount(c => c + 1); // batched
  setFlag(f => !f);     // batched
  // → 1 re-render total
}

// Inside setTimeout - NOT batched in React 17
setTimeout(() => {
  setCount(c => c + 1); // re-render 1
  setFlag(f => !f);     // re-render 2
  // → 2 re-renders (bad)
}, 1000);

// Inside Promise - NOT batched in React 17
fetch('/api').then(() => {
  setData(result); // re-render 1
  setLoading(false); // re-render 2
  // → 2 re-renders (bad)
});

// ─── REACT 18 BEHAVIOR ───────────────────────────────────────

// All of the above now batch automatically - 1 re-render each
setTimeout(() => {
  setCount(c => c + 1); // batched
  setFlag(f => !f);     // batched
  // → 1 re-render total
}, 1000);

fetch('/api').then(() => {
  setData(result);   // batched
  setLoading(false); // batched
  // → 1 re-render total
});

// ─── OPT OUT with flushSync ───────────────────────────────────
import { flushSync } from 'react-dom';

function handleChange() {
  flushSync(() => {
    setCount(c => c + 1); // flush immediately - forces synchronous re-render
  });
  // DOM is updated here before the next line runs
  const height = listRef.current.scrollHeight; // read fresh DOM value
  flushSync(() => {
    setScrollTarget(height); // another synchronous re-render
  });
}

Avoiding Wasted Renders: React.memo, useMemo, useCallback

React.memo is reference equality, not magic. It compares props with ===. A new object literal or inline function is always a new reference - memo sees different props, re-renders, and never tells you why. A new function or object reference is enough to break memoization silently.

The golden rule: do not memoize everything by default. Memoization has a cost - React must compare dependencies every render to decide whether to skip. For fast-rendering components, this overhead can exceed the savings. Profile first, then optimize.

The most common mistake when using React.memo: passing object or function props that are re-created every render. React.memo compares props by reference (===). A new object literal or inline arrow function is always a new reference - it breaks memoization silently.

The correct order: wrap the child in React.memo first, then stabilize object props with useMemo and function props with useCallback in the parent. All three must be in place for memoization to actually skip the render.

javascript

// useCallback - stable function reference for memoized children
function Parent({ userId }) {
  const [filter, setFilter] = useState('');

  // Without useCallback: new function reference every render
  // → MemoizedList always re-renders (React.memo is useless)
  const handleSelect = useCallback((itemId) => {
    console.log('Selected:', userId, itemId);
  }, [userId]); // recreate only when userId changes

  const filteredItems = useMemo(
    () => items.filter(i => i.name.includes(filter)),
    [filter] // recompute only when filter changes
  );

  return (
    <div>
      <input value={filter} onChange={e => setFilter(e.target.value)} />
      <MemoizedList items={filteredItems} onSelect={handleSelect} />
    </div>
  );
}

// ─────────────────────────────────────────────────────────────

// React.memo - skip re-render if props are referentially equal
const MemoizedList = React.memo(function MemoizedList({ items, onSelect }) {
  return <ul>{items.map(item => (
    <li key={item.id} onClick={() => onSelect(item.id)}>{item.name}</li>
  ))}</ul>;
});

// ─────────────────────────────────────────────────────────────

// BROKEN: new object prop defeats React.memo
function Parent2() {
  const [count, setCount] = useState(0);
  // This object is a new reference every render
  const config = { theme: 'dark', size: 'lg' }; // new ref every render

  return <MemoizedChild config={config} />; // memo never skips
}

// FIXED: stabilize the reference
function Parent3() {
  const [count, setCount] = useState(0);
  const config = useMemo(() => ({ theme: 'dark', size: 'lg' }), []); // stable ref

  return <MemoizedChild config={config} />; // memo works
}

// ─────────────────────────────────────────────────────────────

// React.memo with custom comparison function (use sparingly)
const DataGrid = React.memo(
  function DataGrid({ rows, columns }) {
    return <table>{/* expensive table render */}</table>;
  },
  (prevProps, nextProps) => {
    // Return true = skip re-render (props "equal")
    // Return false = re-render (props "different")
    return (
      prevProps.rows.length === nextProps.rows.length &&
      prevProps.columns.join() === nextProps.columns.join()
    );
  }
);

The Memoization Trap

React.memo is reference equality. One inline object or function breaks the whole memoization chain - silently.

Common Misconception

React.memo is reference equality, not magic. A new function or object reference is enough to break memoization silently - no error, no warning, just an unexpected re-render.

Breaks memo silently

<MemoChild config={{ theme: "dark" }} />

New object reference every render → memo always sees changed props

Stable reference

const config = useMemo(() => ({ theme: "dark" }), [])

Same reference across renders → memo skips re-render correctly

The full chain requires all three: React.memo + useMemo + useCallback. Remove any one link and memo silently fails.

Concurrent Rendering and startTransition

Concurrent rendering does not make rendering faster. It makes rendering interruptible. startTransition marks updates as non-urgent - React can pause them mid-render when something more urgent arrives. The render happens eventually; concurrent mode just controls when.

Before concurrent mode, React's render phase was synchronous and blocking. Start a big render (1,000 list items) and React had to finish it before handling the next user event. In concurrent mode, React can pause mid-render, process the new urgent event, then resume or discard the paused render.

startTransition is the API to opt in. Updates inside startTransition are marked as transitions - non-urgent. React still processes them, but it can be interrupted by urgent updates (anything outside startTransition). The isPending flag lets you show a loading indicator while the transition is in progress.

useDeferredValue is a related tool: instead of wrapping a setter, you defer the value itself. The component renders twice - once immediately with the old value, once asynchronously with the new value. React keeps the old rendered output on screen until the deferred render is ready.

The key insight: these APIs do not make rendering faster. They prioritize, allowing fast-path user interactions to always feel responsive, even if background work is heavy. Think of it as giving the browser a chance to breathe between chunks of render work.

javascript

import { useState, useTransition, useDeferredValue } from 'react';

// ─── useTransition ────────────────────────────────────────────

function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  function handleSearch(e) {
    // URGENT: update input immediately (user sees their typing)
    setQuery(e.target.value);

    // NON-URGENT: computing results can be interrupted
    startTransition(() => {
      setResults(expensiveFilter(e.target.value));
      // If user types again before this finishes,
      // React discards this render and starts fresh
    });
  }

  return (
    <div>
      <input value={query} onChange={handleSearch} />
      {isPending
        ? <Spinner />        // show while transition is in progress
        : <ResultsList results={results} />
      }
    </div>
  );
}

// ─── useDeferredValue ─────────────────────────────────────────

function SearchPage2() {
  const [query, setQuery] = useState('');
  // deferredQuery lags behind query during fast typing
  // React shows stale results while computing fresh ones
  const deferredQuery = useDeferredValue(query);

  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      {/* This renders with deferredQuery - won't block typing */}
      <ExpensiveResultsList query={deferredQuery} />
    </div>
  );
}

// ─── When to use which ───────────────────────────────────────

// useTransition: you own the state setter
//   → wrap the setter call in startTransition

// useDeferredValue: you don't own the setter (prop from parent, context)
//   → wrap the value itself with useDeferredValue

// Neither speeds up rendering.
// Both keep urgent interactions (typing, clicking) responsive
// by delaying non-urgent renders until the browser is idle.

Using the React DevTools Profiler

The React DevTools Profiler records which components re-rendered during an interaction, how long each took, and why they re-rendered - the specific prop, state, or hook that changed.

Open React DevTools Profiler tab click Record interact with your app click Stop. The flame chart shows every component render as a colored bar. Gray means the component was bailed out (did not re-render). Colored means it rendered - width represents render duration.

Click any colored bar to see the "Why did this render?" panel in the sidebar. This shows the specific prop, state value, or hook index that changed. This is the most efficient way to pinpoint unnecessary re-renders - far faster than adding console.logs.

Enable "Highlight updates when components render" in DevTools settings (the gear icon) to see colored flash overlays on your actual UI as components re-render. This visual mode is the fastest way to spot cascading re-renders caused by missing memoization or unstable references.

Always profile in production build. The development build has extra checks and intentional double-renders (StrictMode) that inflate timing. Run npm run build && npx serve out and open the production site to get realistic numbers.

Real Bugs That Reconciliation Explains

Developers search for symptoms, not theory. These are the bugs that send people to Stack Overflow - each one is directly caused by a specific reconciliation rule.

Understanding reconciliation explains a class of bugs that otherwise seem random or impossible. The input-losing-focus bug, the form-resetting bug, the list-wrong-state bug - they all reduce to the same two reconciliation rules: type identity and key identity.

  • Input loses focus or clears while typing: a component is defined inside a render function. Every keystroke re-renders the parent, creates a new function reference, React sees a new component type at the same position, unmounts the old input (losing focus and value), mounts a fresh one. Fix: move the component definition outside the parent.
  • Form resets when switching between two views: the condition renders different component types at the same position (isAdmin ? <AdminForm /> : <UserForm />). React unmounts and remounts on every toggle. Fix: use a single component with a variant prop, or accept the reset and use the key prop intentionally.
  • List items show wrong text or input values after reorder or filter: the list uses key={index}. When order changes, React matches items by position and reuses DOM nodes with the wrong content. Internal state (input values, checkboxes, animations) stays at the old position while props update to new values. Fix: key={item.id} - a stable ID from your data.
  • React.memo has no effect - component re-renders on every parent render: an object, array, or function is created inline as a prop. Each render creates a new reference, so memo always sees changed props. Fix: stabilize with useMemo (for objects/arrays) or useCallback (for functions) in the parent.
  • Animation restarts or component flashes unexpectedly: the animated component is receiving a new key, or its parent is conditionally switching the component type. Both cause unmount + fresh mount, which resets animation state. Fix: ensure the component type and key are stable across renders.
  • useEffect cleanup fires more often than expected: the effect dependency array contains an object or function created during render. A new reference each render makes React think the dependency changed. Fix: stabilize the reference with useMemo/useCallback, or store only the primitive values you actually need in the dep array.

Common Mistakes and How to Fix Them

Most React performance and correctness bugs come from eight recurring mistakes. Recognizing the pattern lets you fix the symptom before it becomes a bug report.

  • Component defined inside render: new function reference every render → React treats it as new type → full remount every render. Fix: move component definition outside the parent function.
  • Index as key in reorderable list: when list items shuffle, React reuses wrong DOM nodes → state bugs, animation bugs, focus bugs. Fix: use stable unique IDs from your data.
  • Object/array prop not stabilized before React.memo: inline {} or [] literals create new references every render → memo never skips. Fix: useMemo for objects, store primitives in state instead when possible.
  • Function prop not stabilized before React.memo: inline arrow functions create new references every render. Fix: useCallback with correct deps.
  • Everything in one Context: any context value change re-renders all consumers. Fix: split by update frequency (UserContext, ThemeContext, LocaleContext separately).
  • Benchmarking in development: StrictMode double-renders + extra DevTools overhead inflate timing. Fix: always profile in production build.
  • useEffect for state reset: manually resetting multiple state values on a prop change is fragile. Fix: use key prop to force remount instead.
  • Memoizing cheap components: useMemo/useCallback/React.memo have overhead from dep comparison. Fix: only memoize components that are measurably slow via Profiler - not everything by default.

Test Your Knowledge

1.What does React do when <div> at position 0 becomes <span> at position 0?

// Before:
<div className="card">
  <Counter />  // count = 5
</div>

// After:
<span className="card">
  <Counter />  // what happens to count?
</span>

2.You increment the counter inside <FormA />, then switch to <FormB />. What is the count in FormB?

// FormA and FormB both have internal useState(0) for count
function App({ show }) {
  return show ? <FormA /> : <FormB />;
}

3.A list of 1000 items can be reordered by the user. Which key strategy is correct?

4.Why does React.memo fail to skip the re-render here?

function Parent() {
  const [count, setCount] = useState(0);
  const config = { theme: 'dark' }; // inline object

  return <MemoChild config={config} />;
}

5.A form should fully reset when userId changes. What is the cleanest approach?

6.What changed in React 18 regarding state updates inside setTimeout?

setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  setLabel('done');
}, 500);
Answer all 6 questions to submit
$ git checkout --your-opinion

Your turn

  • >Did this help you ship something?
  • >Which part clicked the most for you?
  • >Applying this at work? Share your experience.

Discussion

Leave a comment

Thoughts, questions, corrections - all welcome.

Sign in with GitHub to comment - free & takes 10 seconds

Recommended blogs

Continue reading

View all blogs
Abstract visualization of asynchronous JavaScript - glowing connected nodes representing Promise chains and async flow
Frontend
22 min readMay 1, 2026

Mastering Async JavaScript: Promises, Async/Await, and the Microtask Queue

From callback hell to Promise chains to async/await, this guide covers everything about asynchronous JavaScript including error handling, Promise combinators, AbortController, and real-world patterns.

Pexels - Free Stock Photo

Read article
Developer coding with AI assistance on a dark terminal screen
AI Tools
13 min readApril 28, 2026

How I Use Claude Code to Ship Features 10× Faster

A real-world workflow guide for using Claude Code CLI to build features, debug bugs, refactor legacy code, and run custom automations - with actual examples from building this portfolio.

Subscribe for new posts, or read how referrals and sponsored placements are handled on this site.