EmpireUI
Get Pro
← Blog7 min read#react#concurrent-mode#starttransition

React Concurrent Features in 2026: Practical startTransition Guide

startTransition, useDeferredValue, and Suspense have matured a lot. Here's how concurrent React actually works in 2026 — with real code, no fluff.

Code editor showing React concurrent features with colorful syntax highlighting on a dark background

What React Concurrent Mode Actually Means in 2026

Honestly, most developers still don't fully understand what "concurrent" means in the React context — and that's not their fault. The marketing around React 18 was muddy, and the React 19 release notes didn't help much either. Let's fix that.

Concurrent React means the renderer can work on multiple versions of your UI at the same time. It can start rendering an update, pause it, throw it away, or resume it later. The browser stays responsive because React yields back to the main thread periodically instead of blocking it for hundreds of milliseconds.

This isn't magic. It's a scheduling system. React 19.1 introduced more granular scheduler prioritization — urgent updates (typing, clicking) get rendered immediately, while non-urgent updates (search results, data fetching) can wait. The two main tools you'll use to tap into this are startTransition and useDeferredValue.

None of this works without the concurrent renderer, which you get automatically in React 18+ when you use createRoot. If you're still on ReactDOM.render, you're not getting any of this. Check your entry file first.

startTransition: Marking Updates as Non-Urgent

startTransition is the function you'll reach for most often. It wraps a state update and tells React: this update is not urgent, feel free to interrupt it if something more important comes in. The UI won't block while the transition is pending.

The most practical use case is filtering or sorting a large list while keeping input responsive. Without startTransition, typing into a search box that filters 5,000 items will feel laggy because every keystroke triggers a synchronous re-render of the entire list. With it, the input updates instantly and the list catches up.

import { useState, startTransition, useTransition } from 'react';

function ProductSearch({ products }: { products: Product[] }) {
  const [query, setQuery] = useState('');
  const [filtered, setFiltered] = useState(products);
  const [isPending, startTrans] = useTransition();

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
    setQuery(value); // urgent — updates input immediately

    startTrans(() => {
      // non-urgent — React can interrupt this
      setFiltered(
        products.filter(p =>
          p.name.toLowerCase().includes(value.toLowerCase())
        )
      );
    });
  }

  return (
    <div className="flex flex-col gap-3">
      <input
        value={query}
        onChange={handleChange}
        placeholder="Search products..."
        className="border rounded px-3 py-2 text-sm"
      />
      {isPending && (
        <span className="text-xs text-gray-400">Updating...</span>
      )}
      <ul>
        {filtered.map(p => <li key={p.id}>{p.name}</li>)}
      </ul>
    </div>
  );
}

Notice the isPending boolean from useTransition. That's your signal to show a loading indicator without hiding the stale content. Users see the old list with a subtle spinner rather than a blank screen — which feels much faster even when it isn't.

useDeferredValue vs startTransition: When to Use Which

Here's where developers get confused. Both useDeferredValue and startTransition defer work, but they're used in different situations. If you own the state setter, use startTransition. If you're receiving a prop or value from outside your component and can't wrap the update yourself, use useDeferredValue.

useDeferredValue takes a value and returns a deferred copy of it. React will render with the old value first, keep the UI interactive, then re-render with the new value when it has time. It's essentially the consumer-side equivalent of startTransition.

import { useDeferredValue, memo } from 'react';

// Wrap the expensive component in memo so React can bail out
const HeavyChart = memo(function HeavyChart({ data }: { data: DataPoint[] }) {
  // imagine this renders 2,000 SVG elements
  return <svg>{data.map(renderBar)}</svg>;
});

function Dashboard({ rawData }: { rawData: DataPoint[] }) {
  const deferredData = useDeferredValue(rawData);

  // stale check — show a visual hint when data is lagging
  const isStale = rawData !== deferredData;

  return (
    <div style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}>
      <HeavyChart data={deferredData} />
    </div>
  );
}

The memo wrapper is not optional here. Without it, useDeferredValue won't buy you anything because React can't skip the re-render of HeavyChart. Always pair useDeferredValue with memo on the deferred subtree.

If you're building component libraries — like we do with Empire UI — you'll find useDeferredValue shows up a lot in data-heavy components like tables, charts, and virtual lists.

Suspense in 2026: Data Fetching That Actually Works

Suspense for data fetching is no longer experimental. As of React 19, you can use it with any framework that supports it — Next.js 15+, Remix v3, and TanStack Start all ship Suspense-compatible data loaders. The mental model is simple: if a component isn't ready, it throws a Promise, and the nearest Suspense boundary catches it.

The tricky part is avoiding Suspense waterfalls. If three sibling components all suspend independently, they'll trigger three sequential loading states instead of one parallel fetch. The fix is to kick off all your fetches at the route level, pass the promises down, and let Suspense handle the coordination.

// app/dashboard/page.tsx (Next.js 15 App Router)
import { Suspense } from 'react';
import { UserStats, RecentOrders, ActivityFeed } from './components';

export default async function DashboardPage() {
  // Fire all requests in parallel — don't await here
  const statsPromise = fetchUserStats();
  const ordersPromise = fetchRecentOrders();
  const activityPromise = fetchActivityFeed();

  return (
    <div className="grid grid-cols-3 gap-6">
      <Suspense fallback={<StatsSkeleton />}>
        {/* Each component awaits its own promise */}
        <UserStats promise={statsPromise} />
      </Suspense>
      <Suspense fallback={<OrdersSkeleton />}>
        <RecentOrders promise={ordersPromise} />
      </Suspense>
      <Suspense fallback={<ActivitySkeleton />}>
        <ActivityFeed promise={activityPromise} />
      </Suspense>
    </div>
  );
}

This pattern — parallel data fetching with individual Suspense boundaries — is what Next.js calls "streaming". Each section loads independently as its data arrives. The user sees content progressively instead of waiting for the slowest request.

Transitions with Routing: Keeping Navigation Snappy

Page transitions are one of the best places to apply startTransition. When a user clicks a nav link, the current page should stay interactive while the next page's data loads. Without transitions, you get a blank screen or a jarring flash. With them, you can show a progress indicator while React prepares the new route in the background.

Next.js 15 wraps router navigation in transitions automatically when you use the App Router. But if you're managing routing yourself — or using a custom client-side router — you need to do it manually. The pattern is straightforward: intercept the navigation, wrap the route state update in startTransition, show a pending state.

This is also why you'll see useTransition showing up in navigation components a lot. If you want a polished feel, combine it with a thin progress bar at the top of the page. Empire UI ships a NavigationProgress component that hooks into this pattern — no third-party dependency needed.

One thing worth flagging: don't wrap every state update in startTransition. It's for genuinely non-urgent updates. Wrapping form submissions or button click feedback in transitions will make your UI feel unresponsive — the interaction won't update immediately. Reserve it for bulk rendering work, search filtering, and navigation.

Error Boundaries and Concurrent Rendering

Concurrent rendering changes how errors behave. When React renders speculatively (in the background), it might hit an error in a component before that render is committed to the screen. You need Error Boundaries to catch these gracefully — and in 2026 you're probably wrapping every Suspense boundary with one anyway.

The unfortunate truth is that Error Boundaries still require class components in vanilla React. Most teams use a small wrapper or a library like react-error-boundary (v5.0+) to avoid writing class components just for error handling. If you're building an app with concurrent data fetching and you don't have Error Boundaries set up, you're going to see some really confusing blank screens in production.

import { ErrorBoundary } from 'react-error-boundary';
import { Suspense } from 'react';

function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
  return (
    <div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm">
      <p className="text-red-700 font-medium">Something went wrong</p>
      <p className="text-red-500 mt-1">{error.message}</p>
      <button
        onClick={resetErrorBoundary}
        className="mt-3 text-xs underline text-red-600"
      >
        Try again
      </button>
    </div>
  );
}

function SafeWidget({ children }: { children: React.ReactNode }) {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <Suspense fallback={<div className="h-24 animate-pulse bg-gray-100 rounded-lg" />}>
        {children}
      </Suspense>
    </ErrorBoundary>
  );
}

Pair this pattern with proper toast notifications for non-blocking error feedback. Error boundaries handle the component tree, toasts handle background operations that fail silently.

Performance Profiling Concurrent React Apps

How do you actually know if your transitions are working? React DevTools 6.x (shipping with React 19) has a Profiler tab that shows you transition lanes. You'll see which renders were scheduled as transitions, how long they took, and whether React interrupted any of them. It's genuinely useful — spend 20 minutes with it on a real interaction.

The metrics to watch are: time-to-first-byte of the transition update (should be near-instant), total render duration (less critical since it doesn't block), and input latency (should stay under 100ms even during heavy renders). Chrome's Performance panel also shows "Long Animation Frame" entries — these are your enemy, and transitions should help eliminate them.

If you're seeing transitions that still block, the usual culprits are: synchronous work inside the transition callback that's too heavy for a single frame, missing memo on expensive children, or third-party libraries that call setState outside of React's scheduler. Libraries that use setTimeout or native event listeners to trigger re-renders bypass the concurrent scheduler entirely.

For a deeper look at profiling patterns and bundle-level performance, the React performance guide covers what to measure before you optimize. Concurrent features are one tool — they're not a substitute for fixing slow components. Do you really need to re-render 5,000 items, or can you virtualize the list first?

Putting It Together: A Real-World Pattern

Let's talk about a realistic scenario: a SaaS dashboard with a filterable table, live search, and a detail panel. Three things that all need to feel instant. This is where concurrent features earn their place.

The search input updates synchronously. The filtered table rows update inside a transition — isPending shows a subtle opacity shift (something like opacity: isPending ? 0.7 : 1 with a 150ms transition). The detail panel uses useDeferredValue on the selected row ID, so clicking a row feels instant even if the detail pane takes 80ms to render. Suspense wraps any async data inside the panel.

This approach also works well with UI component libraries. Empire UI's table components support a pending prop that applies the right visual treatment. You can also check how concurrent patterns interact with animation — if you're doing glassmorphism effects or particle backgrounds, see what glassmorphism is and particles in React for components that stay performant under concurrent rendering.

The goal isn't to use every concurrent API everywhere. It's to identify where your app feels sluggish, understand whether the bottleneck is render time or data fetching, and pick the right primitive. startTransition for heavy renders. useDeferredValue for expensive subtrees driven by props. Suspense for async data. They're each solving a specific problem — not a one-size-fits-all solution.

FAQ

Does startTransition work with React Query or SWR mutations?

Not directly. startTransition only defers React state updates — it doesn't affect network requests. What you can do is wrap the state setter that receives the mutation result inside startTransition, so the UI update that follows the mutation is treated as non-urgent. The fetch itself still runs at normal priority.

Can I use startTransition inside a useEffect?

You can, but it's usually wrong. If you're in a useEffect, you're responding to something that already happened — the render has already committed. startTransition is for marking pending state updates before they render. Put the startTransition call in your event handler, not in the effect.

Why does useDeferredValue need memo to work?

Because without memo, React can't bail out of re-rendering the child component. useDeferredValue gives you a stale value on the first render, but if the child always re-renders regardless, you're not skipping any work. memo lets React compare the previous and next props — if the deferred value hasn't changed yet, it skips the render entirely.

Is Suspense for data fetching stable in React 19?

Yes, as of React 19.0. The API is stable and supported in Next.js 15, Remix v3, and TanStack Start. The catch is that your data fetching layer needs to integrate with the Suspense protocol — either by throwing Promises or by using a framework/library that does it for you. Plain fetch() inside useEffect does not trigger Suspense.

What's the difference between useTransition and startTransition?

useTransition is a hook that returns [isPending, startTransition]. You use it inside a component when you need the isPending boolean to show loading state. The standalone startTransition import is the same function but without the pending state — use it outside of components or when you don't need to show a loading indicator.

Will wrapping everything in startTransition improve performance?

No, and it can actually make things feel worse. startTransition defers updates, which means users see stale UI for longer. Use it only when the update is genuinely non-urgent — large list filtering, heavy re-renders, background route preparation. Form inputs, button feedback, and modal open/close should always be synchronous updates.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

React Suspense Boundaries: Where to Put Them and WhyReact Compiler in 2026: Auto-Memoization and What It ChangesServer-Side Streaming in React + Next.js: Suspense and RSCParallax Scroll Sections in React: Performance-First Approach