React Interview Prep
Lifecycle and Rendering

Batching & Concurrent Features (React 18)

Batching & Concurrent Features (React 18)

LinkedIn Hook

Before React 18, state updates inside setTimeout, fetch, and native event listeners were NOT batched. Each setState caused a separate re-render. Three state updates? Three re-renders.

React 18 changed everything. Automatic batching now groups ALL state updates — regardless of where they happen — into a single re-render. Timeouts, promises, native events — all batched by default.

But batching is just the beginning. React 18 introduced concurrent features that fundamentally change how rendering works: useTransition lets you mark updates as low priority so the UI stays responsive. useDeferredValue lets you defer expensive re-renders. And Suspense for data fetching gives you declarative loading states without a single isLoading flag.

In interviews, these are the questions that separate mid-level from senior candidates. "What changed about batching in React 18?" "When would you use useTransition vs useDeferredValue?" "How does flushSync work?" If you cannot answer these with clarity, you are leaving senior-level offers on the table.

In this lesson, I break down automatic batching, flushSync, Suspense, useTransition, and useDeferredValue — with real code, clear analogies, and the exact interview answers you need.

Read the full lesson -> [link]

#React #JavaScript #InterviewPrep #Frontend #CodingInterview #React18 #ConcurrentReact #useTransition #Suspense #100DaysOfCode


Batching & Concurrent Features (React 18) thumbnail


What You'll Learn

  • How automatic batching works in React 18 and what changed from React 17
  • How to opt out of batching with flushSync and when that is necessary
  • How Suspense enables declarative data fetching with fallback UIs
  • How useTransition marks state updates as low priority to keep the UI responsive
  • How useDeferredValue defers re-rendering of expensive computations
  • When to use useTransition vs useDeferredValue in real applications

The Concept — Batching and Concurrency in React

Analogy: The Restaurant Kitchen

Imagine a restaurant kitchen. Orders come in from multiple tables.

Before React 18 (no universal batching): The kitchen operated under a strange rule. If orders came from the main dining room (React event handlers), the chef would batch them — cook multiple dishes at once. But if orders came from the patio or the phone (setTimeout, fetch callbacks, native events), each order was cooked individually. Three appetizers from the phone? The chef prepared one, served it, came back, prepared the next, served it, came back again. Incredibly wasteful.

React 18 (automatic batching): The kitchen upgraded. Now ALL orders are batched regardless of where they come from. Dining room, patio, phone — the chef collects all orders that arrive in the same moment, prepares them together, and serves them in one trip. Three state updates in a setTimeout? One re-render.

flushSync is the VIP customer who demands their order NOW. They bypass the batching system. The chef drops everything, cooks their dish immediately, serves it, and then returns to the batch. Use it rarely — it breaks the efficiency of batching.

useTransition is the priority system. Urgent orders (typing in a search box) get processed immediately. Low-priority orders (filtering a 10,000-row table based on that search) are marked as "when you get a chance." If a new urgent order comes in, the kitchen can abandon the low-priority work in progress and start fresh. The UI never freezes waiting for a massive re-render.

useDeferredValue is similar, but instead of controlling WHEN an update happens, it controls WHICH version of a value a component sees. The search input sees the latest value immediately, but the expensive results list keeps showing the old value until React has time to re-render with the new one.


Automatic Batching in React 18

In React 17, batching only happened inside React event handlers. In React 18 with createRoot, ALL state updates are batched — inside promises, timeouts, native event handlers, and everywhere else.

Code Example 1: Batching — Before and After React 18

import { useState } from "react";

function UserProfile() {
  const [name, setName] = useState("Alice");
  const [age, setAge] = useState(25);
  const [loading, setLoading] = useState(false);

  console.log("Component rendered");

  // REACT EVENT HANDLER — batched in both React 17 and 18
  const handleClick = () => {
    setName("Bob");
    setAge(30);
    setLoading(true);
    // Result: ONE re-render (batched in both versions)
  };

  // SETTIMEOUT — batched ONLY in React 18
  const handleDelayedUpdate = () => {
    setTimeout(() => {
      setName("Charlie");
      setAge(35);
      setLoading(true);
      // React 17: THREE re-renders (one per setState)
      // React 18: ONE re-render (automatic batching)
    }, 1000);
  };

  // FETCH CALLBACK — batched ONLY in React 18
  const handleFetch = () => {
    fetch("/api/user")
      .then((res) => res.json())
      .then((data) => {
        setName(data.name);
        setAge(data.age);
        setLoading(false);
        // React 17: THREE re-renders
        // React 18: ONE re-render
      });
  };

  return (
    <div>
      <p>{name}, {age}</p>
      <button onClick={handleClick}>Sync Update</button>
      <button onClick={handleDelayedUpdate}>Delayed Update</button>
      <button onClick={handleFetch}>Fetch Update</button>
    </div>
  );
}

// Output when clicking "Delayed Update" in React 18:
// (after 1 second)
// "Component rendered"  <-- printed ONCE, not three times

Key point: React 18 requires createRoot (not the legacy ReactDOM.render) for automatic batching to work. If your app still uses ReactDOM.render, you get React 17 batching behavior even on React 18.


flushSync — Opting Out of Batching

Sometimes you need a state update to be applied to the DOM immediately — for example, when you need to read a DOM measurement right after a state change. flushSync forces React to flush the update synchronously.

Code Example 2: Using flushSync to Force Immediate Updates

import { useState } from "react";
import { flushSync } from "react-dom";

function ScrollToBottom() {
  const [messages, setMessages] = useState(["Hello"]);

  console.log("Rendered with", messages.length, "messages");

  const handleAddMessage = () => {
    // Without flushSync, both updates would be batched into one render.
    // But we need the DOM to update BEFORE we scroll.

    flushSync(() => {
      setMessages((prev) => [...prev, "New message"]);
    });
    // DOM is updated NOW — the new message is in the DOM

    // Safe to scroll to the bottom because the DOM reflects the new message
    const list = document.getElementById("message-list");
    list.scrollTop = list.scrollHeight;

    // This second update triggers a separate render
    flushSync(() => {
      setMessages((prev) => [...prev, "Another message"]);
    });
    // DOM is updated again — "Another message" is now in the DOM
  };

  return (
    <ul id="message-list">
      {messages.map((msg, i) => (
        <li key={i}>{msg}</li>
      ))}
      <button onClick={handleAddMessage}>Add Messages</button>
    </ul>
  );
}

// Output after clicking:
// "Rendered with 2 messages"    <-- first flushSync triggers a render
// "Rendered with 3 messages"    <-- second flushSync triggers another render
// Total: 2 re-renders instead of the 1 that batching would give

When to use flushSync: Scrolling to newly added DOM elements, reading layout measurements after state changes, integrating with third-party DOM libraries that need synchronous updates. In practice, you almost never need it.

Batching & Concurrent Features (React 18) visual 1


Suspense for Data Fetching

Suspense lets you declaratively specify a loading UI while child components are waiting for asynchronous data. Instead of manually tracking isLoading state, the component "suspends" and React shows the fallback.

Code Example 3: Suspense with Lazy Loading and Data Fetching

import { Suspense, lazy, useState } from "react";

// Lazy-loaded component — triggers Suspense while loading the JS bundle
const HeavyDashboard = lazy(() => import("./HeavyDashboard"));

// Simulated data-fetching wrapper (used by frameworks like Next.js, Relay, etc.)
// The component "throws a promise" while data is loading, and Suspense catches it
function UserProfile({ userId }) {
  // In a real app, this would use a Suspense-compatible data fetching library
  // like React Query with suspense: true, or a framework like Next.js
  const user = useSuspenseQuery(`/api/users/${userId}`);

  return <h2>Welcome, {user.name}</h2>;
}

function App() {
  const [showDashboard, setShowDashboard] = useState(false);

  return (
    <div>
      {/* Suspense wraps components that may suspend */}
      <Suspense fallback={<p>Loading profile...</p>}>
        <UserProfile userId="123" />
      </Suspense>

      <button onClick={() => setShowDashboard(true)}>
        Show Dashboard
      </button>

      {showDashboard && (
        <Suspense fallback={<p>Loading dashboard...</p>}>
          <HeavyDashboard />
        </Suspense>
      )}
    </div>
  );
}

// Behavior:
// 1. On initial render, "Loading profile..." is shown while UserProfile fetches data
// 2. When data arrives, UserProfile renders with the user's name
// 3. When "Show Dashboard" is clicked, "Loading dashboard..." shows while
//    the HeavyDashboard JS bundle is downloaded
// 4. Once loaded, HeavyDashboard renders

// Key: No isLoading state, no ternary operators for loading — Suspense handles it

Key point: Suspense is NOT just for React.lazy. In React 18, it works with any Suspense-compatible data fetching approach. However, you should not throw promises manually — use a framework or library that integrates with Suspense (React Query, Relay, Next.js, etc.).


useTransition — Low Priority Updates

useTransition lets you mark a state update as non-urgent. React will keep the UI responsive to urgent updates (like typing) while processing the transition in the background. If a new urgent update comes in, React can interrupt the transition and restart it.

Code Example 4: useTransition for a Search Filter

import { useState, useTransition, memo } from "react";

// Expensive list component — renders 10,000 items
const FilteredList = memo(function FilteredList({ filter }) {
  console.log("FilteredList rendering with filter:", filter);

  // Simulate expensive filtering
  const items = [];
  for (let i = 0; i < 10000; i++) {
    if (`Item ${i}`.toLowerCase().includes(filter.toLowerCase())) {
      items.push(<li key={i}>Item {i}</li>);
    }
  }

  return <ul>{items}</ul>;
});

function SearchPage() {
  const [input, setInput] = useState("");
  const [filter, setFilter] = useState("");
  const [isPending, startTransition] = useTransition();

  const handleChange = (e) => {
    const value = e.target.value;

    // URGENT: Update the input field immediately (user sees their typing)
    setInput(value);

    // LOW PRIORITY: Update the filter for the expensive list
    startTransition(() => {
      setFilter(value);
    });
  };

  return (
    <div>
      <input
        value={input}
        onChange={handleChange}
        placeholder="Search items..."
      />

      {/* Show a visual hint that the list is updating */}
      {isPending && <p>Updating results...</p>}

      {/* This expensive re-render happens as a low-priority transition */}
      <FilteredList filter={filter} />
    </div>
  );
}

// Behavior:
// 1. User types "abc" quickly
// 2. The input field updates instantly on each keystroke (urgent update)
// 3. FilteredList may only re-render once with "abc" (React skips intermediate
//    renders for "a" and "ab" because new urgent updates interrupted them)
// 4. isPending is true while the transition is in progress
// 5. The UI NEVER freezes — typing remains smooth

Key point: useTransition does not debounce. It uses React's concurrent rendering to actually interrupt in-progress renders. This is fundamentally different from setTimeout or debounce — React can abandon partial rendering work when a higher-priority update comes in.

Batching & Concurrent Features (React 18) visual 2


useDeferredValue — Deferring Expensive Re-renders

useDeferredValue accepts a value and returns a deferred version that may lag behind the original. React re-renders with the deferred value at a lower priority, keeping the UI responsive.

import { useState, useDeferredValue, memo } from "react";

const ExpensiveChart = memo(function ExpensiveChart({ data }) {
  // Imagine this takes 200ms to render
  console.log("Chart rendering with data length:", data.length);

  return (
    <div>
      {data.map((point, i) => (
        <div key={i} style={{ height: point, background: "#61dafb" }} />
      ))}
    </div>
  );
});

function Dashboard() {
  const [range, setRange] = useState(100);

  // The deferred value may lag behind the actual range value
  const deferredRange = useDeferredValue(range);

  // Check if the deferred value is stale (still catching up)
  const isStale = range !== deferredRange;

  // Generate data based on the deferred value (not the immediate value)
  const data = generateChartData(deferredRange);

  return (
    <div>
      <input
        type="range"
        min={10}
        max={10000}
        value={range}
        onChange={(e) => setRange(Number(e.target.value))}
      />
      <p>Range: {range}</p>

      {/* Dim the chart while the deferred value is catching up */}
      <div style={{ opacity: isStale ? 0.6 : 1 }}>
        <ExpensiveChart data={data} />
      </div>
    </div>
  );
}

// Behavior:
// 1. User drags the slider rapidly
// 2. The slider and range number update instantly (urgent render)
// 3. The chart uses deferredRange, which lags behind
// 4. React re-renders the chart at low priority with the deferred value
// 5. While catching up, the chart dims to 60% opacity (visual feedback)
// 6. The slider NEVER stutters, even though the chart is expensive

useTransition vs useDeferredValue — When to Use Which

+---------------------------+-------------------------------+-------------------------------+
| Criteria                  | useTransition                 | useDeferredValue              |
+---------------------------+-------------------------------+-------------------------------+
| What you control          | The state UPDATE itself        | A VALUE passed to a component |
+---------------------------+-------------------------------+-------------------------------+
| You own the state setter? | Yes — wrap setState in         | No — you receive the value    |
|                           | startTransition                | as a prop or from a hook      |
+---------------------------+-------------------------------+-------------------------------+
| Provides isPending?       | Yes                           | No (compare old vs new value) |
+---------------------------+-------------------------------+-------------------------------+
| Typical use case          | You trigger the state update   | A parent passes you a prop    |
|                           | and want to deprioritize it    | and you want to defer it      |
+---------------------------+-------------------------------+-------------------------------+
| Example                   | Search input -> filter state   | Receiving a search query prop |
|                           | update in startTransition      | and deferring it for a chart  |
+---------------------------+-------------------------------+-------------------------------+

Common Mistakes

Mistake 1: Assuming Batching Requires createRoot

// WRONG: Using the legacy render API — automatic batching is NOT enabled
import ReactDOM from "react-dom";
ReactDOM.render(<App />, document.getElementById("root"));
// State updates in setTimeout, fetch, etc. are NOT batched

// CORRECT: Using createRoot — automatic batching is enabled everywhere
import { createRoot } from "react-dom/client";
const root = createRoot(document.getElementById("root"));
root.render(<App />);
// Now ALL state updates are batched regardless of context

Mistake 2: Using useTransition for Urgent Updates

function LoginForm() {
  const [email, setEmail] = useState("");
  const [isPending, startTransition] = useTransition();

  const handleChange = (e) => {
    // BUG: Wrapping the input update in startTransition makes typing laggy
    // because React treats it as low priority
    startTransition(() => {
      setEmail(e.target.value);
    });
  };

  return <input value={email} onChange={handleChange} />;
}

// FIX: Only wrap the EXPENSIVE update in startTransition, not the input itself.
// The input state should update urgently. A separate state for the
// expensive computation should be wrapped in startTransition.

Mistake 3: Wrapping Every State Update in startTransition

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

  // WRONG: This update is cheap — wrapping it adds overhead for no benefit
  const handleClick = () => {
    startTransition(() => {
      setCount((prev) => prev + 1);
    });
  };

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

// FIX: Only use startTransition for updates that trigger expensive re-renders.
// Simple state updates like incrementing a counter do not need it.
// Overusing startTransition adds scheduling overhead and delays updates
// that should be instant.

Interview Questions

Q: What is automatic batching in React 18, and how does it differ from React 17?

In React 17, state updates were only batched inside React event handlers (onClick, onChange, etc.). Updates inside setTimeout, fetch callbacks, native event listeners, and promises each triggered a separate re-render. React 18 with createRoot batches ALL state updates into a single re-render, regardless of where they originate. This means fewer re-renders and better performance by default. The key requirement is using createRoot instead of the legacy ReactDOM.render.

Q: What is flushSync and when would you use it?

flushSync is a function from react-dom that forces React to flush state updates synchronously. It opts out of automatic batching for the updates inside its callback. You would use it when you need the DOM to reflect a state change immediately — for example, scrolling to a newly added list item, reading a DOM measurement after a state update, or integrating with third-party libraries that need synchronous DOM updates. It should be used sparingly because it defeats the performance benefits of batching.

Q: How does useTransition work and when should you use it?

useTransition returns [isPending, startTransition]. When you wrap a state update in startTransition, React marks it as a low-priority transition. React will process urgent updates (like user input) first and render the transition update in the background. If a new urgent update arrives while the transition is rendering, React can interrupt and discard the in-progress transition render. Use it when a state update triggers an expensive re-render (like filtering a large list or re-rendering a complex chart) and you want the UI to stay responsive to user input during that re-render.

Q: What is the difference between useTransition and useDeferredValue?

Both achieve similar goals but are used in different situations. useTransition wraps a state setter — you control the update itself and get an isPending flag. Use it when you own the state and trigger the update. useDeferredValue wraps a value — you receive a value (often as a prop) and get a deferred copy that lags behind. Use it when you do not control the state update but want to defer re-rendering based on that value. To detect staleness with useDeferredValue, compare the original and deferred values.

Q: How does Suspense work for data fetching in React 18?

Suspense lets a component "suspend" while waiting for asynchronous data. When a component suspends, React shows the nearest Suspense boundary's fallback UI. Once the data is ready, React re-renders the component with the actual content. This eliminates manual isLoading state management and ternary operators for loading states. In React 18, Suspense works with any Suspense-compatible data source — not just React.lazy. However, you should use it through a framework or library (React Query, Relay, Next.js) rather than throwing promises manually, as the internal protocol is not considered a public API.


Quick Reference — Cheat Sheet

+-----------------------------------+-------------------------------------------+
| Concept                           | Key Point                                 |
+-----------------------------------+-------------------------------------------+
| Automatic batching (React 18)     | ALL state updates are batched into one    |
|                                   | re-render, regardless of context.         |
|                                   | Requires createRoot.                      |
+-----------------------------------+-------------------------------------------+
| flushSync                         | Forces synchronous DOM update. Opts out   |
|                                   | of batching. Use for DOM measurements     |
|                                   | or scroll-after-update. Use sparingly.    |
+-----------------------------------+-------------------------------------------+
| Suspense                          | Declarative loading states. Wrap async    |
|                                   | components in <Suspense fallback={...}>.  |
|                                   | No manual isLoading flags needed.         |
+-----------------------------------+-------------------------------------------+
| useTransition                     | Returns [isPending, startTransition].     |
|                                   | Wrap expensive state updates to mark      |
|                                   | them as low priority. UI stays responsive.|
+-----------------------------------+-------------------------------------------+
| useDeferredValue                  | Returns a deferred copy of a value.       |
|                                   | The deferred version lags behind during   |
|                                   | heavy renders. Compare to detect stale.   |
+-----------------------------------+-------------------------------------------+
| useTransition vs useDeferredValue | useTransition: you own the setState call. |
|                                   | useDeferredValue: you receive the value.  |
+-----------------------------------+-------------------------------------------+
| startTransition (standalone)      | Same as useTransition but without         |
|                                   | isPending. Import from "react". Useful    |
|                                   | outside components (e.g., in routers).    |
+-----------------------------------+-------------------------------------------+
| Concurrent rendering              | React can interrupt, pause, and restart   |
|                                   | renders. Transitions are interruptible.   |
|                                   | Urgent updates are never delayed.         |
+-----------------------------------+-------------------------------------------+

RULE: Use createRoot to enable automatic batching and concurrent features.
RULE: Only use startTransition for updates that trigger expensive re-renders.
RULE: useDeferredValue when you do not control the state, useTransition when you do.
RULE: flushSync is an escape hatch — not a default pattern.

Previous: Lesson 4.3 — React.memo — Preventing Unnecessary Re-renders -> Next: Lesson 5.1 — Event Handling in React ->


This is Lesson 4.4 of the React Interview Prep Course — 10 chapters, 42 lessons.

On this page