React · Routing & Data

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.

Intermediate 6 min Lesson 24 of 42Examples target React 19 with Vite.
Before you start:API Integration
0/42 in React
On this page · 6 min · Intermediate

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#

JSX Illustrative
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#

JSXOrdersPanel.jsx Runnable
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>;
}
Expected output
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#

  1. status is a single value, so the component is always in exactly one state.
  2. Skeleton mimics the list’s shape and is marked aria-busy.
  3. ErrorBox shows a human message and a retry button; the technical error goes to the console.
  4. The empty case gives the user a next step.
  5. 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#

Only handling success

Without loading and error states, the screen is blank or crashes on undefined data.

Several booleans

isLoading, isError and isEmpty can contradict each other. Use one status value.

Showing raw errors

“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?
Same if-chain as the example, but render is a function prop.
Show solution
JSX Runnable
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#

Loading and Error States quizMultiple choice Question 1 of 3

Why use one status value instead of isLoading and isError booleans?

Score so far: 0/0

Want more? Practise all React MCQs · React interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.