Loading and Error States
Every screen that loads data has four states: loading, error, empty and success. Handling all four well is what makes an app feel solid instead of broken.
On this page · 6 min · Intermediate
On this page
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 6 min
- Difficulty
- Intermediate
- Updated
- Oct 28, 2026
Learning objectives#
- Render loading, error, empty and success states
- Show skeletons instead of spinners for content
- Offer retry after errors
- Model status as one value to avoid impossible states
Explanation#
Model status as one value — "idle" | "loading" | "error" | "success" — instead of separate booleans. You can never be loading and failed at the same time.
Loading: for content areas, a skeleton (grey placeholder boxes in the layout’s shape) feels faster and avoids layout jumps. Use aria-busy for screen readers.
Error: say what happened in plain words and what to do (“Could not load orders. Check your connection.”) and offer a Retry button. Log technical details, do not show them.
Empty: “No orders yet” with a next step (“Browse products”) is better than a blank area.
Keep previous data visible while refreshing (show a small “Updating…” indicator) instead of flashing back to a skeleton.
Syntax#
if (status === 'loading') return <Skeleton />;
if (status === 'error') return <ErrorBox onRetry={load} />;
if (items.length === 0) return <Empty />;
return <List items={items} />;
Code example: An orders panel with all four states#
import { useCallback, useEffect, useState } from 'react';
function Skeleton() {
return (
<ul aria-busy="true" aria-label="Loading orders">
{[1, 2, 3].map(i => (
<li key={i} style={{ height: 18, margin: '6px 0', background: '#e5e7eb', borderRadius: 4 }} />
))}
</ul>
);
}
function ErrorBox({ message, onRetry }) {
return (
<div role="alert">
<p>{message}</p>
<button onClick={onRetry}>Try again</button>
</div>
);
}
// Simulated API: fails on the first call, succeeds after
let calls = 0;
function fetchOrders() {
calls += 1;
return new Promise((resolve, reject) =>
setTimeout(() => (calls === 1 ? reject(new Error('Network error')) : resolve([
{ id: 101, total: 499 }, { id: 102, total: 1299 },
])), 600));
}
export default function OrdersPanel() {
const [status, setStatus] = useState('loading'); // loading | error | success
const [orders, setOrders] = useState([]);
const load = useCallback(() => {
setStatus('loading');
fetchOrders()
.then(data => { setOrders(data); setStatus('success'); })
.catch(err => { console.error(err); setStatus('error'); });
}, []);
useEffect(() => { load(); }, [load]);
if (status === 'loading') return <Skeleton />;
if (status === 'error') return <ErrorBox message="We could not load your orders. Please check your connection." onRetry={load} />;
if (orders.length === 0) return <p>No orders yet. <a href="/products">Start shopping</a></p>;
return <ul>{orders.map(o => <li key={o.id}>Order #{o.id} — ₹{o.total}</li>)}</ul>;
}
Three grey skeleton bars appear, then the friendly error message with a “Try again” button (the simulated API fails the first time). Clicking it shows the skeleton again and then “Order #101 — ₹499” and “Order #102 — ₹1299”.
This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.
Step-by-step explanation#
- status is a single value, so the component is always in exactly one state.
- Skeleton mimics the list’s shape and is marked aria-busy.
- ErrorBox shows a human message and a retry button; the technical error goes to the console.
- The empty case gives the user a next step.
- load is reused for the first load and for retries.
Real-world use case#
A banking app shows skeleton rows for transactions, “No transactions this month” for empty periods, and a retry banner when the network drops — users always know what is happening.
Common mistakes#
Without loading and error states, the screen is blank or crashes on undefined data.
isLoading, isError and isEmpty can contradict each other. Use one status value.
“TypeError: Failed to fetch at line 42” scares users. Show a friendly message and log details.
Best practices#
- One status value per request
- Skeletons for content, spinners for actions
- Friendly errors with retry
- Helpful empty states
Remember this#
Loading · Error · Empty · Success — handle all four.
status = "loading" | "error" | "success".
Errors: say what happened and what to do next.
Practice exercise#
Your task: Create a reusable <AsyncView status data onRetry render /> component that shows a loading text, an error with retry, an empty message, or calls render(data).
Need a hint?
Show solution
function AsyncView({ status, data, onRetry, render, empty = 'Nothing here yet.' }) {
if (status === 'loading') return <p aria-busy="true">Loading…</p>;
if (status === 'error') return <p role="alert">Something went wrong. <button onClick={onRetry}>Retry</button></p>;
if (!data || data.length === 0) return <p>{empty}</p>;
return render(data);
}
export default function Demo() {
return (
<AsyncView
status="success"
data={['Pen', 'Bag']}
onRetry={() => {}}
render={items => <ul>{items.map(i => <li key={i}>{i}</li>)}</ul>}
/>
);
}
Mini quiz#
Why use one status value instead of isLoading and isError booleans?
Want more? Practise all React MCQs · React interview questions
My notes#
Saved in this browser.