React NotesRohit’s interview study guide
Chapter 11

Concurrent React & Suspense

How React 18+ can prepare a new screen in the background without freezing the current one: concurrent rendering, useTransition, useDeferredValue, Suspense boundaries, React 19.2's <Activity>, and why external stores need useSyncExternalStore.

6 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Concurrent rendering

Before React 18, rendering was synchronous and uninterruptible: once React started rendering an update, it went through the whole tree in one go. If that took 200 ms, the main thread was blocked for 200 ms, and keystrokes, clicks and animations waited. Concurrent rendering makes rendering interruptible: React renders in small units of work (one component at a time), checks between them whether something more urgent has arrived, and if so, pauses or throws away the work in progress, handles the urgent update, and comes back later.

The trick that makes this safe is that rendering is not committing. Rendering means calling your components and working out the new tree in memory; committing means applying the result to the DOM. React can render a new screen, keep it half-done, discard it, or render it twice, and the user sees none of that until the commit. That's also why render must be pure: React may call your component and then never use the result.

Mental model: a chef (the main thread) is slowly plating a fancy dessert (a transition). A customer at the counter asks for a glass of water (a click). A pre-18 chef finishes the dessert first; a concurrent chef puts the dessert down, hands over the water, and goes back to the dessert, starting it again if the order changed.

Not every update is interruptible. You tell React which updates can wait by marking them as transitions (startTransition, useTransition) or deferring a value (useDeferredValue). Everything else (clicks, typing) stays urgent and synchronous. This example uses the real scheduler (no act() in the test) to show an interruption happening:

Interrupt.jsx
import { memo, startTransition, useLayoutEffect, useState } from "react";

const SlowItem = memo(function SlowItem({ text }) {
  const start = performance.now();
  while (performance.now() - start < 1) {} // 1 ms of pretend work per item
  log.push(`render item ${text}`);
  return <li>{text}</li>;
});

export function App() {
  const [query, setQuery] = useState(""); // urgent: what the user typed
  const [results, setResults] = useState(""); // transition: what the list shows

  useLayoutEffect(() => {
    log.push(`COMMIT query="${query}" results="${results}"`);
  });

  const items = results ? Array.from({ length: 200 }, (_, i) => `${results}-${i}`) : [];
  return (
    <>
      <button onClick={() => setQuery((q) => q + "x")}>Type</button>
      <p>Query: {query}</p>
      <ul>
        {items.map((text) => (
          <SlowItem key={text} text={text} />
        ))}
      </ul>
    </>
  );
}

// The test: start a ~200 ms transition, and click 30 ms into it.
startTransition(() => setResults("a"));
setTimeout(() => button.click(), 30);
// log → item renders 1–29, 'COMMIT query="x" results=""', item renders again ×200,
//        'COMMIT query="x" results="a"'
What’s happening
  1. startTransition(() => setResults("a")) schedules a transition update. React starts rendering the 200 SlowItems, about 1 ms each, and after every few milliseconds of work it yields back to the browser (its scheduler checks the clock between components, in roughly 5 ms slices).
  2. 30 ms in, the click arrives. Because React yielded, the browser can actually run the click handler: setQuery from a click is a discrete (urgent) update. At this point the log shows 29 items rendered for the transition.
  3. On its next slice, React sees a higher-priority update. It throws away the half-built transition tree and renders just the urgent update: the log shows COMMIT query="x" results="". The user sees their click reflected straight away, while the list still shows the old (empty) results.
  4. Then React restarts the transition from scratch, now with query = "x" too, and commits query="x" results="a". The test counts 229 item renders in total: the 29 discarded plus a full 200. Concurrent rendering spends extra CPU to keep the UI responsive.
  5. Without startTransition, setResults("a") would be urgent: one blocking 200 ms render, and the click would only be handled after it finished.
AdvancedNot every non-urgent-looking update interrupts a transition

The same test with setTimeout(() => setQuery("x"), 30) instead of a click gives a different log: all 200 items render once, then COMMIT query="" results="a", then COMMIT query="x" results="a". The setTimeout update waited for the transition to finish.

The reason is in React's lane priorities. Updates from discrete events (click, keydown, input) get the highest lane; updates from continuous events (mousemove, scroll) the next; updates with no event (a timer, a network callback) get the default lane. In the installed react-dom, getNextLanes keeps rendering the current transition when the new update is in the default lane (32 === suspendedLanes && 0 !== (wipLanes & TransitionLanes) → keep the work in progress). Default updates and transitions are both "not user input", so React doesn't throw away transition work for them. Only input interrupts.

Why the tests for this topic don't use act(): inside act, the installed react-dom renders with its synchronous work loop (null !== ReactSharedInternals.actQueue ? workLoopSync() : workLoopConcurrentByScheduler()), so nothing can interrupt anything. The interruption tests create a root with createRoot directly, set IS_REACT_ACT_ENVIRONMENT = false, and let the real scheduler (5 ms frameInterval) run.

Two more limits worth knowing:

  • React can only yield between components. A single component that takes 50 ms to render blocks for 50 ms, transition or not. Concurrency helps when the cost is spread over many components (lists, trees), not one giant one. For one heavy computation, use useMemo, split the work, or move it to a Web Worker.
  • React 19.3 untangled transitions. The 19.3 release post lists "Transitions render independently instead of being entangled into one render, so a slow Transition no longer blocks unrelated ones" (react#37290). Before 19.3, several pending transitions were usually rendered together as one batch.

#useTransition

useTransition lets you mark a state update as non-urgent and tells you whether it's still in progress. It returns [isPending, startTransition]. Updates inside startTransition render in the background: the current UI stays on screen and interactive while React prepares the new one, and if the user does something else first, the background render is abandoned. My notes put it as "useTransition hook for background rendering", which is a good summary.

Mental model: an urgent update is "do this now"; a transition is "start working on this, but don't let it get in the way, and don't show me a half-finished result". isPending is your chance to show that work is under way (dim the old content, show a small spinner) instead of freezing.

The classic use is switching tabs where one tab is slow to render:

Tabs.jsx
import { memo, useLayoutEffect, useState, useTransition } from "react";

const SlowPosts = memo(function SlowPosts() {
  const items = [];
  for (let i = 0; i < 300; i++) {
    const start = performance.now();
    while (performance.now() - start < 0.5) {} // pretend each post is expensive
    items.push(<li key={i}>Post #{i + 1}</li>);
  }
  return <ul>{items}</ul>;
});

export function Tabs() {
  const [tab, setTab] = useState("about");
  const [isPending, startTransition] = useTransition();

  useLayoutEffect(() => {
    commits.push(`tab=${tab} isPending=${isPending}`);
  });

  function selectTab(next) {
    startTransition(() => {
      setTab(next); // low priority: may be interrupted, old UI stays meanwhile
    });
  }

  return (
    <>
      <nav style={{ opacity: isPending ? 0.6 : 1 }}>
        <button onClick={() => selectTab("about")}>About</button>
        <button onClick={() => selectTab("posts")}>Posts</button>
        <button onClick={() => selectTab("contact")}>Contact</button>
      </nav>
      {tab === "about" && <p>About me</p>}
      {tab === "posts" && <SlowPosts />}
      {tab === "contact" && <p>Contact me</p>}
    </>
  );
}
What’s happening
  1. Click "Posts". startTransition runs its callback immediately, but the setTab("posts") inside it is recorded as a transition update, not applied yet.
  2. React first does an urgent render in which only isPending changes: the commit log shows tab=about isPending=true. The nav dims and "About me" is still on screen: the old UI, still interactive.
  3. React then renders the transition in the background: tab = "posts", 300 posts at 0.5 ms each (about 150 ms of work, yielding as it goes). When it completes, it commits tab=posts isPending=false in one step: the posts appear and the dimming goes away together.
  4. With the real scheduler, a second test clicks "Posts" and then "Contact" 30 ms later. The log is tab=about isPending=true, then tab=contact isPending=false: the Posts tab was never committed. React discarded its half-finished render when the newer update arrived. Without a transition, the first click would have blocked for 150 ms and the "Contact" click would have waited behind it.
  5. memo on SlowPosts stops it re-rendering for unrelated parent updates (like the isPending render), so the urgent render stays cheap.

Rules and limits:

  • Only state updates can be transitions. startTransition takes a function that calls setState (or a store's update function wired to React); it doesn't make arbitrary code non-blocking.
  • Don't use it for controlled inputs. The text in an <input value={text}> must update urgently, or typing feels broken. Keep the input's own state urgent and put the expensive part (the filtered list) behind a transition or useDeferredValue.
  • A transition doesn't make slow code faster. The work still happens; it just stops blocking urgent updates. If every render is slow, profile and memoise.
  • startTransition can also be imported directly from react when you don't need isPending (outside components, in a router or store).
Why does the old page stay visible here instead of the Suspense fallback?
React
startTransition(() => setPage("about")); // the About page suspends while loading
Show answer

Because a transition won't hide content that's already on screen to show a fallback. React keeps the old UI (with isPending true) until the new page has everything it needs.

What’s happening
  1. Without a transition, setPage("about") renders urgently; the About page suspends; the nearest <Suspense> boundary must show something, so the current page is hidden and the fallback appears (a "flash" of a spinner).
  2. With the transition, React renders the About page in the background; when it suspends, React simply waits: nothing is committed, and the Home page stays visible.
  3. isPending is true meanwhile, so you can show a small "Navigating…" hint. The Suspense topic below tests exactly this: with the transition, "Home page" stays visible and "Loading page…" never appears.
  4. This is why routers (React Router, Next.js) wrap navigations in transitions.

#useDeferredValue

Added

useDeferredValue(value) gives you a copy of a value that lags behind during busy periods. On an urgent update, React first re-renders with the old deferred value (fast, because whatever depends on it can skip), then starts a background render with the new value, which can be interrupted if the value changes again.

It's the "other half" of useTransition. Use useTransition when you own the state update and can wrap it. Use useDeferredValue when you only receive a value (a prop, or state from a hook you don't control) and want the expensive part of the UI to follow it at a lower priority.

Mental model: a TV news ticker with a slow graphics team. The anchor (the input) speaks in real time; the full-screen graphic (the slow list) is redrawn when the team catches up, always showing the latest headline they've finished, never a half-drawn one.

Search.jsx
import { memo, useDeferredValue, useLayoutEffect, useState } from "react";

const ALL = Array.from({ length: 2000 }, (_, i) => `item ${i}`);

const SlowResults = memo(function SlowResults({ query }) {
  const start = performance.now();
  while (performance.now() - start < 50) {} // 50 ms of filtering work
  const hits = ALL.filter((item) => item.includes(query)).slice(0, 5);
  return <ul>{hits.map((h) => <li key={h}>{h}</li>)}</ul>;
});

export function Search() {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  useLayoutEffect(() => {
    commits.push(`query="${query}" deferred="${deferredQuery}"`);
  });

  return (
    <>
      <input aria-label="Search" value={query} onChange={(e) => setQuery(e.target.value)} />
      <div style={{ opacity: isStale ? 0.5 : 1 }}>
        <SlowResults query={deferredQuery} />
      </div>
    </>
  );
}
What’s happening
  1. Type "1". The input's onChange makes an urgent update, query = "1". During that render useDeferredValue returns the previous value, "". SlowResults gets the same query="" prop it already had, so memo skips it: the render is cheap and commits immediately. The log shows query="1" deferred="", with the old results dimmed (isStale).
  2. React then schedules a background render in which deferredQuery is "1". SlowResults really re-renders (50 ms) and the commit is query="1" deferred="1".
  3. Type "9": the same two steps, query="19" deferred="1" then query="19" deferred="19". The final list is item 19, item 119, item 190, item 191, item 192.
  4. The memo is essential. Without it, SlowResults would re-render in the urgent pass too (its parent re-rendered), and the deferral would buy nothing.
  5. Under the real scheduler, with keystrokes queued while a slow render was running, the test saw SlowResults start a render for every value (r, re, rea, reac, react), but only deferred="react" was ever committed; every intermediate render was abandoned when the next keystroke arrived. The input itself committed all five values.

useDeferredValue vs debouncing: a debounce waits a fixed time (say 300 ms) after the last keystroke, even on a fast machine, and still blocks once it runs. A deferred value has no fixed delay: on a fast device the list follows almost immediately; on a slow one it lags only as much as needed, and the background render never blocks typing. Debounce is still the right tool for network requests (you don't want a request per keystroke); deferring is for rendering cost.

With Suspense: if the deferred render suspends (the new results need data), the user keeps seeing the old results instead of a fallback, the same as with a transition. query !== deferredQuery is the usual "show a stale hint" check.

#Suspense boundaries

<Suspense fallback={…}> marks a part of the tree that may not be ready to render yet. When any component inside it suspends (waiting for code or data), React shows the fallback for that whole boundary, and swaps in the real content once everything inside is ready. My notes described it well: "What to show when the component is loading", with the classic lazy-loading example, which is correct as written.

Mental model: a boundary is a curtain. Everything behind one curtain is revealed together. More curtains (nested boundaries) mean more independent reveals; one big curtain means one reveal when the slowest part is ready. You design the loading experience by choosing where the curtains go.

What can suspend: React.lazy components while their chunk loads, use(promise) while the promise is pending, and Suspense-enabled libraries and frameworks (useSuspenseQuery, Next.js, Relay). Data fetched in useEffect cannot, because it happens after rendering.

LazyDemo.jsx
import { Suspense, lazy, useState } from "react";

const MyComponent = lazy(() => import("./MyComponent"));

function App() {
  const [showComponent, setShowComponent] = useState(false);
  return (
    <div>
      <h1>Lazy Loading Example</h1>
      <button onClick={() => setShowComponent(true)}>Load Component</button>
      {showComponent && (
        <Suspense fallback={<div>Loading...</div>}>
          <MyComponent />
        </Suspense>
      )}
    </div>
  );
}
What’s happening
  1. At first showComponent is false, so MyComponent isn't rendered and its chunk (MyComponent.js) hasn't been downloaded. lazy only stores the import function.
  2. Click → showComponent becomes true → React renders <MyComponent /> for the first time, which calls the import function. The module isn't loaded, so the lazy component suspends (internally, it hands React the import promise).
  3. React shows the nearest boundary's fallback, "Loading...". In the test the import is a promise we control, so "Loading..." stays until we resolve it.
  4. The import resolves with { default: Component } (that's why lazy needs a default export). React retries the render, MyComponent renders "I am lazily loaded!", and the fallback is removed.
  5. A second click (or a remount) won't suspend again: lazy caches the loaded module. More on chunk failures in When a lazy chunk fails to load.

Boundaries nest, and that's how you design the reveal order:

Nested.jsx
import { Suspense, use } from "react";

function Section({ promise }) {
  const text = use(promise);
  return <p>{text}</p>;
}

export function Page({ promises }) {
  return (
    <Suspense fallback={<p>Loading page…</p>}>
      <Section promise={promises.header} />
      <Suspense fallback={<p>Loading comments…</p>}>
        <Section promise={promises.comments} />
      </Suspense>
    </Suspense>
  );
}
What’s happening
  1. Both promises are pending. The header suspends the outer boundary, so the whole page shows "Loading page…" (the test reads exactly that as the page text).
  2. The header resolves first: the outer boundary can reveal. Its content is the header plus the inner boundary, which is still waiting, so the page reads "Post title" + "Loading comments…".
  3. The comments resolve: "Post title" + "3 comments".
  4. Reverse the order (comments first) and nothing changes on screen until the header arrives, because the inner boundary sits inside the outer one, behind its curtain. Then both appear at once: "Post title3 comments". An inner boundary can never reveal before its parent.
  5. So put boundaries around parts that are useful on their own (the article before the comments), and keep things that should appear together under one boundary to avoid content popping in piece by piece.

Suspense and transitions together decide what happens when already visible content suspends again (a navigation, a new tab):

Nested.jsx
function Navigator({ loadPage, useTransitionForNav }) {
  const [page, setPage] = useState(() => loadPage("home"));
  const [isPending, startTransition] = useTransition();
  function go(name) {
    if (useTransitionForNav) startTransition(() => setPage(loadPage(name)));
    else setPage(loadPage(name));
  }
  return (
    <>
      <button onClick={() => go("about")}>About</button>
      {isPending && <span>Navigating…</span>}
      <Suspense fallback={<p>Loading page…</p>}>
        <Section promise={page} />
      </Suspense>
    </>
  );
}
What’s happening
  1. Home has loaded. Click "About" without a transition: the update is urgent, Section suspends on the About promise, and the boundary has to show its fallback. The test sees "Loading page…", and the Home <p> is still in the DOM but hidden with style="display: none !important;". React hides rather than deletes it, so its state survives if the navigation is cancelled.
  2. Same click with startTransition: React renders the About page in the background, sees it suspend, and keeps the Home page visible; isPending shows "Navigating…" and "Loading page…" never appears.
  3. When the About promise resolves, both versions end with "About page"; the transition version also clears "Navigating…".
  4. The rule: an urgent update may replace visible content with a fallback; a transition won't, it waits. New boundaries that have never shown content still show their fallback either way.
  5. To deliberately show the fallback again for a new item (a different profile), give the boundary a key (<Suspense key={userId}>), which makes it a new boundary.
AdvancedReact 19: fallbacks commit first, siblings are pre-warmed

React 18 rendered all siblings of a suspended component before showing the fallback. React 19 changed this: as soon as a component suspends, React commits the fallback immediately, then does a separate "pre-warm" render of the remaining siblings so their lazy chunks and data requests start early.

The test renders <Suspense fallback={<Fallback />}><A /><B /></Suspense> where A suspends:

JavaScript
// log (React 19.3)
["render A", "COMMIT fallback", "render A", "render B"]
What’s happening
  1. A renders and suspends. React stops there: B is not rendered before the fallback is shown.
  2. The fallback commits (its layout effect logs COMMIT fallback). The user sees "Loading…" sooner than in React 18, where B (and everything else in the boundary) would have rendered first.
  3. React then pre-warms: it renders A (still suspended) and B in the background, so if B had its own lazy import or use(promise), that request would start now rather than after A resolves. Nothing from this pass is committed.
  4. When A's data arrives, A and B render and commit together: the page reads "AB".

Practical guidance:

  • Put a boundary around each independently useful region (a sidebar, a feed, a chart), not around every component. Too many boundaries make a page that flickers in pieces; too few make one slow part hold up everything.
  • Pair boundaries with error boundaries: a rejected promise or failed chunk throws to the nearest error boundary, not the Suspense boundary. See Error boundaries.
  • Trigger navigations and tab switches in transitions so revealed content doesn't flash back to a spinner.
  • In React 19.1+, Suspense boundaries work the same on the client, the server and during hydration (19.1 changelog), and in 19.2 the server batches the reveal of streamed boundaries for a short time so more content appears together.

#Activity: hiding UI without losing state

Added

<Activity> (stable in React 19.2) lets you hide part of the tree instead of unmounting it. With mode="hidden", React keeps the component's state and DOM, hides the DOM with display: none, cleans up its effects (so subscriptions and timers stop), and keeps rendering it at the lowest priority when its props change. Switch back to mode="visible" and it reappears exactly as you left it, with its effects set up again.

The problem it solves: with conditional rendering ({tab === "compose" && <Compose />}), switching tabs unmounts the component, so a half-written draft, a scroll position or an expanded tree is lost. Hiding with CSS keeps the state but leaves effects running (a hidden tab still polling, a hidden video still playing). <Activity> gives you the middle ground: kept, but asleep.

Mental model: conditional rendering is closing the app; CSS hiding is minimising it (still running in the background); <Activity mode="hidden"> is a phone app that's suspended in the background, memory kept, not using CPU, restored instantly.

ActivityTabs.jsx
import { Activity, useEffect, useState } from "react";

function Compose() {
  const [draft, setDraft] = useState("");
  useEffect(() => {
    log.push("Compose effect: subscribe");
    return () => log.push("Compose cleanup: unsubscribe");
  }, []);
  return <textarea aria-label="Draft" value={draft} onChange={(e) => setDraft(e.target.value)} />;
}

function Inbox() {
  return <p>3 new messages</p>;
}

export function Mail() {
  const [tab, setTab] = useState("compose");
  return (
    <>
      <button onClick={() => setTab("compose")}>Compose</button>
      <button onClick={() => setTab("inbox")}>Inbox</button>
      <Activity mode={tab === "compose" ? "visible" : "hidden"}>
        <Compose />
      </Activity>
      <Activity mode={tab === "inbox" ? "visible" : "hidden"}>
        <Inbox />
      </Activity>
    </>
  );
}
What’s happening
  1. First render: Compose is visible, so its effect runs (Compose effect: subscribe). Inbox starts hidden but is still rendered: the initial HTML already contains <p style="display: none !important;">3 new messages</p>. It was pre-rendered at low priority, without running its effects.
  2. The user types "Hi Bob" into the draft. That's state inside Compose.
  3. Click "Inbox": Compose's Activity becomes hidden. React runs Compose's effect cleanup (Compose cleanup: unsubscribe) and sets style="display: none !important;" on the textarea, but doesn't unmount it: the DOM still contains the textarea with "Hi Bob". Inbox's <p> gets its display back.
  4. Click "Compose": the textarea reappears with "Hi Bob" still in it, and the effect runs again (Compose effect: subscribe). From the effect's point of view it was unmounted and remounted; from the state's point of view nothing happened.
  5. The comparison test uses {tab === "compose" && <Compose />}: after switching away and back, the draft is "". The state was destroyed with the component.

Hidden Activities can also pre-render what the user will probably open next, data included, as long as the data comes from something Suspense-aware:

React
<Activity mode="hidden">
  <Suspense fallback={<p>…</p>}>
    <Next /> {/* calls use(dataPromise) */}
  </Suspense>
</Activity>
What’s happening
  1. Next renders while hidden and suspends on its promise, so the request is already in flight before the user asks for the page.
  2. When the promise resolves, React renders Next with the data. The test finds "Next page" in the DOM but not.toBeVisible().
  3. Its useEffect has not run (the test's events array is empty). Effects only mount when the Activity becomes visible, so a pre-rendered page doesn't start timers, analytics or subscriptions early.
  4. Data fetched in a useEffect would not be pre-fetched this way, since effects don't run while hidden. The React docs make the same point: only Suspense-enabled data sources are fetched during pre-rendering.

Things to watch for:

  • DOM side effects keep going. Hidden isn't unmounted: a <video> keeps playing and an <iframe> keeps running. The React docs' fix is a useLayoutEffect whose cleanup calls video.pause().
  • Effects must clean up properly. Code that only works because "mount happens once" breaks, because hiding and showing re-runs effects. Strict Mode exercises this path for you in development.
  • Memory. Hidden trees stay in memory. Keep Activity for a few likely destinations (tabs, the previous page), not hundreds of items; a list of hidden rows is a job for virtualisation.
  • A hidden Activity whose children are only text renders nothing (there's no element to put display: none on).

#Concurrent features and tearing

Added

Tearing is when one screen shows two different versions of the same data: half the components rendered with the old value, half with the new. It's a bug that concurrent rendering makes possible for one specific kind of state: external mutable stores (a global object, a module variable, an older store library) read directly during render.

Why: with synchronous rendering, nothing else runs while React renders, so every component sees the same store value. With concurrent rendering, React yields in the middle of a render, other code runs, and if that code mutates the store, the components rendered after the yield read the new value while those rendered before read the old one. React's own state (useState, useReducer, context) can't tear, because React controls when it changes. Mental model: photographing a group with a slow shutter while someone swaps shirts mid-exposure.

The fix is useSyncExternalStore, the hook that store libraries (Redux's useSelector, Zustand) use under the hood. You give it subscribe and getSnapshot; React checks the snapshot during and after rendering, and if the store changed mid-render, it re-renders synchronously so the commit is consistent. This test shows tearing for real:

Tearing.jsx
import { startTransition, useState, useSyncExternalStore } from "react";

export function createStore(initial) {
  let value = initial;
  const listeners = new Set();
  return {
    get: () => value,
    set(next) {
      value = next;
      listeners.forEach((l) => l());
    },
    subscribe(listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

// ❌ Reads the mutable store directly during render.
function UnsafeCell({ store }) {
  spin(2); // 2 ms of work
  return <span>{store.get()}</span>;
}

// ✅ Reads it through useSyncExternalStore.
function SafeCell({ store }) {
  const value = useSyncExternalStore(store.subscribe, store.get);
  spin(2);
  return <span>{value}</span>;
}

// The test: render 50 cells in a transition and change the store 30 ms in.
startTransition(() => setShow(true));
setTimeout(() => store.set("red"), 30);
// UnsafeCell → { blue: 14, red: 36 }    SafeCell → { blue: 0, red: 50 }
What’s happening
  1. The store starts as "blue". A transition starts rendering 50 cells at about 2 ms each, so the render takes about 100 ms and React yields every few milliseconds.
  2. 30 ms in, a timer runs store.set("red"). That's not a React state update; the store just changes its variable (and calls its listeners).
  3. UnsafeCells rendered before the change read "blue"; those rendered after read "red". Nothing tells React the earlier cells are now wrong, so it commits the mix: the test counts 14 blue and 36 red cells on one screen, in every run. That's tearing.
  4. SafeCell reads through useSyncExternalStore. React notices the snapshot no longer matches what it rendered with (via the listener and a check before commit), discards the inconsistent work and re-renders the cells synchronously with "red": the test sees 0 blue, 50 red.
  5. The cost: a store update during a transition makes React fall back to a blocking render for those components, so external-store updates are never "background" updates. That's the trade for consistency.

Who needs to care:

  • App code using React state (useState, useReducer, context): never tears.
  • Store libraries: must use useSyncExternalStore. Modern versions of react-redux (8+), Zustand (4+) and Jotai do; very old store bindings that read in render or subscribe in useEffect can tear under concurrent features.
  • Your own subscriptions to browser APIs (navigator.onLine, matchMedia, localStorage): wrap them in useSyncExternalStore (see useSyncExternalStore) instead of useState + useEffect, which also avoids a flash of the wrong value on mount.

Built from Rohit’s “React Interview concepts” doc. Examples target React 19.3 and are tested with Vitest and React Testing Library.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.