Skip to content
React rc data 5 min read

Loading & Error States

Every component that talks to a network exists in more than one state. Before the data arrives it is loading; the request might fail; the response might come back empty; and only sometimes does it land on the happy success path. Treating these as four first-class states — rather than an afterthought wrapped around the success case — is what separates a polished app from one that flashes blank screens and swallows errors. This page shows how to model and present each state accessibly.

The four async states

Any asynchronous read resolves into one of four mutually exclusive UI states. A useful mental model is to render exactly one of them at a time, in priority order.

StateWhenTypical UI
LoadingRequest in flight, no data yetSpinner or skeleton
ErrorRequest rejected or returned a non-OK statusMessage + retry action
EmptyRequest succeeded but the result has no itemsFriendly “nothing here” copy
SuccessRequest succeeded with dataThe real content

The order matters: check loading first, then error, then empty, and treat the remainder as success. Collapsing empty into success is the most common mistake — an empty array is not an error, and rendering it through the success branch usually produces a confusing blank region.

function ProductList({ products, error, isPending }) {
  if (isPending) return <ProductSkeleton />;
  if (error) return <ErrorState error={error} />;
  if (products.length === 0) return <EmptyState message="No products yet." />;

  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}

Spinners vs. skeletons

A spinner says “something is happening”; a skeleton says “this is happening, and here is its shape.” Skeletons reduce perceived load time because the layout does not jump when content arrives, so prefer them for content areas with a predictable shape. Reserve spinners for short, indeterminate actions like a button submit.

function ProductSkeleton() {
  return (
    <ul aria-hidden="true" className="skeleton">
      {Array.from({ length: 6 }, (_, i) => (
        <li key={i} className="skeleton-row" />
      ))}
    </ul>
  );
}

Mark purely decorative skeletons with aria-hidden="true" so screen readers do not announce a list of empty rows. Announce the loading state separately with a live region (see below) instead.

Accessible status messaging

Sighted users see the spinner; assistive-technology users need an announcement. Wrap status text in an ARIA live region so changes are read aloud without moving focus. Use role="status" (polite) for loading and success, and role="alert" (assertive) for errors that demand attention.

function AsyncStatus({ isPending, error, count }) {
  if (isPending) {
    return (
      <p role="status" aria-live="polite">
        Loading results…
      </p>
    );
  }
  if (error) {
    return (
      <p role="alert">Could not load results: {error.message}</p>
    );
  }
  return (
    <p role="status" aria-live="polite">
      {count} results loaded.
    </p>
  );
}

Output:

(screen reader, on mount)        "Loading results…"
(screen reader, on success)      "12 results loaded."
(screen reader, on failure)      "Could not load results: HTTP 500"

Error states with retry

A failed request should never be a dead end. Give the user the error context and a way to try again. With a manual useEffect fetch you trigger a refetch by bumping a counter that the effect depends on; with TanStack Query or SWR you call the library’s refetch / mutate function.

import { useEffect, useState } from "react";

function useRetryableFetch(url) {
  const [state, setState] = useState({ data: null, error: null, loading: true });
  const [attempt, setAttempt] = useState(0);

  useEffect(() => {
    const controller = new AbortController();
    setState((s) => ({ ...s, loading: true, error: null }));

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((data) => setState({ data, error: null, loading: false }))
      .catch((err) => {
        if (err.name !== "AbortError") {
          setState({ data: null, error: err, loading: false });
        }
      });

    return () => controller.abort();
  }, [url, attempt]);

  const retry = () => setAttempt((n) => n + 1);
  return { ...state, retry };
}

function Report({ url }) {
  const { data, error, loading, retry } = useRetryableFetch(url);

  if (loading) return <p role="status">Loading report…</p>;
  if (error) {
    return (
      <div role="alert">
        <p>We could not load this report: {error.message}</p>
        <button onClick={retry}>Try again</button>
      </div>
    );
  }
  return <h1>{data.title}</h1>;
}

Error boundaries for render errors

The states above handle fetch failures, but a bug while rendering data — reading a property off undefined, for example — throws during render and is not caught by try/catch or promise .catch. For that you need an error boundary: the one place React still requires a class component. It catches errors thrown by its descendants during rendering and shows a fallback instead of unmounting the whole tree.

import { Component } from "react";

class ErrorBoundary extends Component {
  state = { error: null };

  static getDerivedStateFromError(error) {
    return { error };
  }

  componentDidCatch(error, info) {
    console.error("Render error:", error, info.componentStack);
  }

  render() {
    if (this.state.error) {
      return (
        <div role="alert">
          <p>Something went wrong rendering this view.</p>
          <button onClick={() => this.setState({ error: null })}>
            Reload section
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

function Dashboard() {
  return (
    <ErrorBoundary>
      <Report url="https://api.example.com/reports/42" />
    </ErrorBoundary>
  );
}

In production, prefer the react-error-boundary package, which adds a resetErrorBoundary callback and a FallbackComponent prop so you do not hand-roll the class. Pair it with Suspense so loading falls to the nearest boundary and errors fall to the next one out.

Best Practices

  • Model async UI as four explicit states — loading, error, empty, success — and render exactly one at a time in that priority order.
  • Never collapse the empty state into success; an empty result deserves its own clear message.
  • Prefer skeletons over spinners for content regions to keep layout stable and reduce perceived latency.
  • Announce state changes with ARIA live regions: role="status" for loading/success, role="alert" for errors.
  • Always offer a retry path on error; a failed request should be recoverable, not a dead end.
  • Wrap data-driven subtrees in an error boundary so a render-time bug degrades one section instead of crashing the app.
  • Keep error messages human and actionable — show what failed and what to do next, log the technical detail to the console or your error tracker.
Last updated June 14, 2026
Was this helpful?