Skip to content
elephantoo

Suspense & lazy loading

Lesson 21 of 30 15 min read

Code-split with lazy(), show fallbacks with Suspense, and keep UI responsive with transitions.


Some things take time: downloading a chunk of JavaScript, fetching data, rendering a huge list. React's concurrent features let you handle waiting gracefully. Suspense shows a fallback while something loads, lazy splits your code so users download less up front, and transitions keep the UI responsive while expensive updates happen in the background.

Code splitting with lazy#

By default, Vite bundles your whole app into one JavaScript file. If your settings page uses a heavy chart library, every visitor downloads it — even those who never open settings. lazy loads a component's code only when it's first rendered:

JSX
import { lazy, Suspense, useState } from "react";

// Declared at module level – Vite puts AdminPanel and its imports in a separate file
const AdminPanel = lazy(() => import("./AdminPanel"));

export default function App() {
  const [showAdmin, setShowAdmin] = useState(false);

  return (
    <>
      <button onClick={() => setShowAdmin(true)}>Open admin panel</button>
      {showAdmin && (
        <Suspense fallback={<p>Loading admin tools…</p>}>
          <AdminPanel />
        </Suspense>
      )}
    </>
  );
}
src/AdminPanel.jsx
export default function AdminPanel() {
  return <h2>Admin panel</h2>;
}

When the button is clicked, React starts downloading AdminPanel's chunk, shows the fallback, then swaps in the real component. After a production build you can see the separate file:

Output
dist/assets/index-B2x9Kq.js        190.12 kB
dist/assets/AdminPanel-Dh3Lp1.js     2.41 kB

Rules for lazy:

  • The import() must resolve to a module with a default export that is a component. For a named export, map it: lazy(() => import("./Charts").then((m) => ({ default: m.SalesChart }))).
  • Call lazy at the top level of a module, never inside a component.
  • A lazy component must be rendered inside a <Suspense> boundary (any ancestor will do).

Splitting by route#

Pages are the most natural place to split code. With React Router you can use lazy components in your routes:

JSX
import { lazy, Suspense } from "react";
import { createBrowserRouter, Outlet } from "react-router";

const Home = lazy(() => import("./pages/Home"));
const Courses = lazy(() => import("./pages/Courses"));
const Settings = lazy(() => import("./pages/Settings"));

function RootLayout() {
  return (
    <>
      <Nav />
      <Suspense fallback={<PageSkeleton />}>
        <Outlet />
      </Suspense>
    </>
  );
}

const router = createBrowserRouter([
  {
    path: "/",
    Component: RootLayout,
    children: [
      { index: true, Component: Home },
      { path: "courses", Component: Courses },
      { path: "settings", Component: Settings },
    ],
  },
]);

React Router also has a built-in route-level lazy property that loads a route's Component, loader and more in parallel — see its docs when you need it.

How Suspense works#

Suspense is a boundary, like an error boundary but for loading:

JSX
<Suspense fallback={<Spinner />}>
  <Profile />
  <Suspense fallback={<PostsSkeleton />}>
    <Posts />
  </Suspense>
</Suspense>
  • If anything inside a boundary suspends, React shows that boundary's fallback instead of its children.
  • The nearest boundary wins. Above, if only Posts is loading, the profile is shown with a skeleton for the posts.
  • Children inside one boundary are revealed together, so you avoid "popcorn" UIs where pieces jump in one by one.

Things that can suspend:

  • lazy components while their code downloads.
  • Reading a promise with use(promise) (see React 19 features).
  • Suspense-enabled data libraries, e.g. TanStack Query's useSuspenseQuery, or React Router and Next.js data loading.

Tip: Suspense does not detect data fetched inside useEffect. Effects run after rendering, so the component never suspends.

Data with use and Suspense#

React 19's use reads a promise during render. While it's pending, the component suspends; if it rejects, the nearest error boundary catches it:

JSX
import { Suspense, use, useState } from "react";
import { ErrorBoundary } from "react-error-boundary";

function fetchUser(id) {
  return fetch(`https://jsonplaceholder.typicode.com/users/${id}`).then((res) => {
    if (!res.ok) throw new Error("User not found");
    return res.json();
  });
}

function UserName({ userPromise }) {
  const user = use(userPromise); // suspends until resolved
  return <h2>{user.name}</h2>;
}

export default function Page() {
  // Create the promise once (in a loader, an event handler, or cached) – not on every render
  const [userPromise] = useState(() => fetchUser(1));

  return (
    <ErrorBoundary fallback={<p>Couldn’t load user.</p>}>
      <Suspense fallback={<p>Loading user…</p>}>
        <UserName userPromise={userPromise} />
      </Suspense>
    </ErrorBoundary>
  );
}

The promise must be stable. Creating a new promise inside the component on every render would suspend forever. In practice, frameworks and libraries create and cache these promises for you.

Transitions: keep showing the old UI#

When something already on screen suspends again (say, switching tabs to a lazy page), Suspense would normally replace it with the fallback — a jarring flash. Marking the update as a transition tells React: "this update isn't urgent; keep showing the current UI until the new one is ready."

JSX
import { lazy, Suspense, useState, useTransition } from "react";

const tabs = {
  about: lazy(() => import("./tabs/About")),
  posts: lazy(() => import("./tabs/Posts")),
  contact: lazy(() => import("./tabs/Contact")),
};

export default function TabbedPage() {
  const [tab, setTab] = useState("about");
  const [isPending, startTransition] = useTransition();
  const Tab = tabs[tab];

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

  return (
    <>
      <nav style={{ opacity: isPending ? 0.6 : 1 }}>
        {Object.keys(tabs).map((name) => (
          <button key={name} onClick={() => selectTab(name)} aria-pressed={tab === name}>
            {name}
          </button>
        ))}
      </nav>
      <Suspense fallback={<p>Loading…</p>}>
        <Tab />
      </Suspense>
    </>
  );
}

useTransition returns:

  • isPending — true while the transition is in progress, so you can dim the UI or show a small spinner.
  • startTransition(fn) — state updates inside fn are low-priority and interruptible. In React 19, fn can be async (an "Action"), and isPending stays true until it finishes.

React Router automatically wraps navigations in transitions, which is why the old page stays visible while the next one loads.

useDeferredValue: responsive typing with slow rendering#

Imagine a search box filtering a list that takes 100 ms to render. Every keystroke would freeze the input. useDeferredValue lets the input update immediately while the list catches up:

JSX
import { memo, useDeferredValue, useState } from "react";

const SlowList = memo(function SlowList({ query }) {
  const items = [];
  for (let i = 0; i < 250; i++) {
    items.push(<SlowItem key={i} text={`Result ${i} for “${query}”`} />);
  }
  return <ul>{items}</ul>;
});

function SlowItem({ text }) {
  const start = performance.now();
  while (performance.now() - start < 0.5) {
    // simulate 0.5 ms of work per item
  }
  return <li>{text}</li>;
}

export default function Search() {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Type fast…" />
      <div style={{ opacity: isStale ? 0.5 : 1, transition: "opacity 0.2s" }}>
        <SlowList query={deferredQuery} />
      </div>
    </>
  );
}

How it works:

  1. You type; query updates and React re-renders right away with the old deferredQuery, so SlowList (memoised) skips rendering and the input feels instant.
  2. React then renders in the background with the new deferredQuery. If you type again before it finishes, React abandons that render and starts over with the latest value.

useDeferredValue also works with Suspense: while new results load, it keeps showing the old results instead of a fallback.

useTransition vs useDeferredValue#

useTransitionuseDeferredValue
You control…the state update (startTransition(() => setX(...)))a value you receive (often a prop or state)
Gives youisPendingcompare value !== deferred to detect staleness
Typical usetab switches, navigations, async actionsexpensive rendering driven by fast input

Never wrap a text input's own setState in a transition — controlled inputs must update synchronously.

Common mistakes#

  • Calling lazy inside a component, causing state loss and endless reloading.
  • Forgetting a <Suspense> boundary above a lazy component.
  • Expecting Suspense to show a fallback for data fetched in useEffect.
  • Creating a new promise on every render and passing it to use.
  • One giant boundary around the whole app, so any loading shows a full-page spinner. Place boundaries where loading states make sense.
  • Splitting tiny components — each split adds a network request. Split pages and heavy features.

What's next#

You've now seen several React 19 APIs. Next, we'll tour them properly: Actions, useActionState, useOptimistic, use and more.

Check your understanding

Quick quiz

0/3 answered
  1. 1.What does <Suspense fallback={<Spinner />}> do?

  2. 2.Where should you call lazy(() => import('./Settings'))?

  3. 3.What is useDeferredValue useful for?

Finished reading?

Mark this lesson complete to track your progress.