Loading and Error States
Data fetching is not just "get data and render it".
A real UI needs to communicate what is happening.
At minimum, think about:
- loading
- success
- error
- empty
- refreshing stale data
- unauthorized or forbidden access
A Simple Status Model
Use an explicit status instead of guessing from data.
function TeamList() {
const [teams, setTeams] = useState([]);
const [status, setStatus] = useState("idle");
const [error, setError] = useState(null);
useEffect(() => {
async function loadTeams() {
setStatus("loading");
setError(null);
try {
const response = await fetch("/api/teams");
if (!response.ok) {
throw new Error("Could not load teams");
}
setTeams(await response.json());
setStatus("success");
} catch (err) {
setError(err);
setStatus("error");
}
}
loadTeams();
}, []);
if (status === "loading") return <p>Loading teams...</p>;
if (status === "error") return <p role="alert">{error.message}</p>;
if (teams.length === 0) return <p>No teams yet.</p>;
return teams.map((team) => <article key={team.id}>{team.name}</article>);
}An empty array is not the same as loading.
An error is not the same as empty.
Avoid Spinner-Only Interfaces
A spinner without text may be inaccessible or unclear.
Prefer visible, meaningful status messages.
<p aria-live="polite">Loading invoice details...</p>For errors, role="alert" can help assistive technology announce the problem.
<p role="alert">Payment history could not be loaded. Try again.</p>Retry Actions
Errors should often give the user a next step.
function ErrorState({ message, onRetry }) {
return (
<div role="alert">
<p>{message}</p>
<button onClick={onRetry}>Try again</button>
</div>
);
}Do not make the user refresh the entire page for a recoverable request.
Background Refreshing
After initial data is loaded, a refetch should not always replace the page with a full-screen spinner.
return (
<>
{isRefreshing && <p aria-live="polite">Refreshing...</p>}
<ProjectTable projects={projects} />
</>
);Keeping stale data visible during refresh can make the UI feel more stable.
This is one reason query libraries are useful.
Error Boundaries Are Not Enough
React error boundaries catch rendering errors.
They do not automatically catch failed async requests in an effect.
You still need request-level error handling.
Common Mistakes
- Using
data.length === 0to mean both loading and empty. - Showing a spinner forever when a request fails.
- Hiding detailed errors from logs and showing raw technical messages to users.
- Removing existing data during every background refetch.
- Forgetting accessible status text and alert behavior.
Why is explicit request status useful?
Practical Challenge
Create a reusable AsyncState component that accepts:
statuserrorisEmptychildrenonRetry
Use it to render loading, error, empty, and success states for a list of tasks.
Recap
Good loading and error states are part of the product experience.
Show clear status, distinguish empty from failed, keep stale data visible when appropriate, and give users a practical recovery path.