Designing Loading UI in React

Designing Loading UI in React

A practical guide to loading UI in React: Suspense boundaries, skeletons vs spinners, avoiding layout shift, and animating reveals.

Aurora Scharff

Aurora Scharff

July 16, 2026

If you've built anything that fetches data, you've had to decide what to show while it loads, and it's common to end up with something that flickers or shifts around. There are plenty of ways to build loading states, but React now covers a lot of it out of the box with Suspense, useTransition(), and the experimental ViewTransition component. In this post, I'll walk through where to place your boundaries, how to keep the layout stable as content loads, and how to animate it in.

Suspense: declaring what to show while loading

Suspense lets you declare what to show while part of your component tree is still loading. You wrap the component that needs data and give it a fallback:

      <Suspense fallback={<ProfileSkeleton />}>
  <Profile userId={userId} />
</Suspense>

    

React shows the fallback until Profile has what it needs, then swaps in the real content. A boundary activates whenever a component inside it suspends. Most often that's a component reading a promise with use(), including data streamed from Server Components or loaded through a Suspense-enabled framework, though code-split components loaded with lazy() suspend the same way.

A common misconception is that Suspense catches any data fetching. It only reacts to a component that suspends, and a component that fetches in a useEffect() doesn't:

      function Profile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    getUser(userId).then(setUser);
  }, [userId]);

  return <p>{user?.name}</p>;
}

    

Suspense can't see the fetch in the Effect, so the boundary above never shows its fallback, and Profile renders empty until the data arrives. For how Suspense fits alongside the other concurrent features, see React Concurrent Features: An Overview.

Put the boundary around the slow part

The first decision is where the boundary goes. A Suspense boundary hides everything inside it until its slowest child is ready, so you want it around the part that's actually slow, with the fast content left outside.

Take a team directory. The page has a title and a small stats line, then a list of members that takes a moment to load. Only the list is slow, so only the list goes behind a boundary:

      function Page() {
  return (
    <main>
      <header>
        <h1>Team directory</h1>
        <Stats />
      </header>
      <Suspense fallback={<DirectorySkeleton />}>
        <Directory />
      </Suspense>
    </main>
  );
}

    

Stats sits outside the boundary, so it renders right away, while Directory shows a skeleton until its data arrives. If Stats were inside the same boundary, it would wait behind the skeleton too, even though it has nothing to load.

Skeletons or spinners

The fallback you pass to Suspense is usually a skeleton or a spinner. A spinner only says that something is happening. A skeleton shows the shape of what's coming and holds its space until the real content arrives, so when you know that shape ahead of time it's usually the better choice. Reach for a spinner when you can't predict the shape, or when the region is too small to draw one, like an icon in a search field or a button.

A skeleton and a spinner are both indeterminate. They show that something is happening, not how far along it is. For the quick loads you get most of the time that's fine, but a skeleton that keeps pulsing for ten seconds or more starts to read as stuck. When an operation can run that long, switch to a determinate progress bar with a percentage or a step count, so the user can see it's actually moving. Response-time research puts the cutoff at around ten seconds.

Layout shift and Core Web Vitals

The fallback has to hold the right amount of space, or the page jumps. If it's a different size than the content that replaces it, everything below shifts when they swap. That shift is Cumulative Layout Shift (CLS), one of Google's Core Web Vitals, and it's the shove you feel when an image or an ad pushes the text you're reading down the screen.

So the skeleton should mirror the size of the content it stands in for. A directory row is a fixed-height row with an avatar, a name, and a subtitle:

      function Directory({ members }) {
  return (
    <ul className="divide-y divide-gray-200">
      {members.map((member) => (
        <li key={member.id} className="flex h-16 items-center gap-3 px-4">
          <img src={member.avatar} className="size-9 rounded-full" />
          <div>
            <p className="font-medium">{member.name}</p>
            <p className="text-sm text-gray-500">{member.role}</p>
          </div>
        </li>
      ))}
    </ul>
  );
}

    

The skeleton is the same rows at the same h-16 height, with a gray block where each piece of content will go:

      function DirectorySkeleton({ rows = 8 }) {
  return (
    <div>
      {Array.from({ length: rows }).map((_, i) => (
        <div key={i} className="flex h-16 items-center gap-3 px-4">
          <div className="size-9 rounded-full bg-gray-200" />
          <div className="flex-1 space-y-2">
            <div className="h-3.5 w-32 rounded bg-gray-200" />
            <div className="h-3 w-24 rounded bg-gray-200" />
          </div>
        </div>
      ))}
    </div>
  );
}

    

Pass <DirectorySkeleton /> as the Suspense fallback and the real list drops into the same space when it arrives, so nothing on the page moves. That holds whenever you know the content's shape ahead of time.

loading.tsx, or your own boundaries

So far each example has had a single boundary, but real pages have several sections, and you decide how the boundaries fall across them.

If you use a framework, this connects to how it streams the page. Next.js streams HTML to the browser as each part finishes rendering (see React Server Components in Practice for how that works underneath), and a loading.tsx file marks the streaming boundary for the whole route, a single <Suspense> around the entire segment:

      // app/member/[id]/loading.tsx
export default function Loading() {
  return <ProfileSkeleton />;
}

    

The page streams that one skeleton in first, then swaps in the route once every query on it has resolved, so a fast header waits for the slowest section below it.

Placing the boundaries yourself lets the page arrive in stages. The header renders with no boundary, and each section that loads gets its own:

      import { Suspense } from 'react';

function ProfilePage({ userId }) {
  return (
    <main>
      <ProfileHeader userId={userId} />
      <Suspense fallback={<BioSkeleton />}>
        <Bio userId={userId} />
      </Suspense>
      <Suspense fallback={<ActivitySkeleton />}>
        <Activity userId={userId} />
      </Suspense>
    </main>
  );
}

    

The header paints immediately, and the bio and activity each stream in when their own query resolves rather than waiting for each other. All the queries start together, and the boundaries only decide what shows while they're in flight and what order the sections reveal in. With loading.tsx you get one skeleton for the whole page. With your own boundaries you get a page that fills in as its data arrives.

Nested Suspense for sections you can't size

Some sections you can't size ahead of time, like a bio that might run two lines or ten. A min-height stops its skeleton from collapsing, but it's still a guess. In the profile above, the bio and activity are separate boundaries side by side, so when the real bio comes in taller than its skeleton, it pushes the activity below it down.

Nesting the lower boundary inside the upper one prevents that:

      <Suspense fallback={<BioSkeleton />}>
  <Bio userId={userId} />
  <Suspense fallback={<ActivitySkeleton />}>
    <Activity userId={userId} />
  </Suspense>
</Suspense>

    

Now the activity can't reveal until the bio has resolved to its real height, so it always lands directly below the finished bio instead of being pushed down a moment later. Both still fetch in parallel, and the nesting only controls the order they appear in, not when they start. When every section has a fixed, known size you don't need this, because nothing changes size on the way in and side-by-side boundaries shift nothing.

Keep the current results on screen

In a search box, a naive loading state gets in the way. If the results sit behind a Suspense boundary and you let it fall back on every keystroke, the list flashes to gray and back on every character, and the user loses their place.

useTransition() prevents that. You wrap the state update that triggers the new search in a transition, and React keeps the current results on screen while the next set loads instead of dropping to the fallback:

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

  function handleChange(value) {
    startTransition(() => setQuery(value));
  }

  return (
    <div>
      <input onChange={(e) => handleChange(e.target.value)} />
      <div style={{ opacity: isPending ? 0.6 : 1 }}>
        <Suspense fallback={<DirectorySkeleton />}>
          <Results query={query} />
        </Suspense>
      </div>
    </div>
  );
}

    

Results reads its data and suspends while it loads. On the first search there's nothing on screen yet, so the boundary shows the skeleton. Every search after that is an update to content that's already on screen. During a transition, Suspense won't hide content it has already revealed behind a fallback, and keeps it up until the new content is ready. That behavior only applies to updates inside a transition, which is why the query update is wrapped in startTransition. React keeps the current results visible and sets isPending to true while the next set loads, and I use that flag to dim them so it's clear they're stale.

useDeferredValue() reaches the same result from a slightly different angle, and I cover both in the concurrent features overview.

Animating the reveal

Once content lands, a short crossfade makes it feel like it arrived on purpose rather than snapping into place. React's ViewTransition component handles this for you, though it's still in React's Canary channel. Wrap the content, and its default enter animation runs the first time it appears:

      import { ViewTransition } from 'react';

<Suspense fallback={<ProfileSkeleton />}>
  <ViewTransition enter="auto" default="none">
    <Profile userId={userId} />
  </ViewTransition>
</Suspense>

    

The default crossfade is enough, but two things around it matter. First, animate only the enter. Setting default="none" keeps the transition from firing on ordinary re-renders, so content that's already on screen doesn't re-animate every time something near it updates. The search results re-render on every keystroke, for instance, so I wouldn't wrap them in a reveal at all. Second, only animate what was actually hidden. The stats header renders outside every boundary, so it's never behind a fallback and has nothing to fade in.

The default doesn't respect a user's motion preferences, so switch the animation off for anyone who's asked for less:

      @media (prefers-reduced-motion: reduce) {
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation-duration: 0s !important;
  }
}

    

For more on the component, including exit and shared transitions, see React ViewTransition: Smooth Animations Made Simple.

Key Takeaways

  • Suspense declares your loading states. Wrap the parts that load, give them a fallback, and let React coordinate the swap.
  • Scope the boundary to the slow part. A boundary hides everything inside it until its slowest child is ready, so keep fast content outside it.
  • Design the loading sequence. One page-wide boundary like loading.tsx makes everything wait for the slowest part, while your own boundaries let each section stream in as it's ready.
  • Prefer skeletons when you know the shape. Size them to the real content so they hold the layout and keep CLS down.
  • Show progress for long loads. A skeleton or spinner only says something is happening, so past about ten seconds it reads as stuck. Use a determinate progress bar instead.
  • Nest boundaries for sections you can't size. A section whose height you can't predict should sit above its neighbor in a nested boundary, so the neighbor reveals after it and nothing gets pushed down. Both still fetch in parallel.
  • Hold the current UI with useTransition(). During a transition, Suspense won't hide already-revealed content, so the previous results stay on screen (dimmed) instead of flashing a skeleton on every change.
  • Let the default crossfade do the work. Wrap reveals in ViewTransition, animate the enter only, and respect reduced motion.

Conclusion

Most of designing loading UI is deciding where the boundaries go. Put them around the parts that are actually slow, size each fallback to the content it replaces, and the page loads in stages without shifting, instead of sitting behind one spinner until the slowest query is done.


Sources:

More certificates.dev articles

Get the latest news and updates on developer certifications. Content is updated regularly, so please make sure to bookmark this page or sign up to get the latest content directly in your inbox.