#Next.js#React

React Concurrent Features in Depth: Suspense, Transitions, and useDeferredValue

React 18+ concurrent rendering is not multithreading; it is interruptible rendering. Learn Suspense for declarative loading states, useTransition for low-priority updates that do not block input, useDeferredValue for deferring expensive work, and how they keep React apps responsive.

Key Points

  • Topic Focus: Next.js、React
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

Anyone who has worked with React for a few years has probably encountered this: a search box makes the page stutter for half a second while the user types; switching a table with tens of thousands of rows freezes the entire interface after a click; navigating to a route produces a blank loading screen where even the Back button cannot be clicked.

These issues have the same root cause: React rendering used to be synchronous and non-interruptible. Once it began reconciling a large tree, it occupied the main thread until completion, while user input, clicks, and animations all waited in line.

React 18 made "Concurrent Features" first-class to solve this problem. But misunderstandings in the community remain common: some people think React became multithreaded, some treat useTransition as a universal remedy and wrap it around everything, and some put <Suspense> around the root node and then wonder why waterfalls still happen.

This article explains it all at once: what concurrent rendering actually changes, the boundaries of the three core APIs, Suspense / useTransition / useDeferredValue, how they work together, and when you should not use them.


1. The Essence of Concurrent Rendering: Not Multithreading, but Interruptible Rendering

First, clear up the most fundamental misunderstanding.

React concurrent rendering is not multithreaded rendering. React rendering still runs on the JS main thread and remains single-threaded. It does not send workers to paint components in parallel, nor does it move reconciliation to another thread.

So where is the "concurrency"? It is in scheduling. Specifically, it provides three things:

  1. Interruptible: while rendering a tree, React can pause the current render if a higher-priority task arrives, such as user input.
  2. Resumable: after handling the higher-priority task, React can continue from where it paused instead of starting again from scratch.
  3. Priority scheduling: different updates have different priorities. User input is high priority (ContinuousEvent / UserBlocking), while data fetching and batched setState results are lower priority (Default / Idle).

The foundation for all of this was laid by the Fiber architecture in React 16. Fiber breaks a component tree into interruptible units of work, and every fiber node can pause / resume. But in the React 16/17 era, this mechanism only surfaced in narrow places such as shouldComponentUpdate / unstable_batchedValues; ordinary developers could not really access it.

In React 18, concurrent mode changed from opt-in unstable_ConcurrentMode to enabled by default, and the accompanying APIs, createRoot, Suspense data fetching, useTransition, useDeferredValue, and useSyncExternalStore, became truly usable.

An Intuitive Comparison

In the era of synchronous rendering, the following code stutters:

tsx
function Search() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  function onChange(e: React.ChangeEvent<HTMLInputElement>) {
    setQuery(e.target.value);
    // 假设 filterResults 是一个 200ms 的昂贵计算
    setResults(filterResults(e.target.value));
  }

  return (
    <>
      <input value={query} onChange={onChange} />
      <ResultList results={results} />
    </>
  );
}

On every keystroke, React must synchronously complete the whole sequence: "update input -> compute results -> render ResultList." For 200ms, the user cannot do anything. Type a little faster and the input feels noticeably sticky.

With concurrent rendering, React can say: "The input update for this controlled component is high priority, so do it first. The result-list update is low priority, so it can be interrupted and delayed." The input remains responsive, while the result list catches up in the background.

But this capability does not take effect automatically. You must communicate that intent to React through the following three APIs.


2. Suspense: Declarative Loading States

<Suspense> is the earliest and easiest-to-understand concurrent feature. It solves one problem: "this subtree is not ready yet, so show a placeholder first."

Core Mechanism: Throw a Promise

Suspense is not magic. Its essence is a convention: when data is not ready, a child component throws a promise. The nearest <Suspense> boundary catches that promise, renders fallback, and rerenders the subtree when the promise resolves.

tsx
import { Suspense } from 'react';

function App() {
  return (
    <Suspense fallback={<Skeleton />}>
      <Profile userId={1} />
    </Suspense>
  );
}

The advantage of this "declarative loading state" is that the component itself only cares whether data exists or not. Loading state is extracted from the component to the boundary, leaving cleaner logic.

2.1 With React.lazy for Code Splitting

This is the earliest and most reliable use case.

tsx
import { lazy, Suspense } from 'react';

const SettingsPage = lazy(() => import('./SettingsPage'));

function App() {
  return (
    <Suspense fallback={<PageLoader />}>
      <SettingsPage />
    </Suspense>
  );
}

When React.lazy loads a chunk, it throws a promise. Suspense shows the fallback, then rerenders after the chunk finishes downloading. From the code's perspective, the component is still written as a synchronous function, without conditional branches such as if (loading) return ....

2.2 With Data Fetching

This is Suspense's real design intent. The React team has long wanted data fetching to throw promises too, so loading state can be fully moved out of business components.

React Query, SWR, and Relay all support this pattern in the community. Using React Query as an example with suspense: true enabled:

tsx
function Profile({ userId }: { userId: number }) {
  // 这个 hook 内部在数据未就绪时 throw promise
  const { data } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });
  return <div>{data.name}</div>;
}

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <Profile userId={1} />
    </Suspense>
  );
}

The component code has no loading branch at all, leaving business logic very clean. But there is a cost: you must hand loading state to the outer Suspense boundary. If the boundary is placed incorrectly, the experience becomes a full-page blank loading screen. Boundary placement is discussed below.

2.3 Nested Suspense: Avoid Waterfalls and Allow Parallelism

This is the easiest place to make mistakes in real projects. Consider the difference between these two snippets:

Waterfall (bad):

tsx
<Suspense fallback={<PageSpinner />}>
  <Header />
  <Suspense fallback={<BodySpinner />}>
    <Body />
    <Suspense fallback={<SidebarSpinner />}>
      <Sidebar />
    </Suspense>
  </Suspense>
</Suspense>

If you place all Suspense boundaries together but send data requests from child useEffect hooks, following the typical "request only after the component mounts" pattern, a waterfall forms: parent data returns -> child mounts -> child sends a request -> wait -> grandchild mounts, and so on. Every level waits serially.

Parallel (good):

tsx
function App() {
  return (
    <Suspense fallback={<PageSpinner />}>
      <Header />
      <Suspense fallback={<BodySpinner />}>
        <Body />
      </Suspense>
      <Suspense fallback={<SidebarSpinner />}>
        <Sidebar />
      </Suspense>
    </Suspense>
  );
}

If Body and Sidebar requests are triggered at the same time, for example through React Query prefetching or parallel declarations in a data-fetching framework, these two Suspense boundaries resolve in parallel. Whichever returns first is displayed first, without blocking the other.

This is Suspense's greatest value: it makes parallel loading a default declarative structure without requiring you to write Promise.all manually.

2.4 Suspense in Streaming SSR

If you use Next.js with the App Router or Remix, Suspense also handles streaming SSR. The server does not need to wait until all data has arrived before returning HTML. When it encounters <Suspense>, it sends the fallback first, then injects the real content through the same HTTP stream as a <script> once data is ready.

The result is a very fast initial TTFB. Slow components progressively fill in later, and users do not have to stare at a blank screen.

tsx
// Next.js App Router 里,每个 page.tsx 本身就在一个 Suspense 边界里
export default function Page() {
  return (
    <>
      <Hero /> {/* 立即可用 */}
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments /> {/* 数据慢,流式补全 */}
      </Suspense>
    </>
  );
}

Suspense Summary

  • Value: move loading state from inside components to boundaries, allowing business components to return to "pure rendering."
  • Uses: React.lazy (the most reliable option), data-fetching frameworks (React Query/SWR/Relay), and streaming SSR.
  • Key decision: where the boundary goes determines the experience. Too far out and the entire page becomes blank; too far in and waterfalls appear.
  • Do not forget: Suspense is not a performance optimization. It is a semantic abstraction for loading states.

3. useTransition: Mark Heavy Updates as Low Priority

useTransition is the most practical concurrent feature. Its role is: allow a state update to be interrupted so it does not block high-priority interactions.

The API Looks Like This

tsx
const [isPending, startTransition] = useTransition();

function onChange(e) {
  setQuery(e.target.value); // 高优先级:受控输入必须立即生效
  startTransition(() => {
    setResults(filterEverything(e.target.value)); // 低优先级:结果可以延迟
  });
}

The setState wrapped in startTransition is marked as a transition, with priority lowered below Default. During rendering, if a higher-priority update arrives, such as the user continuing to type or clicking a button, React discards this transition render and starts again after processing the higher-priority work.

Notice the word "discards": it is the key. In synchronous rendering, once rendering begins, it must finish, even if the user has already typed five more characters. In transition mode, those invalidated intermediate results are never committed.

A Complete Search-Box Example

This is the classic useTransition scenario:

tsx
import { useState, useTransition, useMemo } from 'react';

interface Item {
  id: string;
  name: string;
}

export function SearchBox({ items }: { items: Item[] }) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<Item[]>(items);
  const [isPending, startTransition] = useTransition();

  function onChange(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
    setQuery(value); // 高优先级:输入框立即更新
    startTransition(() => {
      // 低优先级:把昂贵的过滤标记为 transition
      setResults(items.filter((i) => i.name.includes(value)));
    });
  }

  return (
    <div>
      <input value={query} onChange={onChange} />
      {isPending && <span className="text-gray-400">过滤中…</span>}
      <ul>
        {results.map((r) => (
          <li key={r.id}>{r.name}</li>
        ))}
      </ul>
    </div>
  );
}

Compared with the laggy version at the beginning, the input remains responsive while the result list catches up in the background. You can also use isPending to show a subtle loading indicator that tells users the results are still being calculated.

3.1 The Fundamental Difference from debounce

This is commonly asked both in interviews and in practice.

debounce uses a timer to delay execution. Regardless of whether the main thread is busy, it waits N ms before executing, updating only after the user pauses.

useTransition does not delay. It starts rendering immediately, but allows the work to be interrupted. If the main thread is idle, it runs immediately. If the user keeps typing, it catches up: every keystroke starts a new transition and old ones are discarded.

The practical trade-off is:

  • For network requests such as data fetching, use debounce. You do not want to call an API for every character.
  • For pure CPU work such as expensive local calculations, use useTransition. It can finish as soon as the main thread is idle, which feels more responsive than debounce's fixed delay.

The two can also be combined: debounce API requests and use useTransition for local filtering. That is the mature solution.

3.2 The Boundary of useTransition

startTransition should contain only state updates (setState). Putting an expensive synchronous calculation directly inside it does not help, because the calculation itself blocks synchronously and React cannot control it. The correct approach is to commit the calculated result through setState, allowing React to schedule that commit.

tsx
// 错:昂贵计算直接放在 transition 里,仍然阻塞主线程
startTransition(() => {
  const r = heavyCompute(value); // 这一步同步执行,不受 transition 影响
  setResults(r);
});

// 对:把计算挪到 setState 里,或者用 useMemo + useDeferredValue 配合

This is the most common misuse in the community. startTransition does not "make this code asynchronous". It only makes this setState low priority. The code itself still executes synchronously.

useTransition Summary

  • Value: make heavy state updates interruptible and protect high-priority interactions such as input, clicks, and animations.
  • Suitable for: updates driven by expensive local calculations, such as search filtering, list sorting, and tab switches to large data views.
  • Not suitable for: network requests, where debounce or a caching framework should be used, or pure synchronous calculations, where the result should first be stored in state.
  • Pair it with isPending to give users feedback.

4. useDeferredValue: Delay the Propagation of a Value

useDeferredValue is the mirror image of useTransition. It solves the same problem from the perspective of a value.

API

tsx
const deferredQuery = useDeferredValue(query);

When query changes, deferredQuery does not necessarily change immediately. React asks: is the current render too busy? If it is, React keeps the old value and defers propagating the new value until a later idle render.

That may sound abstract, but the example makes it clear.

Large Data-Table Example

tsx
import { useState, useDeferredValue, useMemo } from 'react';

interface Row {
  id: string;
  /* ...很多字段 */
}

export function BigTable({ rows }: { rows: Row[] }) {
  const [filter, setFilter] = useState('');
  const deferredFilter = useDeferredValue(filter);

  // 昂贵的计算依赖的是 deferredFilter,而不是 filter
  const filtered = useMemo(
    () => rows.filter((r) => matches(r, deferredFilter)),
    [rows, deferredFilter]
  );

  const isStale = filter !== deferredFilter;

  return (
    <div>
      <input
        value={filter}
        onChange={(e) => setFilter(e.target.value)}
      />
      <table className={isStale ? 'opacity-60' : ''}>
        <tbody>
          {filtered.map((r) => (
            <Row key={r.id} data={r} />
          ))}
        </tbody>
      </table>
    </div>
  );
}

The essence here is: the input uses the "new" filter, while the table uses the "old" deferredFilter. The input always responds immediately, while the table's filtering calculation can be delayed and interrupted. The isStale marker lets the UI express that the table is catching up.

4.1 useTransition vs useDeferredValue: Which Should You Choose?

They solve the same class of problem from different perspectives:

DimensionuseTransitionuseDeferredValue
Perspective"This setState is low priority""Propagation of this value can be delayed"
Trigger pointThe producer of setStateThe consumer of a value
When to useYou control where setState is calledYou can only receive a value, such as one from props or a third-party store
Built-in supportIncludes isPendingCompare value !== deferredValue yourself to get stale state
Suitable componentThe component that calls setStateA child component or one that receives a prop

A practical rule of thumb:

  • You can change the line that calls setState -> use useTransition.
  • The value comes from props or an external source and you cannot change its origin -> use useDeferredValue.

For example, if you encapsulate a DataTable and receive query from the outside, you cannot wrap the outer source in startTransition. Calling useDeferredValue(query) inside DataTable is the cleanest solution.

4.2 Difference from debounce/throttle

Like useTransition:

  • debounce/throttle are time-based. They introduce a fixed delay regardless of the main thread's state.
  • useDeferredValue is scheduling-based. It updates immediately when the main thread is idle and defers when it is busy.

For local calculations, useDeferredValue usually feels better because it does not introduce an artificial delay and users see results immediately when they stop typing.

useDeferredValue Summary

  • Value: delay expensive calculations from the value consumer side and protect input responsiveness.
  • Suitable for: table filtering, chart redraws, and complex derived calculations that depend on props.
  • Complementary to useTransition: use transition when you control the producer, and deferred when you control the consumer.
  • Do not forget to pair it with useMemo, or you defer value propagation without caching the calculation and gain nothing.

5. How the Three Work Together, and When Not to Use Them

At this point, you should see that these three APIs are not isolated. They all approach one goal from different angles: make expensive rendering interruptible.

5.1 Typical Combinations

Suspense + useTransition is a golden pairing. Consider this route-switching example, suitable for the Next.js App Router or a custom router:

tsx
function App() {
  const [tab, setTab] = useState('home');
  const [isPending, startTransition] = useTransition();

  function switchTab(next: string) {
    startTransition(() => {
      setTab(next);
    });
  }

  return (
    <div>
      <Nav active={tab} onSwitch={switchTab} isPending={isPending} />
      <Suspense fallback={<RouteSpinner />}>
        {isPending ? <RouteSpinner /> : renderTab(tab)}
      </Suspense>
    </div>
  );
}

When switching tabs, the new tab component may be fetching data and throwing a promise. With Suspense alone, the whole region immediately falls back to a spinner when the tab changes, which feels visually abrupt. Once setTab is wrapped in startTransition, the old tab remains visible until the new tab's data is ready, making the transition much smoother. isPending can also let Nav show a subtle loading state.

This is the routing-switching approach recommended by React, and why router.push in the Next.js App Router is a transition by default.

useDeferredValue + useMemo is the standard structure for large-data components, as shown above.

5.2 A Complete Practical Example: Real-Time Search + Route Switching

Combine all three in one real component:

tsx
function ProductSearch({ query }: { query: string }) {
  // query 来自 props(比如 URL search param),源头不可控 → deferred
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  // useMemo 锁住昂贵计算
  const results = useMemo(
    () => expensiveFilter(deferredQuery),
    [deferredQuery]
  );

  return (
    <div className={isStale ? 'opacity-70' : ''}>
      <Suspense fallback={<GridSkeleton />}>
        <ProductGrid products={results} />
      </Suspense>
    </div>
  );
}

Here, useDeferredValue protects input responsiveness, useMemo caches the calculation, and Suspense covers child components that may throw a promise, such as images lazily loaded inside ProductGrid. Each has its own responsibility.

5.3 Trade-Offs: Do Not Use Them Everywhere

All three APIs have a cost. It is not a performance cost, but a cognitive cost. Each added startTransition / useDeferredValue requires readers to think one layer further: is this value new or old, and is this update delayed?

Use these criteria to decide whether they are warranted:

  1. Is it actually lagging? Record it with the React DevTools Profiler and inspect render time. For updates under 100ms, these APIs are unnecessary most of the time. React's own batching is already sufficient.
  2. Is rendering the root cause? If network requests are the bottleneck, use debounce plus caching. If a massive synchronous calculation is the problem, such as one million pure CPU operations, React's concurrent scheduler cannot save you. Use a Web Worker or OffscreenCanvas.
  3. Does adding it make the code harder to read? If 80% of the team cannot understand the purpose of a transition, its cost exceeds its benefit.

In my experience, these two hooks are needed in 5% of components. For the other 95%, use debounce where debounce is appropriate, caching where caching is appropriate, and component splitting where splitting is appropriate. Treating concurrent APIs as a silver bullet is one of the most common forms of over-engineering among newcomers.


6. Three Typical Practical Scenarios

Scenario 1: Real-Time Search (useTransition)

Requirement: users type in an input while the result list below filters in real time. There are 10,000 items and filtering happens locally.

Key points:

  • Use query for the input's value as a high-priority value so it remains responsive.
  • Wrap filtered results in startTransition to mark them as low priority.
  • Use isPending for a subtle loading indicator so users do not think the interface has frozen.

See the code in Chapter 3. If the dataset becomes larger, around 100,000 rows, add useMemo + Web Worker and move filtering off the main thread. The transition should only make the result-commit step interruptible.

Scenario 2: Large Data Table (useDeferredValue + useMemo)

Requirement: a table displays thousands of rows. Users enter filters, and table reordering and rerendering are slow.

Key points:

  • filter is the state of a controlled input.
  • Use deferredFilter = useDeferredValue(filter), and let the table use the deferred version.
  • Make useMemo depend on deferredFilter, or deferring has no purpose.
  • Use an isStale condition for visual de-emphasis, such as opacity or a skeleton shimmer, to tell users that the table is catching up.

See the code in Chapter 4. If rendering rows is also expensive because each row contains complex components, add a virtual list such as react-virtual or TanStack Virtual. Concurrent features optimize whether rendering can be interrupted, while virtual lists optimize total render volume. They do not conflict and work well together.

Scenario 3: Route-Level Suspense (Next.js App Router)

Requirement: navigate with the App Router to a data-heavy page while keeping the initial screen fast and the transition smooth.

Key points:

  • The Next.js App Router wraps every page.tsx in a Suspense boundary by default, and loading.tsx is the fallback. You do not need to write this yourself.
  • Wrap slow child components separately inside the page so fast content appears first.
  • For programmatic navigation, wrap useRouter().push in startTransition, or use useTransition together with Link prefetching, so the old page stays visible until the new one is ready.
tsx
// app/dashboard/page.tsx
export default function DashboardPage() {
  return (
    <>
      <Header /> {/* 立即可用 */}
      <Suspense fallback={<ChartSkeleton />}>
        <SlowChart /> {/* 数据获取慢,流式补全 */}
      </Suspense>
      <Suspense fallback={<TableSkeleton />}>
        <DataTable /> {/* 与图表并行加载 */}
      </Suspense>
    </>
  );
}

With App Router + Streaming SSR, this page's HTML returns in chunks: the Header arrives first, then Chart and Table each fill in when their data is ready. Users see initial content much sooner than with traditional SSR that waits for all data.


7. Common Misuses and Pitfalls

Finally, here are several issues that repeatedly come up in code review.

7.1 Treating startTransition as "Making It Async"

tsx
// 错
startTransition(async () => {
  const data = await fetch('/api/x');
  setData(data);
});

The callback passed to startTransition should not contain await. It only cares about synchronously executed setState calls. If setState happens after asynchronous code, it is already after a microtask and the transition marker is lost. The correct approach is:

tsx
// 对
fetch('/api/x').then((data) => {
  startTransition(() => {
    setData(data);
  });
});

Wrap the transition around setState, not around the entire asynchronous operation.

7.2 Putting a Suspense Boundary at the Root

tsx
// 错
<Root>
  <Suspense fallback={<FullPageSpinner />}>
    <Header />
    <Nav />
    <Main />
    <Footer />
  </Suspense>
</Root>

If any child component throws a promise, the entire page becomes a spinner. Even immediately available parts such as Header and Nav are dragged down with it.

A Suspense boundary should sit at a level where the loading state has independent meaning, such as a widget, panel, or route section. The finer the granularity, the more parallelism you get and the better the experience.

7.3 Mixing a Controlled Input's State into a Transition

tsx
startTransition(() => {
  setQuery(value);  // 这个也会变成低优先级!
  setResults(filter(value));
});

If you wrap the controlled input's state in a transition too, the input becomes sticky because React treats it as low priority and may delay committing it. This directly cancels out the optimization you wanted.

Remember: the state of controlled components should never go into a transition.

7.4 Using useDeferredValue Without useMemo

tsx
const deferred = useDeferredValue(query);
// 每次都重算,deferred 没有任何意义
const results = heavyFilter(deferred);

useDeferredValue delays the propagation of a value. But if you do not cache the derived computation, React still calculates it, just a little later. You must pair it with useMemo, or move the computation into setState, for the delay to deliver a real benefit.

7.5 Overuse

This is the most subtle problem. Some teams learn concurrent features and then wrap every useState in startTransition and pass every prop through useDeferredValue. The result is implicit assumptions everywhere that "this value might be old," and even the team cannot tell which render used the new value when debugging.

Concurrent APIs are scalpels, not salt. Use them only where the Profiler actually shows a rendering bottleneck. Elsewhere, write straightforward synchronous code. Readability matters far more than being "theoretically better."

7.6 Forgetting useSyncExternalStore

If you use non-React state management such as zustand, Redux, or Jotai, concurrent mode can cause "tearing": different components can read store values from different moments during the same render. This is a side effect of interruptible concurrent rendering.

The solution is not to avoid these libraries. Confirm that they use useSyncExternalStore to subscribe to external stores. Mainstream libraries have migrated since 2022. When writing your own external-store integration, use useSyncExternalStore as well; do not hand-roll a subscription pattern with useEffect + setState.


Summary

Here are the key points to take away:

  1. Concurrent rendering is not multithreading. It is a rendering-scheduling model with "interruptible + resumable + priority scheduling," running on single-threaded JS and backed by the Fiber architecture.
  2. Suspense is an abstraction for declarative loading states. It does not solve whether loading is fast; it solves whether loading states are expressed cleanly. Boundary placement determines parallelism.
  3. useTransition marks updates as low priority to protect high-priority interactions such as controlled input. It affects only setState; it is not asynchronous magic.
  4. useDeferredValue delays value propagation from the consumer side, for situations where you cannot change the source setState, such as a value received through props. Always pair it with useMemo.
  5. Using all three together is better than using one alone. Suspense + transition for route switching and deferred + useMemo for large tables are proven combinations.
  6. Do not use them everywhere. Use them only when the Profiler shows a real bottleneck. Use caching for network bottlenecks and Workers for CPU bottlenecks. Concurrent APIs solve only one thing: making rendering interruptible.

Key takeaway: the essence of concurrent features is moving the decision of "when to render and how far to render" from your code into React's scheduler. You communicate intent, such as "this update can be slower" or "this value can be old," and it chooses the timing. It does not make code faster; it keeps code from blocking when it is not fast. That is React 18's real gift.


After reading this article, you should be able to look at concurrent-related code in any React project and immediately distinguish real optimization from "wrapping it in a transition just to look advanced." The latter is far more common. Using tools with restraint is closer to seniority than merely knowing how to use them.

Related

Related Content