Episode 2 — React Frontend Architecture NextJS / 2.3 — State and Re rendering Logic

2.3.c — How React Re-renders

In one sentence: Understanding React's re-rendering process — what triggers it, how reconciliation works, and why components re-render when they do — is the key to building performant applications and debugging unexpected behavior.

Navigation: ← 2.3.b — useState Hook · Next → 2.3.d — Batching State Updates


Table of Contents

  1. What Is a Re-render?
  2. The Render-Commit Cycle
  3. What Triggers a Re-render
  4. The Component Tree and Cascading Renders
  5. Virtual DOM and Reconciliation
  6. The Diffing Algorithm in Detail
  7. When React Skips a Re-render
  8. React.memo — Preventing Child Re-renders
  9. Reference Equality and Why It Matters
  10. Common Re-render Patterns and Anti-Patterns
  11. Debugging Re-renders with React DevTools
  12. The Mental Model: State as a Snapshot
  13. Fiber Architecture — How React Schedules Work
  14. Concurrent Features and Rendering
  15. Key Takeaways
  16. Explain-It Challenge

1. What Is a Re-render?

A re-render is when React calls your component function again to get a new description of what the UI should look like. It's NOT the same as a DOM update.

┌──────────────────────────────────────────────────────────┐
│            RE-RENDER vs DOM UPDATE                        │
│                                                          │
│  Re-render (cheap):                                      │
│  ┌──────────────┐                                        │
│  │ React calls   │  → Your function runs                 │
│  │ YourComponent │  → Returns new JSX                    │
│  │ ()            │  → Creates new Virtual DOM tree       │
│  └──────────────┘                                        │
│         ↓                                                │
│  Reconciliation (comparison):                            │
│  ┌──────────────┐                                        │
│  │ Diff old      │  → Finds minimum changes              │
│  │ vs new        │  → Calculates what actually changed   │
│  │ Virtual DOM   │                                       │
│  └──────────────┘                                        │
│         ↓                                                │
│  DOM Update (expensive — only if needed):                │
│  ┌──────────────┐                                        │
│  │ Apply only    │  → Only changed nodes touched          │
│  │ the diff to   │  → Browser repaints only affected area│
│  │ real DOM      │                                       │
│  └──────────────┘                                        │
│                                                          │
│  Key insight: Many re-renders produce ZERO DOM updates!  │
└──────────────────────────────────────────────────────────┘

Why This Distinction Matters

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

  console.log("Component rendered");  // Fires on EVERY re-render

  return (
    <div>
      <h1>{count}</h1>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <p>This text never changes</p>  {/* Still in the re-render, but no DOM update */}
    </div>
  );
}

// Click "+1":
// 1. React calls Counter() again (re-render)
// 2. Returns new JSX with count=1
// 3. Diffs: only <h1> text changed from "0" to "1"
// 4. Updates ONE text node in the DOM
// 5. <p> and <button> are untouched in the DOM

2. The Render-Commit Cycle

React processes updates in two distinct phases:

┌─────────────────────────────────────────────────────────────┐
│              RENDER PHASE → COMMIT PHASE                     │
│                                                             │
│  ┌─────────────────────────────┐                            │
│  │        RENDER PHASE          │  ← Pure, no side effects  │
│  │                             │                            │
│  │  1. State/props change       │                            │
│  │  2. React calls component()  │                            │
│  │  3. Component returns JSX    │                            │
│  │  4. React builds new VDOM    │                            │
│  │  5. React diffs old vs new   │                            │
│  │                             │                            │
│  │  ⚡ Can be interrupted!      │                            │
│  │  ⚡ Can be discarded!        │                            │
│  │  ⚡ No DOM mutations!        │                            │
│  └──────────────┬──────────────┘                            │
│                 │                                           │
│                 ▼                                           │
│  ┌─────────────────────────────┐                            │
│  │        COMMIT PHASE          │  ← Mutations happen here  │
│  │                             │                            │
│  │  6. Apply DOM changes        │                            │
│  │  7. Run useLayoutEffect      │                            │
│  │  8. Browser paints screen    │                            │
│  │  9. Run useEffect            │                            │
│  │                             │                            │
│  │  ⚠️ Synchronous              │                            │
│  │  ⚠️ Cannot be interrupted    │                            │
│  └─────────────────────────────┘                            │
└─────────────────────────────────────────────────────────────┘

Phase Rules

PhaseCan DoCannot Do
RenderReturn JSX, compute values, read state/propsMutate DOM, fire API calls, set timers
CommitUpdate DOM, run effects, measure layoutSkip or interrupt

This is why components must be "pure" during rendering — no side effects. Side effects belong in useEffect.


3. What Triggers a Re-render

There are exactly three things that trigger a re-render:

Trigger 1: State Change

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

  // Clicking this button triggers a re-render
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

Trigger 2: Parent Re-renders

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

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Click</button>
      {/* Child re-renders when Parent re-renders, even if its props haven't changed! */}
      <Child name="Alice" />
    </div>
  );
}

function Child({ name }) {
  console.log("Child rendered");  // Fires every time Parent re-renders!
  return <p>Hello, {name}</p>;
}

This is the #1 source of unnecessary re-renders. The child's props (name="Alice") never change, but it re-renders because its parent re-rendered.

Trigger 3: Context Change

const ThemeContext = createContext("light");

function App() {
  const [theme, setTheme] = useState("light");
  return (
    <ThemeContext.Provider value={theme}>
      <Header />      {/* Only re-renders if it consumes ThemeContext */}
      <Content />
    </ThemeContext.Provider>
  );
}

function DeepChild() {
  const theme = useContext(ThemeContext);
  // Re-renders whenever theme value changes, even if parent doesn't re-render
  return <p>Theme: {theme}</p>;
}

What Does NOT Trigger Re-renders

ActionTriggers Re-render?
setState(sameValue)No — React bails out (shallow comparison)
Mutating a variable (not state)No — React doesn't track it
Changing a ref (ref.current = x)No — refs don't trigger renders
props staying the same but parent re-rendersYes! (unless React.memo)
Creating a new object/array that's equal in valueYes (different reference)

4. The Component Tree and Cascading Renders

When a component re-renders, all of its descendants re-render too, by default.

┌─────────────────────────────────────────────────────────────┐
│              CASCADE RE-RENDER                               │
│                                                             │
│  State changes in App:                                      │
│                                                             │
│         ┌──────┐                                            │
│         │ App  │ ← setState() called here                   │
│         │  🔄  │ ← Re-renders                               │
│         └──┬───┘                                            │
│       ┌────┴──────┐                                         │
│       ▼           ▼                                         │
│   ┌──────┐   ┌────────┐                                    │
│   │Header│   │ Main   │                                    │
│   │  🔄  │   │   🔄   │  ← Both re-render (children of App)│
│   └──────┘   └───┬────┘                                    │
│              ┌───┴────┐                                     │
│              ▼        ▼                                     │
│          ┌──────┐ ┌──────┐                                  │
│          │ List │ │Sidebar│                                 │
│          │  🔄  │ │  🔄   │  ← All descendants re-render   │
│          └──┬───┘ └───────┘                                 │
│         ┌───┴────┐                                          │
│         ▼   ▼    ▼                                          │
│        🔄  🔄   🔄   ← Even deeply nested items            │
│                                                             │
│  EVERY component below the state change re-renders!        │
│  (Unless stopped by React.memo or useMemo)                 │
└─────────────────────────────────────────────────────────────┘

Why This Is Usually Fine

React re-renders are cheap. Calling a function and diffing virtual DOM nodes is fast — microseconds per component. The expensive part is DOM mutations, and React minimizes those.

Rules of thumb:

  • Don't optimize prematurely — measure first with Profiler
  • 100-200 components re-rendering is usually imperceptible (<16ms)
  • Optimize only when you see jank, slow interactions, or >16ms render times

5. Virtual DOM and Reconciliation

The Virtual DOM is a lightweight JavaScript representation of the real DOM. React uses it to minimize actual DOM operations.

How It Works

// Your component returns JSX
function App() {
  return (
    <div className="app">
      <h1>Hello</h1>
      <p>Count: {count}</p>
    </div>
  );
}

// React internally creates a tree of objects (Virtual DOM):
{
  type: 'div',
  props: { className: 'app' },
  children: [
    { type: 'h1', props: {}, children: ['Hello'] },
    { type: 'p', props: {}, children: ['Count: ', 5] }
  ]
}

// On re-render with count=6:
{
  type: 'div',
  props: { className: 'app' },
  children: [
    { type: 'h1', props: {}, children: ['Hello'] },        // Same
    { type: 'p', props: {}, children: ['Count: ', 6] }      // Changed!
  ]
}

// React diffs these two trees and finds:
// Only the text node "5" → "6" changed
// → Updates ONE text node in the real DOM

The Diffing Process

┌─────────────────────────────────────────────────────────┐
│              RECONCILIATION RULES                        │
│                                                         │
│  Rule 1: Different element types → destroy and rebuild  │
│  ────────────────────────────────────────────────────── │
│  Old: <div><Counter /></div>                            │
│  New: <span><Counter /></span>                          │
│  → Destroy <div>, destroy Counter (loses state!)        │
│  → Create <span>, create new Counter (fresh state)      │
│                                                         │
│  Rule 2: Same element type → update attributes only     │
│  ────────────────────────────────────────────────────── │
│  Old: <div className="old" style={{color: 'red'}} />    │
│  New: <div className="new" style={{color: 'blue'}} />   │
│  → Update className and style on existing DOM node      │
│  → Component keeps its state!                           │
│                                                         │
│  Rule 3: Same component type → re-render with new props │
│  ────────────────────────────────────────────────────── │
│  Old: <Counter count={5} />                             │
│  New: <Counter count={6} />                             │
│  → Same Counter instance, update props, re-render       │
│  → State is preserved!                                  │
│                                                         │
│  Rule 4: Lists use keys for identity matching           │
│  ────────────────────────────────────────────────────── │
│  Old: [key=1, key=2, key=3]                             │
│  New: [key=2, key=3]                                    │
│  → Remove key=1, keep key=2 and key=3                   │
└─────────────────────────────────────────────────────────┘

6. The Diffing Algorithm in Detail

Element Type Comparison

// CASE 1: Same HTML element type — update attributes
// Old:
<div className="before" title="stuff" />
// New:
<div className="after" title="stuff" />
// React: update className from "before" to "after", leave title

// CASE 2: Different HTML element types — tear down and rebuild
// Old:
<div><Counter /></div>
// New:
<article><Counter /></article>
// React: unmount old <div> and Counter, mount new <article> and Counter
// Counter LOSES all state!

// CASE 3: Same component type — update props
// Old:
<UserCard name="Alice" role="admin" />
// New:
<UserCard name="Alice" role="user" />
// React: same UserCard instance, call with new props, state preserved

Children Comparison

// React compares children by POSITION (without keys)

// Old:
<ul>
  <li>Alice</li>
  <li>Bob</li>
</ul>

// New (prepend Charlie):
<ul>
  <li>Charlie</li>
  <li>Alice</li>
  <li>Bob</li>
</ul>

// Without keys: React compares position-by-position
// Position 0: "Alice" → "Charlie" (update text)
// Position 1: "Bob" → "Alice" (update text)
// Position 2: (new) → "Bob" (insert)
// Result: 3 DOM operations

// With keys: React matches by identity
// key="charlie": new → insert
// key="alice": same → no change
// key="bob": same → no change
// Result: 1 DOM operation

7. When React Skips a Re-render

React has a built-in optimization called bailout: if setState is called with the same value, React skips the re-render.

Same-Value Bailout

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

  const handleClick = () => {
    setCount(0);  // Same value as current state
    // React compares: Object.is(0, 0) === true
    // → Skips re-render! Component function doesn't run.
  };

  console.log("Rendered");  // Does NOT fire when setting same value

  return <button onClick={handleClick}>{count}</button>;
}

How React Compares Values

React uses Object.is() for comparison:

// Primitives — compared by value
Object.is(1, 1)           // true → skip re-render
Object.is("hello", "hello") // true → skip
Object.is(true, true)     // true → skip

// Objects — compared by REFERENCE
Object.is({a: 1}, {a: 1}) // false! Different objects → RE-RENDER
Object.is([1,2], [1,2])   // false! Different arrays → RE-RENDER

const obj = {a: 1};
Object.is(obj, obj)       // true → skip (same reference)

This is why you should never create new objects/arrays in state updates unless the data actually changed:

// ❌ Always re-renders — new object every time
setUser({ ...user });  // Even if nothing changed, new reference!

// ✅ Only re-renders when data changes
setUser(prev => {
  if (prev.name === newName) return prev;  // Same reference → skip
  return { ...prev, name: newName };       // New reference → re-render
});

8. React.memo — Preventing Child Re-renders

React.memo wraps a component and prevents re-rendering when props haven't changed:

// Without memo — re-renders every time Parent re-renders
function ExpensiveList({ items }) {
  console.log("ExpensiveList rendered");
  return items.map(item => <div key={item.id}>{item.name}</div>);
}

// With memo — only re-renders when `items` prop actually changes
const ExpensiveList = React.memo(function ExpensiveList({ items }) {
  console.log("ExpensiveList rendered");
  return items.map(item => <div key={item.id}>{item.name}</div>);
});

How memo Works

┌─────────────────────────────────────────────────────────┐
│              React.memo FLOW                             │
│                                                         │
│  Parent re-renders                                      │
│       │                                                 │
│       ▼                                                 │
│  React prepares to render <MemoizedChild props={...}>   │
│       │                                                 │
│       ▼                                                 │
│  Compare previous props vs new props                    │
│  (shallow equality: Object.is for each prop)            │
│       │                                                 │
│  ┌────┴────┐                                            │
│  │         │                                            │
│  Same      Different                                    │
│  │         │                                            │
│  ▼         ▼                                            │
│  SKIP      RE-RENDER                                    │
│  (reuse    (call component                              │
│  previous   function again)                             │
│  output)                                                │
└─────────────────────────────────────────────────────────┘

When memo Helps vs. Hurts

Scenariomemo Helpful?Why
Expensive computation in childYesAvoids costly recalculation
Large list of itemsYesPrevents re-rendering 100+ children
Child has same props alwaysYesPrevents unnecessary work
Props change on every renderNomemo comparison cost + re-render anyway
Cheap component (few elements)NoOverhead of comparison > benefit
Props include inline functions/objectsNoNew reference every time → always re-renders

9. Reference Equality and Why It Matters

This is the most common source of "why is my memoized component still re-rendering?"

The Problem

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

  // ❌ New object created on EVERY render
  const style = { color: "red", fontSize: 16 };
  
  // ❌ New array created on EVERY render
  const items = [1, 2, 3];
  
  // ❌ New function created on EVERY render
  const handleClick = () => console.log("clicked");

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>Re-render</button>
      {/* MemoizedChild STILL re-renders because props are new references! */}
      <MemoizedChild style={style} items={items} onClick={handleClick} />
    </>
  );
}

The Solution: useMemo and useCallback

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

  // ✅ Same reference unless dependencies change
  const style = useMemo(() => ({ color: "red", fontSize: 16 }), []);
  const items = useMemo(() => [1, 2, 3], []);
  const handleClick = useCallback(() => console.log("clicked"), []);

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>Re-render</button>
      {/* Now MemoizedChild skips re-render — props references are stable */}
      <MemoizedChild style={style} items={items} onClick={handleClick} />
    </>
  );
}

Reference Equality Cheat Sheet

// PRIMITIVES — compared by value (always stable)
"hello" === "hello"     // true ✅
42 === 42               // true ✅
true === true           // true ✅

// OBJECTS/ARRAYS/FUNCTIONS — compared by reference
{} === {}               // false ❌ (different objects)
[] === []               // false ❌ (different arrays)
(() => {}) === (() => {}) // false ❌ (different functions)

// Same reference — stable
const obj = {};
obj === obj             // true ✅

10. Common Re-render Patterns and Anti-Patterns

Anti-Pattern 1: State in the Wrong Place

// ❌ Typing in SearchBar re-renders the ENTIRE product list
function App() {
  const [query, setQuery] = useState("");
  const [products, setProducts] = useState([...]);

  return (
    <div>
      <SearchBar query={query} onChange={setQuery} />
      <ProductList products={products} />  {/* Re-renders on every keystroke! */}
    </div>
  );
}

// ✅ Move search state down — only SearchBar re-renders
function App() {
  const [products, setProducts] = useState([...]);

  return (
    <div>
      <SearchBar />  {/* Manages its own query state */}
      <ProductList products={products} />  {/* Only re-renders when products change */}
    </div>
  );
}

Anti-Pattern 2: Inline Object/Function Props

// ❌ New object/function on every render
<UserCard style={{ margin: 10 }} onEdit={() => handleEdit(user.id)} />

// ✅ Stable references
const cardStyle = useMemo(() => ({ margin: 10 }), []);
const handleEdit = useCallback(() => editUser(user.id), [user.id]);
<UserCard style={cardStyle} onEdit={handleEdit} />

Pattern: Composition to Avoid Re-renders

// ❌ ExpensiveTree re-renders when color changes
function App() {
  const [color, setColor] = useState("red");
  return (
    <div style={{ color }}>
      <input value={color} onChange={e => setColor(e.target.value)} />
      <ExpensiveTree />  {/* Re-renders on every keystroke! */}
    </div>
  );
}

// ✅ Move state down — ExpensiveTree doesn't re-render
function App() {
  return (
    <div>
      <ColorPicker />  {/* Color state lives here */}
      <ExpensiveTree />  {/* Never re-renders for color changes */}
    </div>
  );
}

function ColorPicker() {
  const [color, setColor] = useState("red");
  return (
    <div style={{ color }}>
      <input value={color} onChange={e => setColor(e.target.value)} />
    </div>
  );
}

Pattern: Children as Props (Slot Pattern)

// ✅ Pass expensive component as children — it's created by the parent
// and doesn't re-render when ColorPicker's state changes
function App() {
  return (
    <ColorPicker>
      <ExpensiveTree />  {/* Created by App, not by ColorPicker */}
    </ColorPicker>
  );
}

function ColorPicker({ children }) {
  const [color, setColor] = useState("red");
  return (
    <div style={{ color }}>
      <input value={color} onChange={e => setColor(e.target.value)} />
      {children}  {/* Same JSX element reference — no re-render! */}
    </div>
  );
}

11. Debugging Re-renders with React DevTools

Using the Profiler

┌─────────────────────────────────────────────────────────┐
│  React DevTools → Profiler Tab                          │
│                                                         │
│  1. Click Record button                                 │
│  2. Interact with your app                              │
│  3. Click Stop                                          │
│  4. Analyze the flame graph:                            │
│                                                         │
│  ┌─ App (2.1ms) ────────────────────────┐               │
│  │ ┌─ Header (0.1ms) ──┐ ┌─ Main ─────┐│               │
│  │ │ ┌ Logo (0.05ms) ┐ │ │ ┌ List ───┐││               │
│  │ │ └────────────────┘ │ │ │ Item ×50│││               │
│  │ └────────────────────┘ │ └─────────┘││               │
│  │                        └────────────┘│               │
│  └──────────────────────────────────────┘               │
│                                                         │
│  Colors:                                                │
│  🟩 Green  = fast render (<1ms)                         │
│  🟨 Yellow = medium render (1-16ms)                     │
│  🟥 Red    = slow render (>16ms)                        │
│  ⬜ Gray   = did not render                             │
│                                                         │
│  "Why did this render?" shows:                          │
│  - Props changed                                        │
│  - State changed                                        │
│  - Parent re-rendered                                   │
│  - Context changed                                      │
└─────────────────────────────────────────────────────────┘

Highlight Updates Setting

In React DevTools → Settings → enable "Highlight updates when components render". Components flash with colored borders when they re-render:

  • Blue: infrequent renders
  • Green: moderate frequency
  • Yellow/Red: frequent renders (potential problem)

Console Logging Pattern

function MyComponent(props) {
  // Debug: log every render with props
  console.log("MyComponent rendered", { props });
  
  // Debug: track render count
  const renderCount = useRef(0);
  renderCount.current++;
  console.log(`MyComponent render #${renderCount.current}`);
  
  return <div>...</div>;
}

12. The Mental Model: State as a Snapshot

Each render is a "snapshot" — a frozen picture of the UI at one point in time. When state changes, React takes a new snapshot.

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

  const handleClick = () => {
    // All three setCount calls see count = 0 (the snapshot value)
    setCount(count + 1);  // Sets to 1
    setCount(count + 1);  // Sets to 1 (still reads count=0!)
    setCount(count + 1);  // Sets to 1 (still reads count=0!)
  };

  // Result: count goes from 0 → 1, not 0 → 3
  return <button onClick={handleClick}>{count}</button>;
}
// Fix: Use the updater function to get the LATEST value
const handleClick = () => {
  setCount(prev => prev + 1);  // 0 → 1
  setCount(prev => prev + 1);  // 1 → 2
  setCount(prev => prev + 1);  // 2 → 3
};
// Result: count goes from 0 → 3 ✅

The Snapshot Analogy

┌─────────────────────────────────────────────────────────┐
│              STATE AS SNAPSHOTS                          │
│                                                         │
│  Render 1 (snapshot):     Render 2 (snapshot):          │
│  ┌────────────────────┐   ┌────────────────────┐        │
│  │ count = 0          │   │ count = 1          │        │
│  │ text = "Hello"     │   │ text = "Hello"     │        │
│  │ items = [a, b]     │   │ items = [a, b, c]  │        │
│  │                    │   │                    │        │
│  │ Every variable,    │   │ New snapshot with   │        │
│  │ every function,    │   │ updated state.      │        │
│  │ every event handler│   │ Old snapshot is     │        │
│  │ sees THIS version  │   │ gone.               │        │
│  └────────────────────┘   └────────────────────┘        │
│                                                         │
│  State updates don't change the current snapshot.       │
│  They schedule a NEW render with a NEW snapshot.        │
└─────────────────────────────────────────────────────────┘

Closures Capture the Snapshot

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

  const handleClick = () => {
    setTimeout(() => {
      // This alert captures count from the snapshot when handleClick was created
      alert(`Count was: ${count}`);
    }, 3000);
  };

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
      <button onClick={handleClick}>Alert in 3s</button>
    </div>
  );
}

// Steps:
// 1. count = 0
// 2. Click "Alert in 3s" → captures count=0 in closure
// 3. Click "+1" three times → count becomes 3
// 4. Alert fires → shows "Count was: 0" (not 3!)
// Because the setTimeout closure captured the count=0 snapshot

13. Fiber Architecture — How React Schedules Work

React's internal engine (since React 16) is called Fiber. It enables interruptible rendering.

┌─────────────────────────────────────────────────────────┐
│              FIBER ARCHITECTURE                          │
│                                                         │
│  Each component instance is a "Fiber node":             │
│                                                         │
│  Fiber {                                                │
│    type: Component,       // What to render              │
│    props: { ... },        // Input data                  │
│    state: { ... },        // Internal data               │
│    child: Fiber | null,   // First child                 │
│    sibling: Fiber | null, // Next sibling                │
│    return: Fiber | null,  // Parent                      │
│    effectTag: string,     // What DOM operation needed    │
│    alternate: Fiber,      // Previous version (double    │
│  }                        //   buffering)                │
│                                                         │
│  React walks the Fiber tree:                            │
│                                                         │
│       App                                               │
│      ╱    ╲                                              │
│   Header  Main  ← Walk: App → Header → Logo →          │
│    │       │       (back up) → Main → Sidebar →         │
│   Logo  Sidebar    Content → (done)                     │
│          │                                              │
│        Content                                          │
│                                                         │
│  Key feature: React can PAUSE this walk at any          │
│  fiber node and resume later (time-slicing).            │
└─────────────────────────────────────────────────────────┘

Why Fiber Matters

Before Fiber (React 15): Rendering was synchronous. A large update would block the main thread, causing janky animations and unresponsive inputs.

With Fiber: React breaks rendering into small units of work (one fiber at a time) and can:

  • Pause rendering to handle high-priority events (user typing, animations)
  • Resume rendering where it left off
  • Abort rendering if a newer update makes it obsolete
  • Reuse previous work when possible

14. Concurrent Features and Rendering

React 18+ introduced concurrent rendering features that take advantage of Fiber:

Transitions (useTransition)

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

  const handleChange = (e) => {
    // High priority — update input immediately
    setQuery(e.target.value);
    
    // Low priority — can be interrupted
    startTransition(() => {
      setFilteredResults(filterHugeList(e.target.value));
    });
  };

  return (
    <div>
      <input value={query} onChange={handleChange} />
      {isPending ? <Spinner /> : <ResultsList results={filteredResults} />}
    </div>
  );
}

Suspense

// Declarative loading states
function ProfilePage({ userId }) {
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <ProfileDetails userId={userId} />
      <Suspense fallback={<PostsSkeleton />}>
        <ProfilePosts userId={userId} />
      </Suspense>
    </Suspense>
  );
}

Summary of Concurrent Features

FeaturePurpose
useTransitionMark non-urgent updates as low priority
useDeferredValueDefer re-rendering with a stale value
SuspenseDeclarative loading states for async operations
startTransitionNon-hook version for class components / outside React

15. Key Takeaways

  1. Re-render ≠ DOM update — React calls your function, diffs the result, and only updates what actually changed in the DOM
  2. Three triggers: state change, parent re-render, context change
  3. Cascading: when a component re-renders, ALL descendants re-render by default
  4. Reconciliation: React diffs old vs new Virtual DOM trees and applies minimal DOM changes
  5. Same type = update, different type = destroy and rebuild — this is why component position matters
  6. Object.is() for comparison — same primitive value skips re-render, but {} !== {}
  7. React.memo prevents child re-renders when props haven't changed (shallow comparison)
  8. Reference equality is critical — inline objects/arrays/functions create new references every render
  9. State is a snapshot — each render captures state at that moment; closures keep the snapshot
  10. Don't optimize prematurely — measure with Profiler first, then apply memo/useMemo/useCallback where it matters

Explain-It Challenge

  1. The Photo Lab: Explain React's render-commit cycle using a photo development analogy. The render phase is like developing a negative (can be discarded), the commit phase is like printing the photo (permanent). What happens when you develop a new negative before the old one is printed?

  2. The Falling Dominoes: When a parent component re-renders, all children re-render too — like dominoes falling. Explain three ways to "block" a domino from falling (React.memo, composition, moving state down). What are the tradeoffs of each approach?

  3. The Detective: Your colleague says "my component renders 50 times per second when I type in the search bar." Walk them through a debugging process: what tools would you use, what questions would you ask, and what are the three most likely causes?


Navigation: ← 2.3.b — useState Hook · Next → 2.3.d — Batching State Updates