JavaScript and React course Β· Module 16: Error Handling and Suspense

Fallback UI and Skeleton Loaders

6 min read
In this lesson5

Imagine that you open the navigation panel and for two seconds you see an empty black screen. Has the system frozen? Is the ship even responding? A moment later the whole panel pops in and shifts the buttons you were just aiming at. This is not a failure, and yet the crew loses trust in the station. In space, when a station module is initializing, the crew sees progress indicators and holographic outlines of the upcoming interface. In React, we have skeleton loaders and fallback UI - placeholder interface elements that inform the user about ongoing loading.

Why Is Fallback UI Important?

Good loading is not just a spinner. The user should feel that something is happening and know what to expect. A fallback that takes up as much space as the target content also prevents the layout from jumping when the data finally arrives.

Loading Quality Levels (from Worst to Best)

Compare four versions of the same Suspense. Only the fallback prop changes, that is, what React shows until the component inside is ready:

1// 1. Nothing (worst) - user doesn't know what's happening
2<Suspense fallback={null}>
3
4// 2. Simple text
5<Suspense fallback={<p>Loading...</p>}>
6
7// 3. Spinner/Loader
8<Suspense fallback={<Spinner />}>
9
10// 4. Skeleton loader (best)
11<Suspense fallback={<ContentSkeleton />}>

The loading mechanics are identical in every variant, only the message for the crew differs. For the record: Suspense is a built-in React component that shows the fallback while something inside it is "waiting". According to react.dev, it reacts to components loaded lazily with React.lazy() and to promises read with the use hook, not to an arbitrary fetch inside an effect.

Skeleton Loaders

A skeleton loader is a placeholder UI that mimics the shape and layout of the target content. The user sees a "skeleton" of the page that gradually fills in with real content. It's like a hologram of a module displayed before the module physically docks with the station.

Basic CSS Skeleton

The card skeleton consists of ordinary, empty div elements. Each one gets a class describing its shape and the shared animation class skeleton-pulse:

1function CardSkeleton() {
2  return (
3    <div className="skeleton-card">
4      <div className="skeleton-image skeleton-pulse" />
5      <div className="skeleton-title skeleton-pulse" />
6      <div className="skeleton-text skeleton-pulse" />
7      <div className="skeleton-text skeleton-pulse short" />
8    </div>
9  );
10}

The component has no state and no props, because its only job is to take up space. CSS does all the visual work. The linear-gradient creates a lighter band, background-size: 200% 100% makes the background twice as wide as the element, and the @keyframes rule moves it from right to left:

1.skeleton-pulse {
2  background: linear-gradient(
3    90deg,
4    #e0e0e0 25%,
5    #f0f0f0 50%,
6    #e0e0e0 75%
7  );
8  background-size: 200% 100%;
9  animation: pulse 1.5s infinite;
10  border-radius: 4px;
11}
12
13@keyframes pulse {
14  0% { background-position: 200% 0; }
15  100% { background-position: -200% 0; }
16}
17
18.skeleton-image {
19  width: 100%;
20  height: 200px;
21  margin-bottom: 12px;
22}
23
24.skeleton-title {
25  width: 60%;
26  height: 24px;
27  margin-bottom: 8px;
28}
29
30.skeleton-text {
31  width: 100%;
32  height: 16px;
33  margin-bottom: 6px;
34}
35
36.skeleton-text.short {
37  width: 40%;
38}

The dimensions in .skeleton-image, .skeleton-title and .skeleton-text match the real card, so nothing jumps after loading. The .short class shortens the last line, because a real paragraph rarely ends exactly at the edge.

Skeletons for Different Content Types

One skeleton doesn't fit everything. A crew list looks different from a dashboard full of statistics, so we build separate shapes.

Element List

The list skeleton accepts a count prop, and Array.from({ length: count }) creates an array of that length that we can iterate over:

1function ListSkeleton({ count = 3 }) {
2  return (
3    <div className="list-skeleton">
4      {Array.from({ length: count }).map((_, i) => (
5        <div key={i} className="list-item-skeleton">
6          <div className="avatar-skeleton skeleton-pulse" />
7          <div className="info-skeleton">
8            <div className="skeleton-pulse name-skeleton" />
9            <div className="skeleton-pulse detail-skeleton" />
10          </div>
11        </div>
12      ))}
13    </div>
14  );
15}

Using the index as the key is fine here, because the items never change order and have no state of their own.

Dashboard with Multiple Sections

You assemble bigger skeletons from smaller ones, exactly like a real dashboard is made of cards and lists:

1function DashboardSkeleton() {
2  return (
3    <div className="dashboard-skeleton">
4      <div className="stats-row">
5        <StatCardSkeleton />
6        <StatCardSkeleton />
7        <StatCardSkeleton />
8      </div>
9      <div className="main-content">
10        <ChartSkeleton />
11        <ListSkeleton count={5} />
12      </div>
13    </div>
14  );
15}

The grid layout is the same as in the target dashboard, only the building blocks inside change.

Loading Patterns

Progressive Loading

Load important content first, the rest loads in the background. Each less important section gets its own Suspense, so it doesn't block the others:

1function MissionDashboard() {
2  return (
3    <div>
4      {/* Critical content - no lazy loading */}
5      <MissionStatus />
6
7      {/* Less critical - lazy loading with skeleton */}
8      <Suspense fallback={<ChartSkeleton />}>
9        <MissionChart />
10      </Suspense>
11
12      {/* Least important - loads last */}
13      <Suspense fallback={<ListSkeleton count={5} />}>
14        <MissionHistory />
15      </Suspense>
16    </div>
17  );
18}

We assume here that MissionChart and MissionHistory are components created with React.lazy(). MissionStatus is imported normally, so it appears right away and has no fallback of its own.

Inline Loading States

You don't always need Suspense. For data loaded asynchronously in an effect, use loading states, because Suspense doesn't know about a fetch called in useEffect:

1function CrewMember({ id }) {
2  const [member, setMember] = useState(null);
3  const [loading, setLoading] = useState(true);
4
5  useEffect(() => {
6    setLoading(true); // New id - show the skeleton again
7    fetchCrewMember(id)
8      .then(setMember)
9      .finally(() => setLoading(false));
10  }, [id]);
11
12  if (loading) return <MemberSkeleton />;
13
14  return (
15    <div className="crew-member">
16      <img src={member.avatar} alt={member.name} />
17      <h3>{member.name}</h3>
18      <p>{member.role}</p>
19    </div>
20  );
21}

The setLoading(true) line at the start of the effect makes the skeleton come back after id changes, instead of briefly showing the previous crew member. finally turns loading off both after success and after an error. You'll add the error handling itself in the next lesson.

Best Practices

  1. Skeleton should mirror the content layout - same size and shape
  2. Pulse animation - gives a sense that something is happening
  3. Avoid flickering - if loading takes <300ms, don't show skeletons
  4. Use different skeletons for different content types
  5. Maintain proportions - the skeleton should take up the same space as the real content

React partly helps with point 3 on its own: according to react.dev, it reveals suspended content at most once every 300 ms, so boundaries that become ready within that window appear together. My advice: always start with a skeleton, and keep spinners for short actions inside buttons. In the next lesson you'll combine skeletons with error boundaries, so that a module that fails to load doesn't leave an empty hole.

Remember: a good skeleton is a hologram of the module, showing the crew the shape of what is about to dock.

Code for this lesson: App.jsx
1import React, { useState, useEffect } from 'react';
2import './styles.css';
3
4// Skeleton for a mission card
5function MissionCardSkeleton() {
6  return (
7    <div className="card skeleton-card">
8      <div className="skeleton-img skeleton-pulse" />
9      <div className="skeleton-h skeleton-pulse" />
10      <div className="skeleton-p skeleton-pulse" />
11      <div className="skeleton-p skeleton-pulse short" />
12    </div>
13  );
14}
15
16// Skeleton for the crew member list
17function CrewListSkeleton({ count = 3 }) {
18  return (
19    <div className="crew-skeleton">
20      {Array.from({ length: count }).map((_, i) => (
21        <div key={i} className="crew-item-skel">
22          <div className="avatar-skel skeleton-pulse" />
23          <div className="crew-info-skel">
24            <div className="skeleton-name skeleton-pulse" />
25            <div className="skeleton-role skeleton-pulse" />
26          </div>
27        </div>
28      ))}
29    </div>
30  );
31}
32
33// Real mission card
34function MissionCard({ mission }) {
35  return (
36    <div className="card">
37      <div className="card-icon">{mission.icon}</div>
38      <h3>{mission.name}</h3>
39      <p>{mission.description}</p>
40      <span className={`badge ${mission.status}`}>{mission.status}</span>
41    </div>
42  );
43}
44
45// Real crew list
46function CrewList({ members }) {
47  return (
48    <div className="crew-list">
49      {members.map(m => (
50        <div key={m.id} className="crew-item">
51          <div className="avatar">{m.avatar}</div>
52          <div>
53            <strong>{m.name}</strong>
54            <p className="role">{m.role}</p>
55          </div>
56        </div>
57      ))}
58    </div>
59  );
60}
61
62export default function App() {
63  const [missions, setMissions] = useState(null);
64  const [crew, setCrew] = useState(null);
65
66  useEffect(() => {
67    // Simulating mission loading (faster)
68    setTimeout(() => {
69      setMissions([
70        { id: 1, icon: '', name: 'Mars Exploration', description: 'Surface survey', status: 'active' },
71        { id: 2, icon: '', name: 'Jupiter Reconnaissance', description: 'Atmosphere analysis', status: 'pending' },
72      ]);
73    }, 1200);
74
75    // Simulating crew loading (slower)
76    setTimeout(() => {
77      setCrew([
78        { id: 1, avatar: '', name: 'Captain Nova', role: 'Commander' },
79        { id: 2, avatar: '', name: 'Dr. Stellar', role: 'Scientist' },
80        { id: 3, avatar: '', name: 'Tech Orion', role: 'Engineer' },
81      ]);
82    }, 2500);
83  }, []);
84
85  return (
86    <div className="app">
87      <h1>Skeleton Loaders</h1>
88      <p className="subtitle">Watch as skeletons turn into content</p>
89
90      <section>
91        <h2>Missions</h2>
92        {missions ? (
93          <div className="cards-grid">
94            {missions.map(m => <MissionCard key={m.id} mission={m} />)}
95          </div>
96        ) : (
97          <div className="cards-grid">
98            <MissionCardSkeleton />
99            <MissionCardSkeleton />
100          </div>
101        )}
102      </section>
103
104      <section>
105        <h2>Crew</h2>
106        {crew ? <CrewList members={crew} /> : <CrewListSkeleton count={3} />}
107      </section>
108    </div>
109  );
110}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. Why is a skeleton loader better than a simple spinner?

Hands-on tasks in the game

  • Code editor

    Implement MissionCardSkeleton

  • Vertical ordering

    Order loading quality levels from worst to best:

  • Click in order

    Click elements in order for building a skeleton pulse animation:

Useful articles