Every Request Has Four Outcomes
Almost every useful app gets its data from a server, and the first thing to accept is that the data is not there when the component first renders. A request takes time, and it can fail. A component that only handles the case where the data arrived will crash on its first render and show a blank screen whenever the network misbehaves.
So plan for four outcomes from the start: the request is in flight, the request failed, the request succeeded but returned nothing, and the request succeeded with data. Three of those need something on screen — a loading indicator, an error message with a way to retry, and an empty state that says no results rather than showing nothing at all.
In practice that means three pieces of state — the data, an error, and a loading flag — plus a condition on the data's length for the empty case. Writing all four branches before you write the happy path takes two extra minutes and removes most of the bugs people spend an afternoon on.
It also gives you a reliable component shape: state at the top, an effect that performs the request, early returns for loading and error, then the real UI. Once you have written it three times it becomes automatic, and it is exactly what the data libraries at the end of this lesson package up for you.
// The shape every data component has
function Something() {
const [data, setData] = useState(null); // null, not [] — 'not loaded yet'
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => { /* fetch */ }, []);
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} onRetry={reload} />;
if (data.length === 0) return <p>Nothing here yet.</p>;
return <List items={data} />;
} - Initialise a list to
nullrather than[]if you need to distinguish still loading from loaded and empty. Starting at[]makes those two situations look identical, and users see no results for a moment before their data appears.
The Standard Fetch Effect
Fetching is a side effect — it reaches outside React — so it belongs in useEffect. The effect runs after the first render, the request goes out, and when the response arrives you set state, which renders the component again with the data.
Two details in the code below are easy to skip and both cause real bugs. The first is res.ok. Unlike some libraries, fetch does not throw on a 404 or a 500 — those are successful HTTP exchanges as far as the browser is concerned, and your .catch will never see them. Without the res.ok check, a 404 page gets passed to res.json(), which fails with a confusing parse error about unexpected characters. Check res.ok and throw yourself.
The second is the cleanup flag. If userId changes while a request is still in flight, two responses are racing and the slower one might land last, leaving the wrong data on screen. Setting ignore in the cleanup means only the newest effect is allowed to write to state.
Note also that the effect callback cannot be async — React expects it to return either nothing or a cleanup function, and an async function returns a Promise. Either use .then chains, or declare an async function inside the effect and call it immediately.
import { useState, useEffect } from 'react';
function CourseList({ categoryId }) {
const [courses, setCourses] = useState(null);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let ignore = false;
async function load() {
setIsLoading(true);
setError(null);
try {
const res = await fetch(`/api/courses?category=${categoryId}`);
if (!res.ok) throw new Error(`Server returned ${res.status}`);
const data = await res.json();
if (!ignore) setCourses(data);
} catch (err) {
if (!ignore) setError(err.message);
} finally {
if (!ignore) setIsLoading(false);
}
}
load();
return () => { ignore = true; };
}, [categoryId]);
if (isLoading) return <p>Loading courses…</p>;
if (error) return <p className="error">Could not load courses. {error}</p>;
if (courses.length === 0) return <p>No courses in this category yet.</p>;
return (
<ul>
{courses.map(c => <li key={c.id}>{c.title}</li>)}
</ul>
);
} - The fetch goes in
useEffect, never during render - List whatever the URL depends on in the dependency array
- Check
res.ok—fetchdoes not throw on 404 or 500 - Reset the error before each new request, or an old failure lingers
- Use the ignore flag so a slow response cannot overwrite a newer one
- The effect callback itself cannot be
async
- The error thrown by
fetchand caught incatchis usually a network-level failure: no connection, DNS problem, or a request blocked by the browser. HTTP errors from a server that answered are your job to detect, which is exactly what theres.okline is doing.
Sending Data: POST and Mutations
Reading data happens in an effect because it should happen when the component appears. Writing data is different: it happens because the user did something, so it belongs in an event handler. Putting a POST request in an effect that watches a flag is a common mistake and leads to requests firing at surprising moments — including twice, when a component remounts.
A write request needs a method, headers and a body. JSON.stringify turns your object into the request body, and the Content-Type header tells the server how to read it. Forget the header and many servers will hand your code an empty object, which is a frustrating thing to debug from the browser side.
Wrap the whole thing in submitting state so the button disables while the request is in flight, and decide what happens afterwards: refetch the list, or update local state with the server's response. Prefer whatever the server returns, since it may have filled in an id, a timestamp or a computed field that your local guess does not have.
You will hear about optimistic updates, where the UI changes immediately and reverts if the request fails. It makes an app feel fast, but it means writing the revert path too. Get the straightforward version working first.
function AddCourseForm({ onCreated }) {
const [title, setTitle] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState(null);
async function handleSubmit(e) {
e.preventDefault();
setIsSaving(true);
setError(null);
try {
const res = await fetch('/api/courses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
});
if (!res.ok) throw new Error(`Save failed (${res.status})`);
const created = await res.json(); // the server's version, with its id
onCreated(created);
setTitle('');
} catch (err) {
setError(err.message);
} finally {
setIsSaving(false);
}
}
return (
<form onSubmit={handleSubmit}>
<input value={title} onChange={e => setTitle(e.target.value)} />
<button type="submit" disabled={isSaving || !title.trim()}>
{isSaving ? 'Saving…' : 'Add course'}
</button>
{error && <p className="error">{error}</p>}
</form>
);
} - Disabling the button while saving is not cosmetic. Without it, an impatient double-click sends the request twice and creates two records — a bug that reaches production regularly and is invisible on a fast local connection.
The Problems That Are Not React's Fault
Several fetching problems have nothing to do with React, but they show up while you are writing React, so it is worth naming them.
CORS. If the console says a request was blocked by CORS policy, the browser is refusing to let your page read a response from a different origin because that server did not send the header permitting it. Nothing you change in your React code will fix this — the decision belongs to the server. Either the API needs to allow your origin, or you route the request through your own backend. In development, Vite's proxy setting can stand in for that.
Secrets. Anything in your React bundle is public. Vite exposes environment variables that begin with VITE_ through import.meta.env, and it does so by writing their values into the built JavaScript. That is fine for a public API base URL. It is not a hiding place for a private API key, a payment secret or a database password — those must live on a server you control, with your frontend calling your own endpoint.
Environments. Hard-coding http://localhost:3000 works until you deploy. Put the base URL in an environment variable so development and production can differ without editing code.
// .env.development
VITE_API_URL=http://localhost:3000/api
// .env.production
VITE_API_URL=https://api.example.com
// Anywhere in your code
const BASE = import.meta.env.VITE_API_URL;
fetch(`${BASE}/courses`);
// Only VITE_-prefixed variables are exposed, and they are baked into
// the bundle at build time. Treat every one of them as public.
// vite.config.js — a dev-only proxy, which also sidesteps CORS locally
export default defineConfig({
plugins: [react()],
server: {
proxy: { '/api': 'http://localhost:3000' }
}
}); - Blocked by CORS policy — a server-side setting, not a React bug
- Unexpected token < in JSON — you parsed an HTML error page; check
res.ok - Works locally, fails deployed — a hard-coded
localhostURL - 401 or 403 — a missing or expired auth token on the request
- Never put private keys in frontend code or in
VITE_variables - Add
.envfiles to.gitignore, and never commit real credentials
- The browser's Network tab is the fastest tool here. It shows the exact URL requested, the status code, the request headers and the raw response — which usually identifies the problem before you have finished reading your own code.
When to Stop Writing This by Hand
The pattern in this lesson is correct and worth knowing, but it does not scale gracefully. Two components fetching the same user both make their own request. Navigating away and back refetches everything, showing a spinner for data you had a second ago. There is no caching, no deduplication, no background refresh, and no way to tell every list to reload after a create.
Libraries such as TanStack Query (formerly React Query) and SWR exist for exactly this. They keep a cache keyed by the request, share it between components, return cached data immediately while quietly refreshing it, retry failures, and give you the loading and error state without writing it out each time. The component shrinks to a couple of lines.
The advice is not to install one on day one. Write the manual version a few times, so that you understand what caching and deduplication mean and can debug them when they misbehave. Reach for a library when the shortcomings above start costing you real time — which, in any application with more than a handful of screens, will not take long.
// The same component with TanStack Query
import { useQuery } from '@tanstack/react-query';
function CourseList({ categoryId }) {
const { data, error, isLoading } = useQuery({
queryKey: ['courses', categoryId],
queryFn: () => fetch(`/api/courses?category=${categoryId}`).then(r => r.json())
});
if (isLoading) return <p>Loading…</p>;
if (error) return <p>Something went wrong.</p>;
return <ul>{data.map(c => <li key={c.id}>{c.title}</li>)}</ul>;
}
// The cache key does the work: two components asking for
// ['courses', 3] share one request and one cached result. - If your project uses a framework such as Next.js, it has its own recommended way to load data that may run on the server before the page is sent. The React knowledge here still applies — you are simply moving where the request happens.
