background-shape
React 18 Suspense for Data Fetching (Without Server Components)
April 6, 2022 · 6 min read · by Muhammad Amal programming

TL;DR — In React 18, Suspense supports data fetching, but not via plain useEffect. Use SWR 1.3 (suspense: true) or React Query 4 (suspense: true) and wrap fetching components in <Suspense fallback={...}>. Cleaner waterfall handling than useState + useEffect, but requires the right data layer.

After concurrent rendering, the next React 18 capability worth understanding is Suspense for data fetching. The framing in the React docs has been a bit confusing — it talks a lot about Server Components, which aren’t production-ready yet. What is production-ready is using Suspense with SWR or React Query, which work today in React 18 apps.

This post focuses on what you can ship in April 2022, not the future. We’ll get to RSC when it’s actually shipping.

How Suspense actually works

Conceptually: a component can “suspend” by throwing a promise. React catches the thrown promise, shows the nearest <Suspense fallback> ancestor, waits for the promise to resolve, then re-renders the component.

<Suspense fallback={<Skeleton />}>
  <UserCard userId={42} />
</Suspense>

If <UserCard> is fetching and not ready, the skeleton shows. When the fetch resolves, the real card replaces it.

The mechanism for “is this component ready?” is the data layer’s job. Plain useEffect doesn’t suspend. SWR’s useSWR(key, fetcher, { suspense: true }) and React Query’s useQuery({ ..., suspense: true }) do.

With SWR 1.3

import useSWR from 'swr';

const fetcher = (url) => fetch(url).then(r => r.json());

function UserCard({ userId }) {
  const { data: user } = useSWR(
    `/api/users/${userId}`,
    fetcher,
    { suspense: true }
  );
  return <div>{user.name}  {user.email}</div>;
}

function App() {
  return (
    <Suspense fallback={<div>loading</div>}>
      <UserCard userId={42} />
    </Suspense>
  );
}

The suspense: true flag changes SWR’s behaviour: instead of returning { data: undefined, isLoading: true } on first render, it throws a promise that resolves to the data. Suspense catches it.

Three immediate wins over the useEffect + useState pattern:

  • Component code reads as if data is always defined — no if (loading) return ... boilerplate
  • Loading state is owned by the boundary, not duplicated in every fetcher
  • Error boundaries catch fetch errors uniformly

With React Query 4

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

function UserCard({ userId }) {
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
    suspense: true,
  });
  return <div>{user.name}  {user.email}</div>;
}

Same shape. React Query 4 is in beta in April 2022; the suspense story stabilizes through Q2. Migration of stable React Query 3 projects to 4 is mostly painless.

Avoiding waterfalls

The classic data-fetching mistake: rendering a parent that fetches A, then on next render rendering a child that fetches B. Two sequential round trips when they could’ve been parallel.

Suspense doesn’t automatically fix this — it can hide it. To genuinely parallelize, kick off fetches at the parent level:

function UserPage({ userId }) {
  // Both queries start immediately, in parallel.
  useSWR(`/api/users/${userId}`, fetcher, { suspense: true });
  useSWR(`/api/users/${userId}/orders`, fetcher, { suspense: true });

  return (
    <>
      <UserCard userId={userId} />
      <OrderList userId={userId} />
    </>
  );
}

Both queries fire simultaneously. SWR caches by key, so when <UserCard> and <OrderList> ask for the same data, they get the cached promise.

For more sophisticated parallelization (kick off the second fetch as soon as the first one’s response shape is known), you need either React Server Components or framework-level data loading (Next.js’ getServerSideProps, Remix loaders). That’s beyond client-only Suspense.

Error boundaries pair with Suspense

Errors thrown by fetches surface via React error boundaries:

import { ErrorBoundary } from 'react-error-boundary';

<ErrorBoundary fallback={<ErrorScreen />}>
  <Suspense fallback={<Skeleton />}>
    <UserCard userId={42} />
  </Suspense>
</ErrorBoundary>

Loading and error states have separate boundaries; both are at the boundary level rather than per-component. Code paths simplify dramatically.

For SWR/React Query, errors thrown from the fetcher propagate to the nearest error boundary when suspense: true is set. Without suspense, you handle them via the error return value.

What Suspense doesn’t (yet) do for you

  • No automatic deduplication of fetches initiated outside the data layer. Stick to SWR/React Query keys for that.
  • No automatic SSR magic. SSR + Suspense is a Next.js / Remix concern; client-only Suspense streams on the client.
  • No silver bullet for sequential fetches that genuinely depend on each other’s data. The waterfall might be inherent.

A complete page example

import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import useSWR from 'swr';

const fetcher = (url) => fetch(url).then(r => {
  if (!r.ok) throw new Error(`fetch ${url}${r.status}`);
  return r.json();
});

function ProfilePage({ userId }) {
  // Kick off both queries immediately to avoid waterfall
  useSWR(`/api/users/${userId}`, fetcher, { suspense: true });
  useSWR(`/api/users/${userId}/orders`, fetcher, { suspense: true });

  return (
    <div className="profile-page">
      <Suspense fallback={<HeaderSkeleton />}>
        <UserHeader userId={userId} />
      </Suspense>
      <Suspense fallback={<OrdersSkeleton />}>
        <OrdersList userId={userId} />
      </Suspense>
    </div>
  );
}

function App() {
  return (
    <ErrorBoundary fallback={<div>Something went wrong.</div>}>
      <Suspense fallback={<PageSkeleton />}>
        <ProfilePage userId={42} />
      </Suspense>
    </ErrorBoundary>
  );
}

Two nested Suspense boundaries inside the page = independent loading. Header and orders skeletons can resolve at different times. Outer boundary catches the parent-level fetch suspense; outer error boundary catches anything thrown.

The component code never deals with loading or error states explicitly. They’re declarative concerns at the boundaries.

Transition + Suspense interaction

When you trigger a transition that causes a component to suspend, React 18 shows the old UI (stale) instead of the fallback. This is the cleaner UX — no flash of skeleton when filtering or switching tabs.

const [isPending, startTransition] = useTransition();

function selectUser(id) {
  startTransition(() => setSelectedUserId(id));
}

// The page below stays visible while loading the new user.
<Suspense fallback={<Skeleton />}>
  <UserPage userId={selectedUserId} />
</Suspense>

When you click “select user,” the previous user’s data stays visible (with isPending: true) until the new data loads. Then the swap is clean.

Pre-18, you’d get the skeleton flash. With concurrent + Suspense, you don’t.

Common Pitfalls

Trying to use Suspense with plain useEffect data fetching. Doesn’t work. Use SWR or React Query.

One giant <Suspense fallback> wrapping the entire page. Loses the granularity. Wrap individual sections.

Forgetting suspense: true flag. Default SWR/React Query don’t throw promises. They return loading state. With suspense: true, they throw.

Suspense boundary inside the component that fetches. Doesn’t help — the boundary needs to be above the fetching component in the tree.

Mixing client-side Suspense with getServerSideProps. Pick one approach per page. Server-rendered data + client-side Suspense is doable but adds complexity. Stick to one until you need both.

Believing Suspense reduces fetches. It doesn’t. It changes the UX of waiting. Caching reduces fetches; that’s SWR/React Query, not Suspense.

Wrapping Up

Suspense for data fetching is production-ready in React 18 if you pair it with SWR 1.3 or React Query 4. The mental shift — moving loading/error state to the boundary — is the actual benefit; the concrete code reads much cleaner. Friday: automatic batching — the free perf win that ships with every React 18 upgrade.