MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

React 18: Suspense, Transitions, and Concurrent Features

Master React 18: Suspense, useTransition, useDeferredValue, and streaming SSR.

ReactReact 18ConcurrentFrontend

By MinhVo

Introduction

React 18 represents the most significant architectural change since React Hooks in version 16.8. The introduction of Concurrent React fundamentally changes how React renders your UI, enabling it to prepare multiple versions of the screen simultaneously, prioritize urgent updates over non-urgent ones, and stream server-rendered HTML to the client. These features are not just incremental improvements — they represent a paradigm shift in how we think about UI responsiveness.

Before React 18, all state updates were processed synchronously and with equal priority. When you typed in a search field, React would update the input immediately, but if updating the search results took time, the entire UI would freeze. Concurrent React solves this by making rendering interruptible — React can pause a low-priority update to handle a high-priority one, then resume where it left off.

React 18 Concurrent Features

In this comprehensive guide, we will explore every major feature in React 18: the new root API, Suspense for data fetching, useTransition for non-blocking updates, useDeferredValue for expensive computations, and streaming SSR with Suspense boundaries. You will understand not just how to use these features, but when and why they matter.

Understanding React 18: Core Concepts

Concurrent Rendering: The Foundation

The core innovation in React 18 is concurrent rendering. In React 17 and earlier, rendering was synchronous — once React started rendering a component tree, it could not be interrupted. If a component took 200ms to render, the entire UI was blocked for 200ms.

Concurrent rendering makes rendering interruptible. React can start rendering an update, pause if a more urgent update arrives (like a user typing), handle the urgent update, and then resume the original render. This happens automatically — you do not need to manage the interruption yourself.

The key insight is that React now maintains multiple versions of the UI state simultaneously. It can prepare a new screen in the background while keeping the current screen interactive. When the new screen is ready, React swaps it in seamlessly.

Opting In to Concurrent Features

Concurrent rendering is opt-in. Your existing React 17 code works identically in React 18. You opt in by using concurrent features:

  • createRoot() instead of ReactDOM.render()
  • useTransition() for non-blocking state updates
  • useDeferredValue() for deferred re-renders
  • <Suspense> for declarative loading states
  • Server-side: renderToPipeableStream() instead of renderToString()

The New Root API

// React 17 (legacy, still works but triggers warnings)
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, document.getElementById('root'));
 
// React 18 (concurrent mode)
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'));
root.render(<App />);

The new createRoot API enables concurrent features. If you use the legacy ReactDOM.render, React 18 behaves like React 17 — no concurrent features, no automatic batching.

Architecture and Design Patterns

Automatic Batching

React 18 extends automatic batching to all contexts, including setTimeout, Promise, native event handlers, and any other code. In React 17, batching only happened inside React event handlers.

// React 17: two re-renders (one per setState)
setTimeout(() => {
  setCount(c => c + 1);  // Re-render #1
  setFlag(f => !f);       // Re-render #2
}, 1000);
 
// React 18: one re-render (batched automatically)
setTimeout(() => {
  setCount(c => c + 1);  // Both batched
  setFlag(f => !f);       // into one re-render
}, 1000);
 
// Opt out of batching with flushSync (rare)
import { flushSync } from 'react-dom';
flushSync(() => {
  setCount(c => c + 1);
});
// DOM is updated here
flushSync(() => {
  setFlag(f => !f);
});
// DOM is updated again here

Suspense Boundaries

Suspense lets you declaratively specify loading states for parts of your component tree. When a child component is "waiting" for something (data, code, images), Suspense shows a fallback instead.

import { Suspense, lazy } from 'react';
 
const HeavyComponent = lazy(() => import('./HeavyComponent'));
 
function App() {
  return (
    <div>
      <h1>My App</h1>
      <Suspense fallback={<div>Loading component...</div>}>
        <HeavyComponent />
      </Suspense>
    </div>
  );
}

Suspense for Data Fetching

In React 18, Suspense works with data fetching through frameworks that support it (Next.js, Relay). The pattern uses a "suspend" mechanism — when a component tries to read data that is not yet available, it throws a Promise. React catches the Promise, shows the nearest Suspense fallback, and resumes rendering when the Promise resolves.

// Using Suspense with a data fetching library (e.g., SWR or React Query)
function UserProfile({ userId }) {
  const user = use(fetchUser(userId)); // Throws Promise if not ready
 
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}
 
function App() {
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <UserProfile userId={1} />
    </Suspense>
  );
}

Step-by-Step Implementation

useTransition for Non-Blocking Updates

useTransition lets you mark state updates as non-urgent. React keeps the current UI interactive while preparing the new state in the background.

import { useTransition, useState } from 'react';
 
function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();
 
  function handleSearch(e) {
    const value = e.target.value;
    setQuery(value);  // Urgent: update input immediately
 
    startTransition(() => {
      // Non-urgent: update results in background
      setResults(performExpensiveSearch(value));
    });
  }
 
  return (
    <div>
      <input value={query} onChange={handleSearch} />
      {isPending ? (
        <Spinner />
      ) : (
        <SearchResults results={results} />
      )}
    </div>
  );
}

The key behavior: when the user types, the input updates immediately (urgent). The search results update is deferred (non-urgent). If the user types again before the results finish computing, React abandons the old computation and starts a new one with the latest query. This eliminates the janky experience where the UI freezes during expensive computations.

useDeferredValue for Expensive Renders

useDeferredValue is similar to useTransition but works at the value level rather than the state setter level. It is useful when you do not control the state update (e.g., it comes from props).

import { useDeferredValue, useMemo } from 'react';
 
function SearchResults({ query }) {
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;
 
  const results = useMemo(() => {
    return filterAndSortResults(deferredQuery, 10000);
  }, [deferredQuery]);
 
  return (
    <div style={{ opacity: isStale ? 0.7 : 1 }}>
      {results.map(item => (
        <ResultCard key={item.id} item={item} />
      ))}
    </div>
  );
}
 
function SearchPage() {
  const [query, setQuery] = useState('');
 
  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
      />
      <SearchResults query={query} />
    </div>
  );
}

When the user types, query updates immediately, but deferredQuery lags behind. The expensive filterAndSortResults computation runs with the deferred value, keeping the input responsive. The visual indicator (isStale) tells the user that the results are being updated.

Streaming SSR with Suspense

React 18 introduces renderToPipeableStream for server-side rendering that streams HTML to the client progressively:

// server.js
import { renderToPipeableStream } from 'react-dom/server';
import { Writable } from 'stream';
 
app.get('/', (req, res) => {
  const { pipe, abort } = renderToPipeableStream(
    <App />,
    {
      bootstrapScripts: ['/client.js'],
      onShellReady() {
        // The shell (outside Suspense boundaries) is ready
        // Stream it to the client immediately
        res.statusCode = 200;
        res.setHeader('Content-Type', 'text/html');
        pipe(res);
      },
      onShellError(error) {
        // Fatal error — cannot render the shell
        res.statusCode = 500;
        res.send('<h1>Something went wrong</h1>');
      },
      onError(error) {
        console.error(error);
      }
    }
  );
 
  // Abort after timeout
  setTimeout(abort, 10000);
});
// App.js — Streaming with Suspense
function App() {
  return (
    <html>
      <body>
        <h1>My App</h1>
        <Suspense fallback={<SidebarSkeleton />}>
          <Sidebar />
        </Suspense>
        <Suspense fallback={<ContentSkeleton />}>
          <MainContent />
        </Suspense>
        <Suspense fallback={<CommentsSkeleton />}>
          <Comments />
        </Suspense>
      </body>
    </html>
  );
}

The server sends the shell HTML immediately, then streams each Suspense boundary's content as it becomes ready. The browser renders what it has and progressively fills in the rest. This dramatically improves Time to First Byte (TTFB) and Largest Contentful Paint (LCP).

React Performance Optimization

Real-World Use Cases

Use Case 1: Search with Instant Feedback

A product catalog search that filters thousands of items benefits enormously from useTransition. Without it, every keystroke triggers an expensive filter computation that blocks the UI. With useTransition, the input stays responsive while results update in the background.

Use Case 2: Tab Switching with Heavy Content

An analytics dashboard with multiple tabs (Charts, Tables, Reports) uses Suspense with lazy-loaded tab content. Switching tabs shows a skeleton immediately while the heavy component loads in the background.

Use Case 3: Progressive News Feed

A news application uses streaming SSR with Suspense boundaries. The article shell (header, navigation) streams immediately. The article body streams next. Comments and related articles stream last. Users see content within milliseconds instead of waiting for everything to render.

Use Case 4: Form with Optimistic Updates

A task management app uses useTransition to submit form data. The UI shows the new task optimistically while the server processes the request. If the server rejects it, React rolls back to the previous state.

Best Practices for Production

  1. Upgrade to createRoot immediately: The legacy ReactDOM.render triggers console warnings in React 18 and disables automatic batching outside event handlers. Switch to createRoot as your first step.

  2. Use useTransition for expensive state updates: If a state update triggers expensive re-renders (large lists, complex computations, heavy components), wrap it in startTransition. This keeps the UI responsive.

  3. Wrap lazy components in Suspense: Any component loaded with React.lazy() must be wrapped in a <Suspense> boundary with a meaningful fallback (skeleton, spinner, placeholder).

  4. Place Suspense boundaries strategically: Put them where loading states make sense visually. A boundary around the entire app shows a full-page spinner. Boundaries around individual sections show targeted loading states.

  5. Use useDeferredValue for derived expensive computations: When the expensive work is in a child component receiving props (not in a state setter), useDeferredValue is the right tool.

  6. Implement streaming SSR progressively: Start with the shell boundary, then add Suspense boundaries around the most expensive server-rendered components. Measure TTFB and LCP improvements.

  7. Test with concurrent features enabled: Some bugs only manifest under concurrent rendering. Use React's Strict Mode during development to catch issues early.

  8. Do not overuse transitions: Not every state update needs to be a transition. Use them specifically for updates that cause expensive re-renders or where the user would benefit from seeing the current UI while the new UI prepares.

Common Pitfalls and Solutions

PitfallImpactSolution
Using legacy ReactDOM.renderNo concurrent features, console warningsSwitch to createRoot
Wrapping every update in startTransitionUnnecessary overheadOnly use for expensive, non-urgent updates
Placing Suspense too high in the treeFull-page loading statesAdd boundaries at meaningful visual breakpoints
Not handling Suspense fallbacks properlyPoor loading UXUse skeleton screens, not blank spinners
Forgetting streaming SSR error handlingServer hangs on errorsUse onShellError and onError callbacks
Assuming useTransition makes code asyncConfusing mental modelIt defers rendering, not the code itself
Testing only in legacy modeMissing concurrent-specific bugsEnable Strict Mode in development

Performance Optimization

// Before: Expensive list blocks input
function FilterableList({ items }) {
  const [filter, setFilter] = useState('');
  const filtered = items.filter(item =>
    item.name.toLowerCase().includes(filter.toLowerCase())
  );
 
  return (
    <>
      <input onChange={e => setFilter(e.target.value)} />
      {filtered.map(item => <Item key={item.id} item={item} />)}
    </>
  );
}
 
// After: Input stays responsive with useTransition
function FilterableList({ items }) {
  const [filter, setFilter] = useState('');
  const [deferredFilter, setDeferredFilter] = useState('');
  const [isPending, startTransition] = useTransition();
 
  const filtered = useMemo(() =>
    items.filter(item =>
      item.name.toLowerCase().includes(deferredFilter.toLowerCase())
    ),
    [items, deferredFilter]
  );
 
  return (
    <>
      <input
        onChange={e => {
          setFilter(e.target.value);
          startTransition(() => setDeferredFilter(e.target.value));
        }}
      />
      <div style={{ opacity: isPending ? 0.6 : 1 }}>
        {filtered.map(item => <Item key={item.id} item={item} />)}
      </div>
    </>
  );
}

Comparison with Alternatives

FeatureReact 18 ConcurrentVue 3 ReactivityAngular SignalsSvelte Reactivity
Interruptible renderingYesNoNoNo
Automatic batchingYesYesYesYes
Suspense boundariesYes (data fetching)Yes (async components)No (manual)No
Streaming SSRYes (Suspense-based)Yes (unhead)Yes (Angular Universal)Yes (SvelteKit)
Transition APIuseTransition———
Deferred valuesuseDeferredValue———
Learning curveHigherModerateModerateLow

Advanced Patterns

Nested Suspense Boundaries for Progressive Loading

function Dashboard() {
  return (
    <div className="dashboard">
      <Suspense fallback={<HeaderSkeleton />}>
        <Header />
      </Suspense>
      <div className="content">
        <Suspense fallback={<SidebarSkeleton />}>
          <Sidebar />
          <Suspense fallback={<MetricsSkeleton />}>
            <MetricsPanel />
          </Suspense>
        </Suspense>
        <Suspense fallback={<ChartSkeleton />}>
          <Chart />
        </Suspense>
      </div>
    </div>
  );
}

useTransition with Error Boundaries

function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();
  const [error, setError] = useState(null);
 
  function handleSearch(e) {
    const value = e.target.value;
    setQuery(value);
    setError(null);
 
    startTransition(async () => {
      try {
        const data = await searchAPI(value);
        setResults(data);
      } catch (err) {
        setError(err.message);
      }
    });
  }
 
  return (
    <div>
      <input value={query} onChange={handleSearch} />
      {isPending && <Spinner />}
      {error && <ErrorMessage message={error} />}
      {!isPending && !error && <ResultsList results={results} />}
    </div>
  );
}

Testing Strategies

import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
 
test('input stays responsive during expensive filter', async () => {
  const items = Array.from({ length: 10000 }, (_, i) => ({
    id: i,
    name: `Item ${i}`
  }));
 
  render(<FilterableList items={items} />);
  const input = screen.getByRole('textbox');
 
  await act(async () => {
    await userEvent.type(input, 'Item 999');
  });
 
  expect(input.value).toBe('Item 999');
  expect(screen.getByText(/Item 999/)).toBeInTheDocument();
});
 
test('suspense shows fallback then content', async () => {
  render(<App />);
  expect(screen.getByText('Loading...')).toBeInTheDocument();
  expect(await screen.findByText('Loaded Content')).toBeInTheDocument();
  expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});

Concurrent Rendering Performance Patterns

Concurrent rendering in React 18 introduces new performance patterns that weren't possible with synchronous rendering. The useDeferredValue hook defers updates to non-critical parts of the UI, keeping the interface responsive while expensive computations run in the background. For example, a search input can update immediately while the results list defers its rendering, preventing input lag during filtering.

function SearchResults({ query }) {
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;
 
  return (
    <div style={{ opacity: isStale ? 0.7 : 1 }}>
      <ExpensiveList query={deferredQuery} />
    </div>
  );
}

The startTransition API marks state updates as non-urgent, allowing React to interrupt them if a more urgent update arrives. This is ideal for tab switches, filter changes, and navigation where the user expects immediate feedback on their interaction but can tolerate a brief delay on the resulting content update. Wrap the state update in startTransition to tell React that the update can be interrupted by more urgent interactions like typing or clicking.

Batching in React 18 extends to all event handlers, including timeouts, promises, and native events. Previously, state updates inside setTimeout or promise callbacks triggered separate re-renders. React 18 automatically batches these updates into a single re-render, reducing the total number of renders and improving performance. This automatic batching works in both concurrent and legacy rendering modes.

Error Boundaries and Suspense Integration

Error boundaries interact with Suspense in React 18 to provide comprehensive loading and error handling. Place an error boundary above a Suspense boundary to catch both rendering errors and data fetching errors. When a component inside Suspense throws during rendering or data fetching, the error boundary catches it and displays a fallback UI instead of crashing the entire application.

The useSuspenseQuery pattern from data fetching libraries like TanStack Query integrates directly with Suspense boundaries. Instead of managing loading states with isLoading conditions, the query throws a promise when data is pending, which Suspense catches and shows the fallback. When the data resolves, Suspense re-renders the component with the available data. This pattern eliminates conditional loading logic from components and centralizes loading states in Suspense boundaries.

For production applications, combine Suspense with streaming server-side rendering to progressively send HTML to the client. The server renders the shell immediately and streams in content as Suspense boundaries resolve. This delivers the fastest possible first contentful paint while still providing complete data for each component.

Future Outlook

React 18's concurrent features are the foundation for future React improvements. The React Compiler (React Forget) will automatically memoize components, making concurrent rendering even more effective. Server Components build on Suspense boundaries to reduce client-side JavaScript. The View Transitions API integration will enable smooth page transitions using the browser's native capabilities.

The direction is clear: React is moving toward a model where the framework handles performance optimization automatically, and developers focus on expressing what the UI should look like in each state.

Conclusion

React 18's concurrent features represent a fundamental shift in how React handles rendering. By making rendering interruptible and prioritizable, React 18 enables UIs that stay responsive even under heavy computational loads.

Key takeaways:

  1. Upgrade to createRoot — this is the gateway to all concurrent features
  2. Use useTransition for expensive updates — keep the UI responsive during heavy computations
  3. Use useDeferredValue for derived expensive work — when you do not control the state setter
  4. Implement Suspense boundaries strategically — at meaningful visual breakpoints, not at the root
  5. Adopt streaming SSR — for dramatically improved TTFB and LCP
  6. Leverage automatic batching — fewer re-renders by default, with flushSync to opt out when needed
  7. Test with concurrent features enabled — catch bugs that only appear under concurrent rendering

Concurrent React is not just a performance feature — it is a new mental model for building responsive UIs. Embrace it, and your applications will feel faster and more fluid than ever before.