JavaScript and React course Β· Module 11: Data Fetching and APIs

Loading, Error, and Empty Data States

5 min read
In this lesson4

Imagine a panel on the bridge that simply goes dark after you send a request. You don't know whether the data is on its way, the transmission broke off, or Mission Control has nothing to report. The user of your app feels exactly the same when they stare at a blank screen. Every request to a server ends in one of four situations: the data is on its way (loading), the transfer failed (error), the center has no data (empty result) or the data arrived (success). Each of them needs its own view.

The Loading/Error/Data Pattern

The most popular pattern in React relies on three state variables from useState: data for the result, loading as a "we're waiting" flag and error for the error message. We send the request in useEffect, and a try/catch/finally block makes sure every path updates the right state. First, the part that fetches the data:

1import React, { useState, useEffect } from 'react';
2
3function StarshipCatalog() {
4  const [data, setData] = useState(null);
5  const [loading, setLoading] = useState(true);
6  const [error, setError] = useState(null);
7
8  useEffect(() => {
9    async function fetchStarships() {
10      try {
11        setLoading(true);
12        setError(null);
13
14        const response = await fetch(
15          'https://swapi.dev/api/starships/'
16        );
17
18        if (!response.ok) {
19          throw new Error(`HTTP Error: ${response.status}`);
20        }
21
22        const result = await response.json();
23        setData(result.results);
24      } catch (err) {
25        setError(err.message);
26      } finally {
27        setLoading(false);
28      }
29    }
30
31    fetchStarships();
32  }, []);

loading starts as true because the request departs right after the first render. The finally block runs after both success and failure, so the loading flag never gets "stuck". Notice the manual throw for !response.ok too: without it, a 500 response would reach setData as if it were a success.

The second half of the same component decides what to show. The order of the checks matters: loading first, then error, then empty result, and only at the end the data:

1  // Loading state
2  if (loading) {
3    return <div className="loading">Scanning sector...</div>;
4  }
5
6  // Error state
7  if (error) {
8    return <div className="error">Communication error: {error}</div>;
9  }
10
11  // Empty data state
12  if (!data || data.length === 0) {
13    return <div className="empty">No ships in this sector</div>;
14  }
15
16  // Data ready to display
17  return (
18    <div>
19      <h2>Detected ships: {data.length}</h2>
20      {data.map(ship => (
21        <div key={ship.name}>
22          <h3>{ship.name}</h3>
23          <p>Model: {ship.model}</p>
24        </div>
25      ))}
26    </div>
27  );
28}

Thanks to the early return statements, the last fragment runs only when the data really exists, so data.map won't blow up on null. If you checked for an empty result before loading, the user would briefly see "No ships" before the data even arrived.

Conditional State Rendering

Bare strings work, but each state deserves its own small component. Just as a ship is made of modules, an interface is made of building blocks you can reuse across your whole galaxy of apps:

1function LoadingSpinner() {
2  return (
3    <div className="spinner-container">
4      <div className="spinner"></div>
5      <p>Loading data from command center...</p>
6    </div>
7  );
8}
9
10function ErrorMessage({ message, onRetry }) {
11  return (
12    <div className="error-container">
13      <h3>Connection lost!</h3>
14      <p>{message}</p>
15      <button onClick={onRetry}>
16        Retry
17      </button>
18    </div>
19  );
20}
21
22function EmptyState() {
23  return (
24    <div className="empty-container">
25      <p>No data in this quadrant of the galaxy</p>
26      <p>Try scanning another sector</p>
27    </div>
28  );
29}

ErrorMessage takes an onRetry prop, a function called when the button is clicked. The component itself doesn't know how to repeat the request, and that's good: the parent decides that.

Retry Mechanism

For the "Retry" button to have something to call, the fetching function has to be available outside the effect. So we move it one level up, into the component body:

1function SpaceData() {
2  const [data, setData] = useState(null);
3  const [loading, setLoading] = useState(true);
4  const [error, setError] = useState(null);
5
6  const fetchData = async () => {
7    try {
8      setLoading(true);
9      setError(null);
10      const response = await fetch(
11        'https://swapi.dev/api/people/'
12      );
13      if (!response.ok) throw new Error('Network error');
14      const result = await response.json();
15      setData(result.results);
16    } catch (err) {
17      setError(err.message);
18    } finally {
19      setLoading(false);
20    }
21  };
22
23  useEffect(() => {
24    fetchData();
25  }, []);
26
27  if (loading) return <LoadingSpinner />;
28  if (error) return <ErrorMessage message={error} onRetry={fetchData} />;
29  if (!data || data.length === 0) return <EmptyState />;
30
31  return (
32    <ul>
33      {data.map(person => (
34        <li key={person.name}>{person.name}</li>
35      ))}
36    </ul>
37  );
38}

Key difference: the fetchData function is defined outside useEffect, so we can call it again from the "Retry" button. The fetching logic hasn't changed, only its location has. The ESLint react-hooks plugin may report fetchData as a missing dependency here. As long as the function doesn't use props or state that change, the effect still works correctly.

Skeleton Loading

A spinner says "wait", but not what for. An interface skeleton, gray rectangles in place of the future cards, shows the shape of the content and makes the page "jump" less when the data arrives:

1function SkeletonCard() {
2  return (
3    <div className="skeleton-card">
4      <div className="skeleton-title"></div>
5      <div className="skeleton-text"></div>
6      <div className="skeleton-text short"></div>
7    </div>
8  );
9}
10
11function PlanetCatalog() {
12  const [planets, setPlanets] = useState(null);
13  const [loading, setLoading] = useState(true);
14
15  // ... fetch logic ...
16
17  if (loading) {
18    return (
19      <div>
20        <SkeletonCard />
21        <SkeletonCard />
22        <SkeletonCard />
23      </div>
24    );
25  }
26
27  return (
28    <div>
29      {planets.map(planet => (
30        <PlanetCard key={planet.name} planet={planet} />
31      ))}
32    </div>
33  );
34}

The fetching logic is identical to before (which is why it's shortened to a comment), only the loading view changes. I recommend skeletons for lists and cards, and keeping the spinner for short actions like submitting a form.

Good state handling is the difference between a professional application and an amateur project. In the next lesson you'll see what happens when several requests race each other, and how AbortController protects these same three states from stale data.

Remember: a pilot always sees the status of the systems on the panel, so your user should always know too whether they're waiting, something broke, or nothing was found.

Code for this lesson: App.jsx
1import React, { useState, useEffect } from 'react';
2
3// State components
4function LoadingSpinner() {
5  return (
6    <div className="state-container loading">
7      <div className="spinner"></div>
8      <p>Scanning the space sector...</p>
9    </div>
10  );
11}
12
13function ErrorMessage({ message, onRetry }) {
14  return (
15    <div className="state-container error">
16      <h3>Lost connection with command center!</h3>
17      <p>{message}</p>
18      <button className="retry-btn" onClick={onRetry}>
19        Retry scan
20      </button>
21    </div>
22  );
23}
24
25function EmptyState() {
26  return (
27    <div className="state-container empty">
28      <h3>Empty sector</h3>
29      <p>No ships detected in this quadrant</p>
30    </div>
31  );
32}
33
34function StarshipCatalog() {
35  const [data, setData] = useState(null);
36  const [loading, setLoading] = useState(true);
37  const [error, setError] = useState(null);
38
39  const fetchStarships = async () => {
40    try {
41      setLoading(true);
42      setError(null);
43
44      const response = await fetch('https://swapi.dev/api/starships/');
45
46      if (!response.ok) {
47        throw new Error(`HTTP Error: ${response.status}`);
48      }
49
50      const result = await response.json();
51      setData(result.results);
52    } catch (err) {
53      setError(err.message);
54    } finally {
55      setLoading(false);
56    }
57  };
58
59  useEffect(() => {
60    fetchStarships();
61  }, []);
62
63  if (loading) return <LoadingSpinner />;
64  if (error) return <ErrorMessage message={error} onRetry={fetchStarships} />;
65  if (!data || data.length === 0) return <EmptyState />;
66
67  return (
68    <div className="catalog">
69      <h1>Spaceship Catalog</h1>
70      <p>Detected {data.length} ships</p>
71      <div className="ship-grid">
72        {data.map(ship => (
73          <div key={ship.name} className="ship-card">
74            <h3>{ship.name}</h3>
75            <p><strong>Model:</strong> {ship.model}</p>
76            <p><strong>Manufacturer:</strong> {ship.manufacturer}</p>
77            <p><strong>Speed:</strong> {ship.max_atmosphering_speed}</p>
78            <p><strong>Crew:</strong> {ship.crew}</p>
79          </div>
80        ))}
81      </div>
82    </div>
83  );
84}
85
86export default StarshipCatalog;

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. In what order should states be checked in a component that fetches data?

  2. 2. How many state variables (useState) do we need in the standard data fetching pattern in React?

Hands-on tasks in the game

  • Code editor

    Loading, error, and empty states

  • Horizontal ordering

    Arrange the async/await syntax to fetch JSON data from an API:

  • Code editor

    Retry after error

Useful articles