Almost every real app talks to a server. A shop loads products, a news site loads articles, a dashboard loads numbers. In this lesson you will learn how to load data in React in a way that is safe: you will show a loading message, handle errors, and avoid a few bugs that catch many people.
The fetch function
fetch is built into the browser. It sends a request and gives back a promise, a placeholder for a result that arrives later. You wait for it with await inside an async function.
async function loadPosts() {
const response = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5');
if (!response.ok) {
throw new Error('Request failed with status ' + response.status);
}
const posts = await response.json();
return posts;
}
fetch(url)starts the request.response.okis true for success statuses (200 to 299). `fetch` does not throw on a 404 or 500. It only throws when the network itself fails. So you must checkresponse.okyourself.response.json()reads the body and turns the JSON text into JavaScript data. It is also a promise, so weawaitit.
The three states of a request
While waiting, a component is in one of three situations: loading, error or success. Keep one piece of state for each of the first two, and one for the data:
import { useState, useEffect } from 'react';
function PostList() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
async function load() {
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5');
if (!response.ok) throw new Error('Status ' + response.status);
setPosts(await response.json());
} catch (err) {
setError('Could not load posts. Please try again.');
} finally {
setLoading(false);
}
}
load();
}, []);
if (loading) return <p>Loading posts...</p>;
if (error) return <p role="alert">{error}</p>;
if (posts.length === 0) return <p>No posts yet.</p>;
return (
<ul>
{posts.map((post) => <li key={post.id}>{post.title}</li>)}
</ul>
);
}
Read it slowly. We start with loading true. Inside try we fetch and check the status. If anything fails, catch sets a friendly message (do not show raw technical errors to visitors). finally runs in both cases and turns off loading. Then, below, we use early returns, as the conditional rendering lesson showed, so each screen is clear.
The old response problem (race condition)
Imagine a page that loads user data when userId changes. The person clicks user 1, then quickly user 2. Two requests are in flight. If the answer for user 1 arrives after the answer for user 2, the page shows user 1's data while the heading says user 2. This is a race condition, and it is a real bug in real apps.
The fix is to use the cleanup function of the effect to cancel the old request. The browser gives us AbortController for this:
function User({ userId }) {
const [user, setUser] = useState(null);
const [error, setError] = useState('');
useEffect(() => {
const controller = new AbortController();
setUser(null);
setError('');
async function load() {
try {
const response = await fetch('/api/users/' + userId, { signal: controller.signal });
if (!response.ok) throw new Error('Status ' + response.status);
setUser(await response.json());
} catch (err) {
if (err.name !== 'AbortError') setError('Could not load this user.');
}
}
load();
return () => controller.abort(); // cancel the old request
}, [userId]);
if (error) return <p role="alert">{error}</p>;
if (!user) return <p>Loading...</p>;
return <h2>{user.name}</h2>;
}
When userId changes, React runs the cleanup first, which calls controller.abort(). The old request stops, and fetch throws an AbortError. We ignore that special error because it is not a real problem. Only the newest request can set the state. This small pattern makes your data loading safe.
Sending data with POST
To create something on a server, send a POST request with a JSON body. Do this in an event handler (such as form submit), not in an effect:
async function handleSubmit(event) {
event.preventDefault();
setSaving(true);
try {
const response = await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
if (!response.ok) throw new Error('Status ' + response.status);
const saved = await response.json();
setNotes([...notes, saved]);
setText('');
} catch (err) {
setError('Could not save your note.');
} finally {
setSaving(false);
}
}
method: 'POST'says we are creating.- The
Content-Typeheader tells the server the body is JSON. JSON.stringifyturns your JavaScript object into JSON text.- A
savingstate lets you disable the button, so people cannot click twice.
Put the logic in one helper
If every component repeats fetch, headers and error checks, you get a lot of copied code. Write a small helper once:
// api.js
export async function api(path, options = {}) {
const response = await fetch('/api' + path, {
headers: { 'Content-Type': 'application/json' },
...options,
body: options.body ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) {
throw new Error('Request failed: ' + response.status);
}
return response.status === 204 ? null : response.json();
}
// use it
// const notes = await api('/notes');
// await api('/notes', { method: 'POST', body: { text: 'Buy milk' } });
Now each component can focus on its screen. Later you can add things like a login token in one place. In lesson 25 you will meet a library, TanStack Query, that does loading, errors, caching and refreshing for you, and you will see how much code it saves.
Good habits
- Always show a loading state, an error state and an empty state.
- Show friendly messages to people, and write the technical details to the console.
- Cancel or ignore old requests when inputs change.
- Never put secret keys in React code. Everything in the browser can be read by visitors. Keep secrets on a server.
- For text search, wait until the person stops typing (debounce) before sending a request. Lesson 21 shows how.
Common mistakes
- Not checking
response.okand then trying to read a 404 page as data. - Making the effect function
asyncdirectly. - Forgetting the dependency array, so the fetch runs after every draw in a loop.
- No loading state, so the page flashes or shows an empty list that looks like “no data”.
- Forgetting
JSON.stringifyand theContent-Typeheader for POST.
A checklist for every screen that loads data
Before you call a data screen finished, walk through this short list. It catches most of the problems people find in real apps.
- First load: does the person see a loading message, and not a blank page?
- Success: is the data shown, and do the keys and empty cases work?
- Failure: turn off your internet or break the URL. Is there a clear message and a way to try again?
- Slow network: use throttling in the browser tools. Does the page still look right?
- Quick changes: click between items quickly. Does the page always end on the item you clicked last?
- Leaving the page: does the request stop, or at least not cause an error in the console?
If every answer is yes, your screen is in good shape. Libraries such as TanStack Query (lesson 25) give you most of these answers for free, but you now understand what they do for you.
Questions beginners often ask
Why does my request run twice in development? StrictMode runs effects twice on purpose. With the cleanup that cancels the old request, nothing breaks. It does not happen in the built site.
Where should I keep the API address? In an environment variable, such as VITE_API_URL, so you can change it between your computer and the live site without editing code.
What about sending a login token? Add it in your api helper, in one place, as a header such as Authorization. Do not copy it into every component, and never print it in logs.
How do I refresh data after saving? After a successful POST, either add the new item to state, or call your loading function again. Libraries such as TanStack Query do this with one call, as lesson 25 shows.
Your task
- Load five posts from the sample API above and show Loading, an error message and the list.
- Break the URL on purpose and check that your error message shows.
- Add a button that loads the next five posts, and make sure old responses cannot overwrite new ones.
- Create a small
apihelper and use it in your component.
How to check it worked
- You see Loading first, then the posts.
- A wrong URL shows your friendly error and not a crash.
- Clicking the button quickly never shows posts in the wrong order.
Summary
- Use
fetchinside an effect (or an event handler for POST). Checkresponse.ok, because fetch does not throw on 404 or 500. - Keep loading, error and data state, and show each one clearly.
- Use
AbortControllerand the effect cleanup to avoid showing old responses. - Put repeated fetch code in one helper, and never put secrets in React code.