Skip to content
Codeloom
React

React Suspense and useTransition: Async UI Patterns

Learn how to use React Suspense and useTransition to build smooth async UIs. Master concurrent rendering, loading states, and transition patterns.

·8 min read · By Codeloom
Intermediate 14 min read

What you'll learn

  • How React Suspense handles async rendering
  • Using useTransition for non-blocking updates
  • Building loading states with Suspense boundaries
  • Combining Suspense with data fetching libraries

Prerequisites

  • React fundamentals (components, hooks)
  • Basic understanding of async JavaScript

React’s concurrent features changed how we think about loading states and user interactions. Instead of manually juggling isLoading booleans and spinner flags, Suspense and useTransition give you declarative tools to handle async operations without blocking the UI.

This guide walks you through both features, shows how they work together, and demonstrates real patterns you can apply today.

What Problem Does Suspense Solve?

Before Suspense, every async operation required manual state management:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetchUser(userId)
      .then(data => setUser(data))
      .catch(err => setError(err))
      .finally(() => setLoading(false));
  }, [userId]);

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  return <div>{user.name}</div>;
}

This works, but it gets repetitive. Every component that loads data repeats the same loading/error/data pattern. Suspense lets you move loading states out of individual components and into the component tree.

How Suspense Works

Suspense catches promises thrown during rendering. When a component suspends, React walks up the tree until it finds a <Suspense> boundary and renders its fallback.

import { Suspense } from 'react';

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

The key insight is that UserProfile no longer manages its own loading state. The Suspense boundary above it handles it. This means you can compose loading states at any level of your tree.

Nested Suspense Boundaries

You can nest Suspense boundaries to create granular loading experiences:

function Dashboard() {
  return (
    <div className="dashboard">
      <Suspense fallback={<HeaderSkeleton />}>
        <Header />
      </Suspense>

      <div className="dashboard-content">
        <Suspense fallback={<SidebarSkeleton />}>
          <Sidebar />
        </Suspense>

        <Suspense fallback={<MainContentSkeleton />}>
          <MainContent />
        </Suspense>
      </div>
    </div>
  );
}

Each section loads independently. The header might appear first, followed by the sidebar, then the main content. Without nesting, a single Suspense boundary would wait for everything before showing anything.

Using Suspense with Data Fetching

Suspense works with libraries that support the Suspense protocol. React Query (TanStack Query) and SWR both have Suspense modes:

import { useSuspenseQuery } from '@tanstack/react-query';

function UserProfile({ userId }) {
  const { data: user } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  // No loading check needed - Suspense handles it
  return (
    <div className="profile">
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

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

Notice how UserProfile only handles the success case. The component is simpler because it delegates loading and error states to boundaries.

Understanding useTransition

useTransition marks a state update as non-urgent. React can interrupt the transition to handle more urgent updates like typing, keeping the UI responsive.

import { useState, useTransition } 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: can be interrupted
      const filtered = filterLargeDataset(value);
      setResults(filtered);
    });
  }

  return (
    <div>
      <input value={query} onChange={handleSearch} />
      {isPending && <span className="searching">Searching...</span>}
      <ResultsList results={results} />
    </div>
  );
}

The input stays responsive even when filtering thousands of items because React prioritizes the setQuery update over the setResults update inside startTransition.

useTransition with Navigation

Transitions shine during navigation. Instead of showing a blank page while the next route loads, you can keep the current page visible:

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

  function selectTab(nextTab) {
    startTransition(() => {
      setTab(nextTab);
    });
  }

  return (
    <div>
      <nav>
        <button
          onClick={() => selectTab('home')}
          className={tab === 'home' ? 'active' : ''}
        >
          Home
        </button>
        <button
          onClick={() => selectTab('posts')}
          className={tab === 'posts' ? 'active' : ''}
        >
          Posts
        </button>
        <button
          onClick={() => selectTab('settings')}
          className={tab === 'settings' ? 'active' : ''}
        >
          Settings
        </button>
      </nav>

      <div style={{ opacity: isPending ? 0.7 : 1 }}>
        <Suspense fallback={<TabSkeleton />}>
          {tab === 'home' && <HomeTab />}
          {tab === 'posts' && <PostsTab />}
          {tab === 'settings' && <SettingsTab />}
        </Suspense>
      </div>
    </div>
  );
}

Without startTransition, clicking a tab would immediately trigger the Suspense fallback. With it, the current tab stays visible (dimmed via opacity) while the next tab loads.

Combining Suspense and useTransition

The real power comes from combining both features. Here is a search interface that fetches results from an API:

import { Suspense, useState, useTransition } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query';

function SearchResults({ query }) {
  const { data: results } = useSuspenseQuery({
    queryKey: ['search', query],
    queryFn: () => searchAPI(query),
    enabled: query.length > 0,
  });

  if (results.length === 0) {
    return <p>No results found for "{query}"</p>;
  }

  return (
    <ul className="results">
      {results.map(result => (
        <li key={result.id}>
          <h3>{result.title}</h3>
          <p>{result.summary}</p>
        </li>
      ))}
    </ul>
  );
}

function SearchPage() {
  const [input, setInput] = useState('');
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  function handleChange(e) {
    setInput(e.target.value);
    startTransition(() => {
      setQuery(e.target.value);
    });
  }

  return (
    <div className="search-page">
      <input
        value={input}
        onChange={handleChange}
        placeholder="Search..."
      />

      {isPending && <div className="pending-indicator" />}

      {query && (
        <Suspense fallback={<ResultsSkeleton />}>
          <SearchResults query={query} />
        </Suspense>
      )}
    </div>
  );
}

This gives you three behaviors working together: the input updates instantly, the pending indicator shows during transitions, and Suspense handles the initial loading state.

Building a Skeleton Loading Pattern

Skeleton screens work naturally with Suspense. Create skeleton components that match your data components:

function PostCardSkeleton() {
  return (
    <div className="post-card skeleton">
      <div className="skeleton-line title" />
      <div className="skeleton-line" />
      <div className="skeleton-line" />
      <div className="skeleton-line short" />
    </div>
  );
}

function PostListSkeleton() {
  return (
    <div className="post-list">
      {Array.from({ length: 5 }, (_, i) => (
        <PostCardSkeleton key={i} />
      ))}
    </div>
  );
}

function PostList() {
  const { data: posts } = useSuspenseQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  });

  return (
    <div className="post-list">
      {posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

// Usage
function Feed() {
  return (
    <Suspense fallback={<PostListSkeleton />}>
      <PostList />
    </Suspense>
  );
}

Error Handling with Error Boundaries

Suspense handles the loading case. For errors, pair it with an error boundary:

import { Component, Suspense } from 'react';

class ErrorBoundary extends Component {
  state = { error: null };

  static getDerivedStateFromError(error) {
    return { error };
  }

  render() {
    if (this.state.error) {
      return (
        <div className="error-container">
          <p>Something went wrong.</p>
          <button onClick={() => this.setState({ error: null })}>
            Try again
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

function App() {
  return (
    <ErrorBoundary>
      <Suspense fallback={<PageSkeleton />}>
        <Dashboard />
      </Suspense>
    </ErrorBoundary>
  );
}

The error boundary catches any errors thrown during rendering (including failed fetches), while Suspense handles the loading state. Together they cover all three states: loading, error, and success.

When to Use Each Feature

Use Suspense when you want to show a fallback while something loads. It works best for initial data fetching, lazy-loaded components, and code splitting.

Use useTransition when you want to keep the current UI visible during a state change. It works best for tab switches, search filtering, navigation, and any update where showing stale content briefly is better than showing a spinner.

Use both together when navigating between views that fetch data. The transition keeps the old view visible, and Suspense provides the fallback if the transition takes too long.

Common Mistakes to Avoid

Do not wrap every component in its own Suspense boundary. Too many boundaries cause a “popcorn” effect where parts of the page pop in at different times. Group related content under one boundary.

Do not use startTransition for urgent updates. If the user expects immediate feedback (like typing in an input), that state update should happen synchronously.

Do not forget that isPending only stays true during the transition. Once the new state finishes rendering, it becomes false. Use it for visual hints, not for data management.

Wrapping Up

Suspense and useTransition work together to give you fine-grained control over loading experiences. Suspense lets you declaratively place loading states in your component tree, while useTransition lets you keep the current UI visible during non-urgent updates.

Start by adding Suspense boundaries around sections of your app that load data. Then introduce useTransition for interactions where you want to avoid showing a loading state, like tab switches or search filtering. The combination produces UIs that feel faster and more polished without adding complexity to individual components.