GAP·MAP

React concurrent rendering

[ middle depth ]

What interruptible rendering changes, and what it does not

The prerequisite covered the synchronous render then commit cycle. Concurrent rendering changes one thing: in React 18 and later a render can be paused, resumed, or thrown away before it commits. React can start a low-priority render, abandon it when an urgent update (typing, a click) arrives, handle that update, then restart from the latest state. The DOM is mutated once, from a finished tree, so the user never sees a half-updated screen.

The point interviewers press hardest: concurrent features deprioritize work, they do not debounce it and do not make it faster. An expensive render still runs and costs the same CPU; it runs at lower priority and interruptibly, so it cannot block input.

There is no global "Concurrent Mode". React 18 shipped concurrent rendering as an opt-in per feature, only in an app mounted with createRoot. A plain setState still renders in one blocking pass unless a concurrent feature is involved.

useTransition marks an update non-urgent

const [isPending, startTransition] = useTransition();
startTransition(() => setTab("posts")); // this setState is non-urgent

startTransition(fn) marks the state updates made synchronously inside fn as a Transition. That render becomes interruptible: type while it runs and React discards the stale work, applies the keystroke, then restarts. isPending means a transition render is in progress, not that data is loading.

You wrap the setter, so you must own the state. Never route a controlled input's own value through a transition, or the input lags behind typing; keep the input value urgent and defer the expensive consumer. During a transition React also keeps visible content on screen rather than swapping it for a fallback; only a newly mounted boundary shows one, so route and tab navigations are wrapped in a transition.

useDeferredValue defers a value you already have

useDeferredValue(value) returns a lagging copy. An urgent update re-renders immediately with the old deferred value, then React runs a background, interruptible render to catch it up; a newer value throws that render away. Reach for it when you receive a value and do not own its setter (useTransition needs the setter). If the background render suspends, React keeps showing the previous value rather than a fallback, which is why search-as-you-type does not flash. Pass a stable reference: a fresh object literal each render looks changed and schedules pointless work.

What makes a component suspend

A component suspends when it throws a promise during render; the nearest <Suspense> shows its fallback until the promise resolves. Real triggers are a lazy() chunk, or use(promise) where the promise comes from a cache, a framework, or a Server Component passed in as a prop. The use() hook (React 19) unwraps that promise, or a context value, during render.

Wrong "I awaited the fetch in an event handler (or a useEffect), so Suspense will show the spinner." Those promises are invisible to Suspense, which only catches promises thrown during render. Right the component renders with empty or old state until your setState fires. Suspense is for render-time data readiness, not for awaiting inside handlers.

[ senior depth ]

Streaming SSR and selective hydration

Old renderToString had to finish all data before sending any HTML, ship all JS before hydrating, and hydrate the whole page before anything was interactive. Suspense boundaries plus streaming break each constraint. The server (renderToPipeableStream on Node, renderToReadableStream on web streams) sends the shell with fallbacks first, keeps rendering slow boundaries, and streams each boundary's HTML out of order when its data is ready, with a tiny inline script that swaps the fallback for real content even before the React bundle loads. Hydration then runs per boundary (selective hydration): React hydrates the shell before a lazy boundary's code arrives, and if the user clicks a boundary that has not hydrated yet, React hydrates that one first, handles the event, then continues. Parents hydrate before children, and an event does not dispatch until the target's ancestors are hydrated.

ServerClientshell HTML plus Suspense fallbackboundary HTML plus swap script[ click a boundary to hydrate it first ]boundaries stream as they resolve
fig · Streaming SSR - shell first, boundary later

In the Next.js App Router, loading.js wraps the segment's page and children in a <Suspense> whose export is the fallback, giving an instant loading state; manual <Suspense> boundaries stream individual Server Components. Navigation stays interruptible and shared layouts stay interactive.

Tearing, and why external stores need useSyncExternalStore

Tearing is a concurrent-only inconsistency: because a render can pause and resume, a mutable external store can change mid-render, so components rendered before the change show the old value and components rendered after show the new value, two versions of one source on screen. useState and useReducer cannot tear, because React snapshots its own state per render; only a store React does not control can.

const online = useSyncExternalStore(
  (cb) => {
    window.addEventListener("online", cb);
    window.addEventListener("offline", cb);
    return () => { /* remove both listeners */ };
  },
  () => navigator.onLine, // getSnapshot: stable value when unchanged
  () => true,             // getServerSnapshot: must match server HTML
);

React re-reads getSnapshot and, if the store changed during a concurrent render, restarts the render synchronously (blocking) so every component commits the same version. getSnapshot must return the same reference when data is unchanged; a fresh object or array each call is an infinite render loop. This is the primitive for Redux, Zustand, and browser APIs, not a useState plus useEffect mirror.

Transitions have boundaries: await, timeouts, and Actions

Only the setState calls made synchronously inside the transition function count. A setState in a setTimeout or after an await escapes the transition. React 19 lets startTransition take an async function (an Action, whose pending state React manages and wires into useActionState, useOptimistic, and <form action>), but updates after an await still need re-wrapping:

startTransition(async () => {
  const data = await save();
  startTransition(() => setResult(data)); // re-wrap the post-await update
});

use() needs a stable promise and an error boundary

Wrong use(fetch(...)) in render. A new promise every render means the boundary re-suspends forever. Right create the promise in a Server Component (or a Suspense-enabled cache) and pass it in. On rejection, use(promise) throws to the nearest Error Boundary, so a local try/catch cannot handle it. Unlike other hooks, use() may be called inside conditionals and loops, and use(Context) reads context like useContext while following the same rule.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more React

was this useful?