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 Suspense and Concurrent Features

Master React Suspense: lazy loading, data fetching, transitions, and concurrent rendering.

ReactSuspenseConcurrentFrontend

By MinhVo

Introduction

React Suspense and Concurrent Features represent a paradigm shift in how we build user interfaces. Suspense lets components "wait" for something before rendering—whether it's code from a dynamic import, data from an API, or an image loading in the background. Combined with Concurrent Features like transitions, deferred values, and streaming server rendering, these tools enable developers to build fluid, responsive applications that feel instantaneous even on slow networks.

React Suspense

Introduced as experimental in React 16.6 and stabilized in React 18, Suspense fundamentally changes the relationship between components and their data dependencies. Instead of components managing their own loading states with useEffect and useState, Suspense lets the rendering engine handle loading states declaratively. This article explores every aspect of Suspense—from basic lazy loading to advanced concurrent patterns—and shows you how to leverage these features for production applications.

Understanding React Suspense: Core Concepts

What is Suspense?

At its core, Suspense is a mechanism that allows React to "suspend" rendering a component tree until some asynchronous operation completes. When a component needs data that isn't ready yet, it "throws" a promise—similar to how error boundaries catch thrown errors, Suspense boundaries catch thrown promises and display a fallback UI.

import { Suspense } from 'react'
 
function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <UserProfile />
      <UserPosts />
    </Suspense>
  )
}

The Suspense boundary wraps components that might need to wait. When any child suspends, the entire boundary shows the fallback. This is fundamentally different from managing loading states manually with useState—you don't need to track whether each component is loading, error, or ready.

The Mental Model

Think of Suspense like a restaurant kitchen. Without Suspense, each component is a waiter who takes orders, goes to the kitchen, waits, and brings food back. With Suspense, the kitchen sends a signal when food is ready, and the dining room (Suspense boundary) decides when to show the meal. Components focus on what they render, not the logistics of data loading.

Concurrent Rendering

Concurrent Rendering

Concurrent rendering is the engine that makes Suspense powerful. In legacy React (synchronous mode), rendering is blocking—once React starts rendering, it can't be interrupted. Concurrent mode allows React to start rendering, pause to handle more urgent updates (like user input), and resume later. This means high-priority updates never get stuck behind low-priority renders.

import { startTransition, useTransition } from 'react'
 
// Urgent update: user types in search
function handleInput(e) {
  setSearchQuery(e.target.value) // Urgent - updates input immediately
 
  startTransition(() => {
    setSearchResults(filterResults(e.target.value)) // Non-urgent - can be interrupted
  })
}

Architecture and Design Patterns

Suspense Boundaries

Suspense boundaries are analogous to error boundaries—they catch suspensions in their subtree and show fallback content. The key architectural decision is where and how many boundaries to place.

function App() {
  return (
    <>
      {/* Top-level boundary for the main content */}
      <Suspense fallback={<AppSkeleton />}>
        <Header />
        <MainContent />
      </Suspense>
 
      {/* Separate boundary for sidebar - independent loading */}
      <Suspense fallback={<SidebarSkeleton />}>
        <Sidebar />
      </Suspense>
 
      {/* Nested boundary for a specific section */}
      <Suspense fallback={<CommentsSkeleton />}>
        <CommentsSection />
      </Suspense>
    </>
  )
}

The Reveal-Typing Pattern

One powerful pattern is "reveal typing"—showing progressively more content as data arrives. Instead of waiting for everything to load, show what's ready and keep loading indicators for the rest.

function Dashboard() {
  return (
    <Suspense fallback={<FullPageSkeleton />}>
      {/* Header loads fast, reveals immediately */}
      <DashboardHeader />
 
      {/* Each widget has its own boundary */}
      <div className="grid">
        <Suspense fallback={<ChartSkeleton />}>
          <RevenueChart />
        </Suspense>
        <Suspense fallback={<ChartSkeleton />}>
          <UserMetrics />
        </Suspense>
        <Suspense fallback={<TableSkeleton />}>
          <RecentOrders />
        </Suspense>
      </div>
    </Suspense>
  )
}

SuspenseList for Coordination

SuspenseList orchestrates multiple Suspense boundaries, controlling the order and reveal strategy. While not yet stable in React 18, it's a crucial pattern for coordinated loading experiences.

// Future API (experimental)
<SuspenseList revealOrder="forwards" tail="collapsed">
  <Suspense fallback={<NotificationSkeleton />}>
    <Notification1 />
  </Suspense>
  <Suspense fallback={<NotificationSkeleton />}>
    <Notification2 />
  </Suspense>
  <Suspense fallback={<NotificationSkeleton />}>
    <Notification3 />
  </Suspense>
</SuspenseList>

The revealOrder prop controls the sequence:

  • "forwards": Reveal items in order, even if later items load first
  • "backwards": Reveal items in reverse order
  • "together": Wait for all items, then reveal simultaneously

Step-by-Step Implementation

Code Splitting with React.lazy

The most common Suspense use case is code splitting—loading components on demand instead of bundling everything upfront.

import { lazy, Suspense } from 'react'
 
// Dynamic imports - these create split points in your bundle
const AdminPanel = lazy(() => import('./AdminPanel'))
const Analytics = lazy(() => import('./Analytics'))
const Settings = lazy(() => import('./Settings'))
 
function App() {
  return (
    <Router>
      <Suspense fallback={<PageSkeleton />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/admin" element={<AdminPanel />} />
          <Route path="/analytics" element={<Analytics />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
    </Router>
  )
}

Named Exports with lazy

React.lazy only supports default exports. To use named exports, create a wrapper:

const UserList = lazy(() =>
  import('./components').then((module) => ({
    default: module.UserList,
  }))
)
 
// Or create a helper
function lazyNamed(importFn, name) {
  return lazy(() =>
    importFn().then((module) => ({ default: module[name] }))
  )
}
 
const UserList = lazyNamed(() => import('./components'), 'UserList')
const UserForm = lazyNamed(() => import('./components'), 'UserForm')

Data Fetching with Suspense

For data fetching with Suspense, you need a mechanism that integrates with the thrown-promise pattern. Libraries like Relay, SWR v2, and React Query v5 support this natively.

import useSWR from 'swr'
 
// SWR's Suspense mode
function UserProfile({ userId }: { userId: string }) {
  const { data } = useSWR(`/api/users/${userId}`, fetcher, {
    suspense: true,
  })
 
  return (
    <div>
      <h2>{data.name}</h2>
      <p>{data.email}</p>
    </div>
  )
}
 
// Parent with Suspense boundary
function UserPage() {
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <UserProfile userId="123" />
    </Suspense>
  )
}

Manual Suspense Integration

You can create your own suspense-compatible data source:

function createSuspenseResource<T>(promise: Promise<T>) {
  let status = 'pending'
  let result: T
  let error: Error
 
  const suspender = promise.then(
    (data) => {
      status = 'success'
      result = data
    },
    (err) => {
      status = 'error'
      error = err
    }
  )
 
  return {
    read(): T {
      if (status === 'pending') throw suspender
      if (status === 'error') throw error
      return result
    },
  }
}
 
// Usage
const userResource = createSuspenseResource(fetchUser('123'))
 
function UserProfile() {
  const user = userResource.read() // Throws promise if pending
  return <h1>{user.name}</h1>
}

Data Flow Architecture

useTransition for Non-Blocking Updates

useTransition marks state updates as non-urgent, allowing React to interrupt them for more pressing updates like user input.

import { useTransition, useState } from 'react'
 
function SearchPage() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])
  const [isPending, startTransition] = useTransition()
 
  function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
    // Urgent: update the input immediately
    setQuery(e.target.value)
 
    // Non-urgent: update results (can be interrupted)
    startTransition(() => {
      setResults(heavyFilter(e.target.value, allData))
    })
  }
 
  return (
    <div>
      <input value={query} onChange={handleSearch} />
      {isPending ? <Spinner /> : <ResultsList results={results} />}
    </div>
  )
}

useDeferredValue for Expensive Renders

When you can't wrap an update in useTransition (e.g., the value comes from props), use useDeferredValue to create a deferred copy.

import { useDeferredValue, useMemo } from 'react'
 
function FilteredResults({ query }: { query: string }) {
  const deferredQuery = useDeferredValue(query)
 
  const filtered = useMemo(
    () => expensiveFilter(deferredQuery, largeDataset),
    [deferredQuery]
  )
 
  const isStale = query !== deferredQuery
 
  return (
    <div style={{ opacity: isStale ? 0.7 : 1 }}>
      {filtered.map((item) => (
        <ResultCard key={item.id} item={item} />
      ))}
    </div>
  )
}

Real-World Use Cases

Use Case 1: E-Commerce Product Pages

Product pages often need to load multiple data sources—product details, reviews, recommendations, and pricing. With Suspense, each section has its own boundary, allowing users to see product info immediately while reviews and recommendations load independently.

function ProductPage({ productId }: { productId: string }) {
  return (
    <div className="product-page">
      <Suspense fallback={<ProductSkeleton />}>
        <ProductDetails id={productId} />
      </Suspense>
 
      <div className="sidebar">
        <Suspense fallback={<PriceSkeleton />}>
          <PricingWidget id={productId} />
        </Suspense>
 
        <Suspense fallback={<RecommendationsSkeleton />}>
          <Recommendations id={productId} />
        </Suspense>
      </div>
 
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews productId={productId} />
      </Suspense>
    </div>
  )
}

Use Case 2: Tabbed Interfaces with Transitions

When switching between tabs that load different data, transitions prevent the UI from showing a loading state for every tab switch. Instead, keep showing the current tab until the new one is ready.

function TabContainer() {
  const [tab, setTab] = useState('home')
  const [isPending, startTransition] = useTransition()
 
  function selectTab(nextTab: string) {
    startTransition(() => {
      setTab(nextTab)
    })
  }
 
  return (
    <div>
      <TabBar
        tabs={['home', 'profile', 'settings']}
        active={tab}
        onChange={selectTab}
        loading={isPending}
      />
      <div style={{ opacity: isPending ? 0.6 : 1 }}>
        <Suspense fallback={<TabSkeleton />}>
          {tab === 'home' && <HomeContent />}
          {tab === 'profile' && <ProfileContent />}
          {tab === 'settings' && <SettingsContent />}
        </Suspense>
      </div>
    </div>
  )
}

Use Case 3: Streaming Server-Side Rendering

Suspense enables streaming SSR—send the HTML shell immediately, then stream in suspended content as it resolves. This dramatically improves Time to First Byte (TTFB).

// Server-side with streaming
import { renderToPipeableStream } from 'react-dom/server'
 
function handleRequest(req, res) {
  const stream = renderToPipeableStream(<App url={req.url} />, {
    bootstrapScripts: ['/client.js'],
    onShellReady() {
      // Shell is ready - start streaming
      res.statusCode = 200
      res.setHeader('content-type', 'text/html')
      stream.pipe(res)
    },
    onError(err) {
      console.error(err)
      res.statusCode = 500
    },
  })
}

Best Practices for Production

  1. Place Suspense boundaries strategically: Too few boundaries create all-or-nothing loading states. Too many create jarring visual updates. Place boundaries at natural UI seams—sections, panels, or independent content areas.

  2. Use skeleton screens as fallbacks: Skeleton screens that match the layout of the loaded content prevent layout shift and feel faster than spinners. Match the shape, size, and position of the actual content.

  3. Combine useTransition with Suspense: When navigating between routes or views, use transitions to keep the current view visible while the next one loads. This prevents flashing loading screens.

  4. Prefetch data and code: If you can predict user actions (hovering over a link, focusing an input), prefetch the data or code before the user commits to the action. This makes Suspense boundaries a safety net rather than the primary loading mechanism.

  5. Handle errors with Error Boundaries: Suspense only handles loading states. Always pair Suspense boundaries with Error boundaries to handle network failures, API errors, and unexpected exceptions.

  6. Avoid Suspense in tight loops: Don't use Suspense for rapid, repeated updates. It's designed for operations that take meaningful time—data fetches, code loading, image processing—not microsecond computations.

  7. Test with throttled networks: Your development machine is fast. Test Suspense fallbacks with Chrome DevTools network throttling to ensure they appear and work correctly on slow connections.

  8. Use the key prop to reset Suspense: Changing the key on a Suspense boundary forces React to remount its children, effectively resetting any cached data or state. Useful when switching between different data sources.

Common Pitfalls and Solutions

PitfallImpactSolution
No Suspense boundary above suspended componentApp crashes with uncaught promiseWrap components that suspend in <Suspense>
Showing fallbacks too aggressivelyUsers see loading spinners constantlyUse transitions to keep previous content visible
Not handling errors alongside SuspenseSilent failures, blank screensPair Suspense with ErrorBoundary components
Suspense with useEffect data fetchingDouble fetching, race conditionsUse Suspense-compatible libraries (SWR, React Query)
Streaming SSR without proper boundariesDelayed HTML, poor TTFBPlace server boundaries at data-heavy sections
Using Suspense for synchronous codeUnnecessary complexityOnly use Suspense for genuinely async operations

Performance Optimization

// Prefetch on hover to make Suspense invisible
function PrefetchLink({ to, children }: LinkProps) {
  const prefetch = useCallback(() => {
    // Prefetch the route's chunk
    const route = routes[to]
    if (route?.loader) route.loader()
    if (route?.component) route.component()
  }, [to])
 
  return (
    <Link to={to} onMouseEnter={prefetch}>
      {children}
    </Link>
  )
}
 
// Selective hydration with Suspense
// Components inside Suspense boundaries hydrate independently
function App() {
  return (
    <div>
      <Header /> {/* Hydrates first */}
      <Suspense fallback={<MainSkeleton />}>
        <MainContent /> {/* Hydrates when ready */}
      </Suspense>
      <Suspense fallback={<CommentsSkeleton />}>
        <LazyComments /> {/* Hydrates last */}
      </Suspense>
    </div>
  )
}

Comparison with Alternatives

FeatureSuspenseReact QuerySWRuseEffect + useState
Declarative loadingYesPartialPartialNo
Built into ReactYesNoNoYes
Streaming SSRYesNoNoNo
Error handlingVia ErrorBoundaryBuilt-inBuilt-inManual
CachingManualAutomaticAutomaticManual
DeduplicationManualAutomaticAutomaticManual
Background refetchNoYesYesNo
Stale-while-revalidateNoYesYesNo

Recommendation: Use Suspense for code splitting and streaming SSR. For data fetching in production, combine Suspense with a data fetching library that handles caching, deduplication, and background refetching. Don't try to build your own data fetching layer on top of raw Suspense.

Advanced Patterns and Techniques

Suspense with React.lazy and Error Boundaries

function LazyRoute({ component: Component }: { component: React.LazyExoticComponent<any> }) {
  return (
    <ErrorBoundary fallback={<RouteError />}>
      <Suspense fallback={<RouteSkeleton />}>
        <Component />
      </Suspense>
    </ErrorBoundary>
  )
}
 
// Usage
<LazyRoute component={lazy(() => import('./Dashboard'))} />

Nested Suspense with Progressive Loading

function ArticlePage({ slug }: { slug: string }) {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <ArticleHeader slug={slug} />
 
      <Suspense fallback={<ContentSkeleton />}>
        <ArticleContent slug={slug} />
      </Suspense>
 
      <Suspense fallback={<RelatedSkeleton />}>
        <RelatedArticles slug={slug} />
      </Suspense>
    </Suspense>
  )
}

Concurrent Search with Debounced Transitions

function SearchInterface() {
  const [input, setInput] = useState('')
  const [deferredInput, setDeferredInput] = useState('')
  const [isPending, startTransition] = useTransition()
 
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInput(e.target.value)
    startTransition(() => {
      setDeferredInput(e.target.value)
    })
  }
 
  return (
    <div>
      <input value={input} onChange={handleChange} />
      {isPending && <SearchingIndicator />}
      <Suspense fallback={<ResultsSkeleton />}>
        <SearchResults query={deferredInput} />
      </Suspense>
    </div>
  )
}

Testing Strategies

import { render, screen, waitFor } from '@testing-library/react'
import { Suspense } from 'react'
 
test('shows fallback then content', async () => {
  render(
    <Suspense fallback={<div data-testid="loading">Loading...</div>}>
      <SlowComponent />
    </Suspense>
  )
 
  // Initially shows fallback
  expect(screen.getByTestId('loading')).toBeInTheDocument()
 
  // Eventually shows content
  await waitFor(() => {
    expect(screen.getByText('Loaded content')).toBeInTheDocument()
  })
 
  expect(screen.queryByTestId('loading')).not.toBeInTheDocument()
})
 
test('useTransition prevents fallback from showing', async () => {
  render(<SearchPage />)
 
  const input = screen.getByRole('searchbox')
  fireEvent.change(input, { target: { value: 'test' } })
 
  // Input updates immediately
  expect(input).toHaveValue('test')
 
  // Previous results stay visible during transition
  await waitFor(() => {
    expect(screen.getByText(/matching results/)).toBeInTheDocument()
  })
})

Future Outlook

React's concurrent features are still evolving. The React team is working on bringing more primitives to production—use hook for reading resources in render, improved streaming patterns, and deeper integration with Server Components. The Suspense ecosystem will continue to mature as data fetching libraries adopt the pattern more fully.

The combination of Suspense, transitions, and streaming SSR represents React's vision for the future: declarative, composable UI that handles loading states as naturally as it handles rendering. Understanding these patterns now prepares you for the next generation of React development.

Conclusion

React Suspense and Concurrent Features fundamentally change how we think about loading states and asynchronous operations in React applications. Suspense provides a declarative mechanism for handling loading, transitions enable non-blocking updates, and streaming SSR dramatically improves server-side performance.

Key takeaways:

  1. Suspense replaces imperative loading state management with declarative fallback boundaries.
  2. useTransition keeps the UI responsive during heavy updates by marking them as non-urgent.
  3. useDeferredValue creates deferred copies of values for expensive computations.
  4. Streaming SSR sends the HTML shell immediately and streams suspended content as it resolves.
  5. Combine Suspense with data fetching libraries for production-ready caching, deduplication, and error handling.
  6. Place boundaries at natural UI seams, use skeleton screens, and always pair with Error Boundaries.

Start by adding Suspense boundaries around your route components for code splitting, then progressively adopt transitions and data fetching patterns as your comfort grows. The investment in understanding these features will pay dividends as React continues to evolve.