React

The React Suspense Mental Model

What Suspense actually is, how boundaries work, what throws a promise, and how to combine Suspense with Error Boundaries for a complete async UI strategy.

Suspense is one of those React features that looks simple on the surface but has an unusual amount happening under the hood. The API is a single wrapper component with a fallback prop. The behaviour, though, depends on a mechanism that React doesn't make obvious from the docs alone.

What Actually Happens

When React renders a component tree and encounters a component that isn't ready yet, it needs somewhere to go. The mechanism it uses is a thrown promise.

A component signals that it's loading by throwing a promise. React catches it, walks up the tree to find the nearest Suspense boundary, renders the fallback from that boundary, and subscribes to the promise. When the promise resolves, React retries the render from the suspended component down.

// This is what happens under the hood when a component is "suspended"
function SomeSuspendingComponent() {
    const data = cache.read() // throws a promise if not ready
    return <div>{data.title}</div>
}

You don't write this throw yourself most of the time. Libraries like React Query, SWR, or the built-in use() hook do it for you. But knowing that a thrown promise is the signal helps everything else make sense.

The use() Hook

React 19 introduced use(), which lets you unwrap a promise inside a component directly. If the promise isn't resolved yet, React suspends the component automatically.

import { use, Suspense } from 'react'

async function fetchUser(id: string): Promise<{ name: string }> {
    const res = await fetch(`/api/users/${id}`)

    if (!res.ok) {
        throw new Error(`Failed to fetch user: ${res.status}`)
    }
    return res.json()
}

function UserProfile({ userPromise }: { userPromise: Promise<{ name: string }> }) {
    const user = use(userPromise) // suspends until resolved
    return <p>{user.name}</p>
}

const userPromise = fetchUser('1')

export default function Page() {
    return (
        <Suspense fallback={<p>Loading user...</p>}>
            <UserProfile userPromise={userPromise} />
        </Suspense>
    )
}

The promise is created outside the component so it isn't re-created on every render. The boundary above catches the suspension and shows the fallback in the meantime.

How Boundaries Nest

You can have multiple Suspense boundaries at different levels of the tree. React uses the nearest ancestor boundary to catch a thrown promise. Inner boundaries resolve independently, so parts of the UI can finish loading without waiting for each other.

export default function Dashboard() {
    return (
        <div>
            <Suspense fallback={<p>Loading stats...</p>}>
                <StatsPanel />
            </Suspense>

            <Suspense fallback={<p>Loading feed...</p>}>
                <ActivityFeed />
            </Suspense>
        </div>
    )
}

StatsPanel and ActivityFeed suspend independently. If ActivityFeed takes longer, it stays in its fallback state while StatsPanel has already shown its content. No global loading state needed.

Suspense with React Query

React Query supports Suspense mode via useSuspenseQuery. Instead of managing isLoading manually, the hook throws a promise when data isn't available. The component only ever sees resolved data.

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

function PostList() {
    const { data } = useSuspenseQuery({
        queryKey: ['posts'],
        queryFn: async () => {
            const r = await fetch('/api/posts')

            if (!r.ok) {
                throw new Error(`Failed to fetch posts: ${r.status}`)
            }

            return r.json()
        },
    })

    return (
        <ul>
            {data.map((post: { id: string; title: string }) => (
                <li key={post.id}>{post.title}</li>
            ))}
        </ul>
    )
}

export default function BlogPage() {
    return (
        <Suspense fallback={<p>Loading posts...</p>}>
            <PostList />
        </Suspense>
    )
}

No if (isLoading) return <Spinner /> inside PostList. The component is clean and always works with data.

Suspense for Lazy Loading

The other common use for Suspense is code splitting with React.lazy. The pattern is the same. The lazy component throws a promise when the chunk hasn't loaded yet, and the boundary shows the fallback.

import { lazy, Suspense } from 'react'

const HeavyChart = lazy(() => import('./HeavyChart'))

export function AnalyticsPage() {
    return (
        <Suspense fallback={<p>Loading chart...</p>}>
            <HeavyChart />
        </Suspense>
    )
}

The HeavyChart bundle is only fetched when the component is first rendered. Everything above it renders immediately.

Combining Suspense with Error Boundaries

Suspense handles the loading state. It doesn't handle errors. A rejected promise that goes uncaught will bubble up and crash the component tree. That's where ErrorBoundary comes in.

The pattern is to wrap Suspense in an ErrorBoundary so that network errors, bad responses, and thrown exceptions are handled gracefully alongside the loading state.

import { Component, type ReactNode, Suspense } from 'react'

type ErrorBoundaryProps = {
    fallback: ReactNode
    children: ReactNode
}

type ErrorBoundaryState = { hasError: boolean }

class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
    state: ErrorBoundaryState = { hasError: false }

    static getDerivedStateFromError(): ErrorBoundaryState {
        return { hasError: true }
    }

    render() {
        if (this.state.hasError) {
            return this.props.fallback
        }
        return this.props.children
    }
}

export function AsyncSection({ children }: { children: ReactNode }) {
    return (
        <ErrorBoundary fallback={<p>Something went wrong.</p>}>
            <Suspense fallback={<p>Loading...</p>}>
                {children}
            </Suspense>
        </ErrorBoundary>
    )
}

With this wrapper, any async section of the UI has both states covered. Loading shows the Suspense fallback. Errors show the ErrorBoundary fallback. The happy path renders the component with its data.

The Mental Model

Think of Suspense as a try/catch for rendering. The component tree below the boundary might throw a promise (I'm loading) or an error (something broke). Suspense catches the loading signal. ErrorBoundary catches the error. Together they give you a complete strategy for async UI without scattering loading and error state across every component.

Keep boundaries at a granularity that makes sense for the user experience. Too coarse and the whole page spins. Too fine and you get a jarring cascade of tiny spinners. Match the boundary to the content unit: a panel, a feed, a modal, not a whole page or an individual row.

← Older
The Virtual DOM and How React Reconciliation Works
Newer →
Testing React Components with Vitest and Testing Library

Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.