React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

swrsuspensessrstreaming

SWR with React Suspense

Recipe

Enable the suspense option on useSWR to integrate with React Suspense boundaries. When suspense is active, SWR throws a promise during loading, letting <Suspense> handle the fallback UI.

"use client";
 
import { Suspense } from "react";
import useSWR from "swr";
 
const fetcher = (url: string) => fetch(url).then((r) => r.json());
 
function UserName({ id }: { id: string }) {
  // With suspense: true, data is guaranteed to be defined
  const { data } = useSWR(`/api/users/${id}`, fetcher, { suspense: true });
 
  return <span>{data.name}</span>;
}
 
export default function Page() {
  return (
    <Suspense fallback={<div>Loading user...</div>}>
      <UserName id="1" />
    </Suspense>
  );
}

Working Example

"use client";
 
import { Suspense } from "react";
import useSWR, { SWRConfig } from "swr";
 
interface Post {
  id: string;
  title: string;
  body: string;
}
 
interface Comment {
  id: string;
  text: string;
  author: string;
}
 
const fetcher = (url: string) => fetch(url).then((r) => r.json());
 
function PostContent({ postId }: { postId: string }) {
  const { data: post } = useSWR<Post>(`/api/posts/${postId}`, fetcher, {
    suspense: true,
  });
 
  return (
    <article>
      <h1>{post!.title}</h1>
      <p>{post!.body}</p>
    </article>
  );
}
 
function PostComments({ postId }: { postId: string }) {
  const { data: comments } = useSWR<Comment[]>(
    `/api/posts/${postId}/comments`,
    fetcher,
    { suspense: true }
  );
 
  return (
    <ul>
      {comments!.map((c) => (
        <li key={c.id}>
          <strong>{c.author}</strong>: {c.text}
        </li>
      ))}
    </ul>
  );
}
 
export default function PostPage({ postId }: { postId: string }) {
  return (
    <SWRConfig value={{ suspense: true }}>
      <Suspense fallback={<div>Loading post...</div>}>
        <PostContent postId={postId} />
      </Suspense>
      <Suspense fallback={<div>Loading comments...</div>}>
        <PostComments postId={postId} />
      </Suspense>
    </SWRConfig>
  );
}

Deep Dive

How It Works

  • With suspense: true, SWR throws a promise when data is not yet available. React's <Suspense> boundary catches this and renders the fallback.
  • Once the promise resolves, React re-renders the component. At that point, data is guaranteed to be defined (not undefined).
  • Multiple useSWR calls inside the same <Suspense> boundary will trigger parallel fetches, but the boundary waits for all of them.
  • Separate <Suspense> boundaries around each data-dependent component enable independent loading states and streaming.
  • Errors in suspense mode are thrown during render, so they are caught by the nearest Error Boundary.

Variations

Global suspense via SWRConfig:

<SWRConfig value={{ suspense: true }}>
  <Suspense fallback={<Loading />}>
    {children}
  </Suspense>
</SWRConfig>

Nested suspense for progressive loading:

<Suspense fallback={<HeaderSkeleton />}>
  <Header />
  <Suspense fallback={<ContentSkeleton />}>
    <MainContent />
    <Suspense fallback={<SidebarSkeleton />}>
      <Sidebar />
    </Suspense>
  </Suspense>
</Suspense>

Combined with ErrorBoundary:

import { ErrorBoundary } from "react-error-boundary";
 
<ErrorBoundary fallback={<div>Something went wrong</div>}>
  <Suspense fallback={<div>Loading...</div>}>
    <DataComponent />
  </Suspense>
</ErrorBoundary>

TypeScript Notes

  • With suspense: true, data is still typed as Data | undefined by default. Use a non-null assertion or cast when you know suspense guarantees data presence.
  • Consider a wrapper type or hook that narrows the type when suspense is enabled.
function useSuspenseSWR<T>(key: string, fetcher: (url: string) => Promise<T>) {
  const result = useSWR<T>(key, fetcher, { suspense: true });
  return { ...result, data: result.data as T };
}
 
// data is typed as User, not User | undefined
const { data } = useSuspenseSWR<User>("/api/me", fetcher);

Gotchas

  • Suspense mode in SWR is still evolving. Concurrent features like useTransition with SWR may have edge cases.
  • When using suspense with SSR (Server-Side Rendering), SWR will fall back to client-side fetching. You need to prepopulate the cache with fallback in SWRConfig to avoid hydration mismatches.
  • isLoading and isValidating have different semantics in suspense mode. The component simply does not render while loading, so you never see isLoading: true inside the component.
  • If you use suspense: true without a <Suspense> boundary, React will throw an error up to the nearest Error Boundary or crash the app.
  • Conditional keys (null keys) disable suspense behavior for that hook. The component will render with data: undefined.
  • Background revalidations in suspense mode do not re-suspend. Only the initial load suspends.

Alternatives

ApproachProsCons
SWR suspense modeClean loading states, composableSSR complexity, type narrowing needed
Manual isLoading checksFull control, explicitRepetitive loading UI code
React Server ComponentsNo client loading state neededCannot use hooks, no real-time updates
use() hook (React 19)Native promise unwrappingExperimental, different API

FAQs

How does SWR's suspense mode work under the hood?

With suspense: true, SWR throws a promise when data is not yet available. React's <Suspense> boundary catches this promise, renders the fallback, and re-renders the component once the promise resolves.

Is data guaranteed to be defined inside a component using suspense: true?

Yes, at render time data is guaranteed to be available because the component only renders after the promise resolves. However, TypeScript still types it as Data | undefined by default. Use a non-null assertion or a wrapper hook to narrow the type.

What happens if I use suspense: true without a Suspense boundary?

React will throw the promise upward. If there is no <Suspense> boundary, it reaches the nearest Error Boundary or crashes the app entirely.

How do I enable suspense mode globally for all useSWR calls?
<SWRConfig value={{ suspense: true }}>
  <Suspense fallback={<Loading />}>
    {children}
  </Suspense>
</SWRConfig>
Do multiple useSWR calls inside the same Suspense boundary fetch in parallel?

Yes. Multiple useSWR calls within the same <Suspense> boundary trigger parallel fetches. The boundary waits for all of them before rendering.

Gotcha: Do background revalidations re-suspend the component?

No. Only the initial load suspends. Background revalidations update data silently without triggering the Suspense fallback again.

How do I handle errors in suspense mode?

Errors in suspense mode are thrown during render, so they are caught by the nearest Error Boundary. Wrap your <Suspense> inside an <ErrorBoundary>:

<ErrorBoundary fallback={<div>Error</div>}>
  <Suspense fallback={<div>Loading...</div>}>
    <DataComponent />
  </Suspense>
</ErrorBoundary>
Gotcha: What happens with SSR and SWR suspense mode?

SWR falls back to client-side fetching during SSR. You need to prepopulate the cache with fallback in SWRConfig to avoid hydration mismatches between server and client.

How do I create a type-safe suspense hook in TypeScript where data is not undefined?
function useSuspenseSWR<T>(key: string, fetcher: (url: string) => Promise<T>) {
  const result = useSWR<T>(key, fetcher, { suspense: true });
  return { ...result, data: result.data as T };
}
// data is typed as T, not T | undefined
What happens when I use a null key with suspense: true?

Conditional keys (null) disable suspense behavior for that hook. The component renders immediately with data: undefined instead of suspending.

How do I achieve progressive loading with nested Suspense boundaries?

Wrap each data-dependent section in its own <Suspense> boundary. This enables independent loading states and streaming, so faster sections render without waiting for slower ones.