The loading states are the phases an interface goes through while waiting for external data.
When a React application requests data from an API, the screen shouldn’t stay blank (that’s a bad sign). We need to tell the user what’s happening: we’re loading, something went wrong, there’s no data, or we have content.
In the previous article, we saw how to make a request with fetch. Now we’ll dive a little deeper into the interface surrounding that request.
The Four Common States
An HTTP request doesn’t have just two outcomes. In a real interface, we usually handle four distinct states:
- Loading: The request is in progress.
- Error: The request has failed.
- Empty: The request succeeded, but there is no data.
- Success: The request succeeded and we have data to render.
Don’t confuse no data yet with no results. null, [], and "error" don’t mean the same thing, even though we sometimes lump them all together, and then things go wrong.
Basic Multi-State Model
The most straightforward way is to use three separate states: users, loading, and error.
import { useEffect, useState } from "react";
export default function ListaUsuarios() {
const [users, setUsers] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
const loadUsers = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(
"https://jsonplaceholder.typicode.com/users",
{ signal: controller.signal }
);
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
const data = await response.json();
setUsers(data);
} catch (err) {
if (err.name !== "AbortError") {
setError(err.message);
setUsers(null);
}
} finally {
setLoading(false);
}
};
loadUsers();
return () => controller.abort();
}, []);
if (loading) {
return <p>Loading users...</p>;
}
if (error) {
return <p>Could not load users: {error}</p>;
}
if (!users || users.length === 0) {
return <p>No users to show.</p>;
}
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
The order of the return statements matters. We resolve the special states first (loading, error, empty) and leave the happy path for last. This way, the main JSX stays clean and doesn’t turn into a mess of ternary operators.
Extracting the Fetch to a Reusable Function
If we want to add a retry button, it’s better to extract the loading function from the useEffect. This way, we can call it both on mount and when the button is clicked.
import { useCallback, useEffect, useState } from "react";
export default function ListaUsuarios() {
const [users, setUsers] = useState([]);
const [status, setStatus] = useState("idle");
const [error, setError] = useState(null);
const loadUsers = useCallback(async () => {
try {
setStatus("loading");
setError(null);
const response = await fetch("https://jsonplaceholder.typicode.com/users");
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
const data = await response.json();
setUsers(data);
setStatus("success");
} catch (err) {
setError(err.message);
setStatus("error");
}
}, []);
useEffect(() => {
loadUsers();
}, [loadUsers]);
if (status === "loading") return <p>Loading...</p>;
if (status === "error") {
return (
<div>
<p>Error: {error}</p>
<button onClick={loadUsers}>Retry</button>
</div>
);
}
if (status === "success" && users.length === 0) {
return <p>No users.</p>;
}
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
Using a status with values like "idle", "loading", "success", and "error" avoids awkward combinations, such as loading: true and error: "something" at the same time.
Skeletons Better Than Eternal Spinners
For very fast loads, a “Loading…” text might be enough. For larger interfaces, skeleton screens usually work better: gray blocks that mimic the shape of the final content.
function UserSkeleton() {
return (
<div className="skeleton-card">
<div className="skeleton-title" />
<div className="skeleton-line" />
</div>
);
}
This gives the user a hint of what’s going to appear and reduces the feeling of waiting. It doesn’t make the API respond faster, but the wait feels better (and that counts too).
Avoid Flickering Interfaces
A typical mistake is setting loading to true on every minor update, even when we already have data on screen. The result is an interface that constantly disappears and reappears.
For secondary refreshes, you can keep the old data and show a more discreet indicator:
return (
<section>
{status === "loading" && users.length > 0 && (
<small>Updating data...</small>
)}
<UserList users={users} />
</section>
);
This way, we differentiate between initial load and background update. The user appreciates it, even if they don’t know the name for it.
