JavaScript and React course Β· Module 11: Data Fetching and APIs
Main Project: Cosmic Star Catalog
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:
- Fetching data from an API - use
fetch()with async/await - Loading state - display a spinner or skeleton while loading
- Error handling - show an error message with a retry option
- Empty data state - handle the situation when there are no results
- Search - filter results by name with debouncing
- Pagination - navigation between result pages
- 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?