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

Main Project: Cosmic Star Catalog

6 min read
In this lesson3

You already know every ship system on its own: the transmitter (fetch), the status panel, the race protection, pagination. But on a real mission they all run at once, and bugs show up where the systems meet. Time to combine everything you've learned! In this project you'll build a complete "Cosmic Star Catalog" application that fetches data from an API, handles loading and error states, allows searching and supports pagination.

Project Requirements

Your application should include:

  1. Fetching data from an API - use fetch() with async/await
  2. Loading state - display a spinner or skeleton while loading
  3. Error handling - show an error message with a retry option
  4. Empty data state - handle the situation when there are no results
  5. Search - filter results by name with debouncing
  6. Pagination - navigation between result pages
  7. Custom hook useFetch - extract the data fetching logic

The only new concept is debouncing: instead of sending a request on every keystroke, we wait until the user stops typing for half a second. Typing the word "Luke" then means one request, not four.

Application Architecture

Before you write code, sketch the ship's modules. Each component has one job, and all the network logic sits in a single hook:

1// Component structure:
2// App
3// β”œβ”€β”€ SearchBar (search input)
4// β”œβ”€β”€ useFetch (custom hook)
5// β”œβ”€β”€ LoadingSpinner / ErrorMessage / EmptyState
6// β”œβ”€β”€ StarList (results list)
7// β”‚   └── StarCard (single result)
8// └── Pagination (page navigation)

In our implementation SearchBar and StarList are written directly inside App as an <input> and a grid of cards, for brevity. When the app grows, you'll extract them without changing the logic.

Implementation

We build from the bottom up, starting with the engine. The useFetch hook is the safe fetch from the race conditions lesson, with one addition: a reloadKey counter. It's in the effect's dependencies, so incrementing it through refetch sends the request again to the same address:

1import React, { useState, useEffect } from 'react';
2
3// Custom Hook - useFetch
4function useFetch(url) {
5  const [data, setData] = useState(null);
6  const [loading, setLoading] = useState(true);
7  const [error, setError] = useState(null);
8  const [reloadKey, setReloadKey] = useState(0);
9
10  useEffect(() => {
11    if (!url) return;
12    const controller = new AbortController();
13
14    async function fetchData() {
15      try {
16        setLoading(true);
17        setError(null);
18        const response = await fetch(url, {
19          signal: controller.signal
20        });
21        if (!response.ok) {
22          throw new Error(`HTTP ${response.status}`);
23        }
24        const result = await response.json();
25        setData(result);
26      } catch (err) {
27        if (err.name !== 'AbortError') {
28          setError(err.message);
29        }
30      } finally {
31        if (!controller.signal.aborted) {
32          setLoading(false);
33        }
34      }
35    }
36
37    fetchData();
38    return () => controller.abort();
39  }, [url, reloadKey]);
40
41  // Changing reloadKey runs the effect again
42  const refetch = () => setReloadKey(k => k + 1);
43
44  return { data, loading, error, refetch };
45}

When url changes (a new page or a new search), the cleanup cancels the previous request, so a race can't overwrite the results. Simply setting the same state value, for example setPage(p => p), wouldn't repeat anything, because React skips the render when the state doesn't change. That's why we need a separate counter.

Next come the three status panels from the loading and errors lesson. EmptyState receives the query so it can say what wasn't found:

1// Helper components
2function LoadingSpinner() {
3  return (
4    <div style={{ textAlign: 'center', padding: '40px' }}>
5      <div className="spinner"></div>
6      <p>Scanning the galaxy...</p>
7    </div>
8  );
9}
10
11function ErrorMessage({ message, onRetry }) {
12  return (
13    <div className="error-panel">
14      <h3>Connection lost!</h3>
15      <p>{message}</p>
16      <button onClick={onRetry}>Retry scan</button>
17    </div>
18  );
19}
20
21function EmptyState({ query }) {
22  return (
23    <div className="empty-panel">
24      <p>No results {query ? `for "${query}"` : ''}</p>
25      <p>Try a different query</p>
26    </div>
27  );
28}

None of these components knows anything about the network, they only receive props. The star card and the pagination are just as simple. Pagination keeps no state of its own, it only reports a page change through onPageChange:

1function StarCard({ star }) {
2  return (
3    <div className="star-card">
4      <h3>{star.name}</h3>
5      <p>Height: {star.height} cm</p>
6      <p>Mass: {star.mass} kg</p>
7      <p>Hair color: {star.hair_color}</p>
8      <p>Birth year: {star.birth_year}</p>
9    </div>
10  );
11}
12
13function Pagination({ page, totalPages, onPageChange }) {
14  return (
15    <div className="pagination">
16      <button
17        onClick={() => onPageChange(page - 1)}
18        disabled={page === 1}
19      >
20        Previous
21      </button>
22      <span>Page {page} of {totalPages}</span>
23      <button
24        onClick={() => onPageChange(page + 1)}
25        disabled={page >= totalPages}
26      >
27        Next
28      </button>
29    </div>
30  );
31}

The page >= totalPages condition is safer than ===, because it still works when a new search reduces the number of pages.

Finally, the command bridge: App. Debouncing is an effect with setTimeout: every keystroke clears the previous timer, so debouncedQuery changes only after 500 ms of silence. We pass the search text through encodeURIComponent so spaces and special characters don't break the address:

1// Main application
2function App() {
3  const [searchQuery, setSearchQuery] = useState('');
4  const [debouncedQuery, setDebouncedQuery] = useState('');
5  const [page, setPage] = useState(1);
6
7  // Search debounce
8  useEffect(() => {
9    const timer = setTimeout(() => {
10      setDebouncedQuery(searchQuery);
11      setPage(1); // Reset page on new search
12    }, 500);
13    return () => clearTimeout(timer);
14  }, [searchQuery]);
15
16  // Build URL with parameters
17  const url = debouncedQuery
18    ? `https://swapi.dev/api/people/?search=${encodeURIComponent(debouncedQuery)}&page=${page}`
19    : `https://swapi.dev/api/people/?page=${page}`;
20
21  const { data, loading, error, refetch } = useFetch(url);
22  const totalPages = data ? Math.ceil(data.count / 10) : 1;
23
24  return (
25    <div className="app">
26      <h1>Cosmic Star Catalog</h1>
27
28      <input
29        type="text"
30        value={searchQuery}
31        onChange={e => setSearchQuery(e.target.value)}
32        placeholder="Search the galaxy..."
33        className="search-input"
34      />
35
36      {loading && <LoadingSpinner />}
37
38      {error && (
39        <ErrorMessage
40          message={error}
41          onRetry={refetch}
42        />
43      )}
44
45      {!loading && !error && data && (
46        <>
47          {data.results.length === 0 ? (
48            <EmptyState query={debouncedQuery} />
49          ) : (
50            <>
51              <p>Found: {data.count} results</p>
52              <div className="star-grid">
53                {data.results.map(star => (
54                  <StarCard key={star.name} star={star} />
55                ))}
56              </div>
57              <Pagination
58                page={page}
59                totalPages={totalPages}
60                onPageChange={setPage}
61              />
62            </>
63          )}
64        </>
65      )}
66    </div>
67  );
68}
69
70export default App;

The URL is computed from state on every render rather than stored separately, so it always matches page and debouncedQuery. The "Retry scan" button now calls refetch and really sends the request again. A new search resets the page to 1, because page 3 for "Luke" usually doesn't exist.

This project combines all the techniques from this module: the fetch API, loading/error/empty states, useEffect, the useFetch custom hook, AbortController, debouncing and pagination. Try building it yourself, then compare with the solution above. My advice: start with the hook and a single list, and add search at the end. As an extension, you can swap useFetch for useQuery from TanStack Query and see how much code disappears.

Remember: a good app is a ship in which every module does one thing, and the useFetch hook is its shared communication system.

Code for this lesson: App.jsx
1import React, { useState, useEffect } from 'react';
2
3// Custom Hook - useFetch
4function useFetch(url) {
5  const [data, setData] = useState(null);
6  const [loading, setLoading] = useState(true);
7  const [error, setError] = useState(null);
8
9  useEffect(() => {
10    if (!url) {
11      setLoading(false);
12      return;
13    }
14
15    const controller = new AbortController();
16
17    async function fetchData() {
18      try {
19        setLoading(true);
20        setError(null);
21
22        const response = await fetch(url, { signal: controller.signal });
23
24        if (!response.ok) {
25          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
26        }
27
28        const result = await response.json();
29        setData(result);
30      } catch (err) {
31        if (err.name !== 'AbortError') {
32          setError(err.message);
33        }
34      } finally {
35        setLoading(false);
36      }
37    }
38
39    fetchData();
40    return () => controller.abort();
41  }, [url]);
42
43  return { data, loading, error };
44}
45
46// Helper components
47function LoadingSpinner() {
48  return (
49    <div className="state-box">
50      <div className="spinner"></div>
51      <p>Scanning the galaxy...</p>
52    </div>
53  );
54}
55
56function ErrorMessage({ message, onRetry }) {
57  return (
58    <div className="state-box error-box">
59      <h3>Connection lost!</h3>
60      <p>{message}</p>
61      <button className="retry-btn" onClick={onRetry}>Retry scan</button>
62    </div>
63  );
64}
65
66function EmptyState({ query }) {
67  return (
68    <div className="state-box empty-box">
69      <h3>Sektor pusty</h3>
70      <p>No results found {query ? `for "${query}"` : ''}</p>
71    </div>
72  );
73}
74
75function StarCard({ star }) {
76  return (
77    <div className="star-card">
78      <h3>{star.name}</h3>
79      <div className="star-stats">
80        <div className="stat">
81          <span className="stat-label">Height</span>
82          <span className="stat-value">{star.height} cm</span>
83        </div>
84        <div className="stat">
85          <span className="stat-label">Weight</span>
86          <span className="stat-value">{star.mass} kg</span>
87        </div>
88        <div className="stat">
89          <span className="stat-label">Born</span>
90          <span className="stat-value">{star.birth_year}</span>
91        </div>
92        <div className="stat">
93          <span className="stat-label">Hair</span>
94          <span className="stat-value">{star.hair_color}</span>
95        </div>
96      </div>
97    </div>
98  );
99}
100
101function Pagination({ page, totalPages, onPageChange }) {
102  return (
103    <div className="pagination">
104      <button
105        onClick={() => onPageChange(page - 1)}
106        disabled={page <= 1}
107      >
108        Previous
109      </button>
110      <span className="page-info">Page {page} of {totalPages}</span>
111      <button
112        onClick={() => onPageChange(page + 1)}
113        disabled={page >= totalPages}
114      >
115        Next
116      </button>
117    </div>
118  );
119}
120
121// Main app
122function App() {
123  const [searchQuery, setSearchQuery] = useState('');
124  const [debouncedQuery, setDebouncedQuery] = useState('');
125  const [page, setPage] = useState(1);
126
127  // Debounce
128  useEffect(() => {
129    const timer = setTimeout(() => {
130      setDebouncedQuery(searchQuery);
131      setPage(1);
132    }, 500);
133    return () => clearTimeout(timer);
134  }, [searchQuery]);
135
136  // URL with parameters
137  const url = debouncedQuery
138    ? `https://swapi.dev/api/people/?search=${debouncedQuery}&page=${page}`
139    : `https://swapi.dev/api/people/?page=${page}`;
140
141  const { data, loading, error } = useFetch(url);
142  const totalPages = data ? Math.ceil(data.count / 10) : 1;
143
144  return (
145    <div className="app">
146      <h1>Cosmic Star Catalog</h1>
147
148      <div className="search-bar">
149        <input
150          type="text"
151          value={searchQuery}
152          onChange={e => setSearchQuery(e.target.value)}
153          placeholder="Search the galaxy..."
154          className="search-input"
155        />
156      </div>
157
158      {loading && <LoadingSpinner />}
159
160      {error && (
161        <ErrorMessage
162          message={error}
163          onRetry={() => { setPage(1); setDebouncedQuery(debouncedQuery + ' '); }}
164        />
165      )}
166
167      {!loading && !error && data && (
168        <>
169          {data.results.length === 0 ? (
170            <EmptyState query={debouncedQuery} />
171          ) : (
172            <>
173              <p className="results-count">Found: {data.count} results</p>
174              <div className="star-grid">
175                {data.results.map(star => (
176                  <StarCard key={star.name} star={star} />
177                ))}
178              </div>
179              {totalPages > 1 && (
180                <Pagination
181                  page={page}
182                  totalPages={totalPages}
183                  onPageChange={setPage}
184                />
185              )}
186            </>
187          )}
188        </>
189      )}
190    </div>
191  );
192}
193
194export default App;

Spotted a mistake in this lesson?

Useful articles