| |

React 34 ⚛️ Handling Loading and Error States

Every asynchronous operation has three possible outcomes: it is still running, it succeeded, or it failed. A React component that fetches data must represent all three. The previous chapter introduced the three states as part of the fetch pattern. This chapter treats them as a design problem in their own right: how to model them, how to avoid inconsistent combinations, how to render each one, and how to handle the retry, the empty result, and the error boundary.

Key point: Loading and error are states, not afterthoughts. A component has a state that is exactly one of: loading, success, or error. The three states are mutually exclusive. The single state object enforces this. The three separate useState calls do not, and the combinations they allow are the source of most rendering bugs.


Why loading and error states matter

The mutual exclusion problem. Three separate useState calls allow loading: false, data: null, and error: null at the same time. This combination is impossible in a correct state machine, but the separate states allow it. The render branches on the three values and produces a blank screen or the wrong message. The single state object makes the impossible combination unrepresentable.

The flash problem. A component that shows the loading state on every re-render flashes the spinner when the data is already available. The loading state should be set only when the request starts, not on every render. The distinction between “loading” and “refreshing” matters: a background refresh should not hide the existing data.

The error problem. An error that is not displayed is an error the user cannot act on. The error state should be rendered with a message and, where possible, a retry action. An error that is logged to the console but not shown to the user is a silent failure.

The empty problem. A successful request can return an empty result. An empty list is not the same as no data. The component should render an empty state that is distinct from the loading state and the error state. Four states, not three: loading, error, empty, and data.

The boundary problem. An error that is thrown during render is not caught by the fetch’s .catch. It is caught by an error boundary. The error boundary is a separate mechanism for rendering errors, and it complements the fetch error state. The two work together: the fetch error state handles the request failure, and the error boundary handles the render failure.


a. Modeling the states

The single state object is the pattern that enforces mutual exclusion.

const [state, setState] = useState({
  status: 'idle',
  data: null,
  error: null,
});

The status is one of 'idle', 'loading', 'success', or 'error'. The data is the result of the request. The error is the failure. The combinations are constrained by the status.

StatusDataError
idlenullnull
loadingprevious or nullnull
successthe datanull
errornullthe error

The idle state is the initial state before the request starts. The loading state is while the request is in flight. The success state is when the request succeeds. The error state is when the request fails.

The effect transitions between the states.

useEffect(() => {
  const controller = new AbortController();

  setState({ status: 'loading', data: null, error: null });

  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then((res) => {
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json();
    })
    .then((data) => {
      setState({ status: 'success', data, error: null });
    })
    .catch((err) => {
      if (err.name === 'AbortError') return;
      setState({ status: 'error', data: null, error: err });
    });

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

The effect sets loading before the request, success with the data when it resolves, and error with the error when it rejects. The abort is ignored.

The render branches on the status.

if (state.status === 'loading') return <Spinner />;
if (state.status === 'error') return <ErrorMessage error={state.error} />;
if (state.status === 'success' && state.data.length === 0) return <EmptyState />;
if (state.status === 'success') return <DataView data={state.data} />;
return null;

The idle state renders null. The loading state renders the spinner. The error state renders the message. The success state with empty data renders the empty state. The success state with data renders the view.

A reducer formalizes the transitions and makes them testable.

function fetchReducer(state, action) {
  switch (action.type) {
    case 'loading':
      return { status: 'loading', data: null, error: null };
    case 'success':
      return { status: 'success', data: action.data, error: null };
    case 'error':
      return { status: 'error', data: null, error: action.error };
    default:
      return state;
  }
}

const [state, dispatch] = useReducer(fetchReducer, {
  status: 'idle',
  data: null,
  error: null,
});

The reducer is a pure function that takes the current state and an action and returns the new state. The transitions are explicit, and the impossible states are not representable.


b. Rendering each state

Each state has a corresponding render. The renders should be distinct and informative.

The loading state.

function Spinner() {
  return (
    <div role="status" aria-live="polite">
      <span className="spinner" aria-hidden="true" />
      <span className="sr-only">Loading…</span>
    </div>
  );
}

The spinner has role="status" and aria-live="polite" so that screen readers announce the loading. The visual spinner is hidden from screen readers, and the text is hidden visually. The two together serve both audiences.

A skeleton is an alternative to the spinner. It shows the shape of the content before the content arrives.

function UserSkeleton() {
  return (
    <div className="skeleton">
      <div className="skeleton-avatar" />
      <div className="skeleton-line" />
      <div className="skeleton-line short" />
    </div>
  );
}

The skeleton reduces the layout shift because the space is reserved. The spinner does not.

The error state.

function ErrorMessage({ error, onRetry }) {
  return (
    <div role="alert">
      <p>Something went wrong: {error.message}</p>
      <button onClick={onRetry}>Retry</button>
    </div>
  );
}

The error has role="alert" so that screen readers announce it immediately. The message is the error’s message, or a generic message if the error is not user-friendly. The retry button triggers the request again.

The retry is implemented by incrementing a counter in the dependencies.

const [attempt, setAttempt] = useState(0);

useEffect(() => {
  // fetch
}, [userId, attempt]);

// Retry
<button onClick={() => setAttempt((a) => a + 1)}>Retry</button>

The attempt counter is in the dependencies, so incrementing it re-runs the effect and the fetch. The counter is not read by the effect; it is a trigger.

The empty state.

function EmptyState({ message = 'No results found' }) {
  return (
    <div className="empty">
      <p>{message}</p>
    </div>
  );
}

The empty state is distinct from the loading state and the error state. An empty list is a successful request with no results, and the message should say so.

The success state.

function DataView({ data }) {
  return (
    <ul>
      {data.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

The success state renders the data. The key is the item’s stable identifier, and the list is rendered with map.

The four states are the complete set. The component renders exactly one of them, and the render is exhaustive.


c. The error boundary and the retry

An error boundary is a component that catches errors thrown during the render of its children. It is the React mechanism for rendering errors, and it complements the fetch error state.

class ErrorBoundary extends React.Component {
  state = { hasError: false, error: null };

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  componentDidCatch(error, info) {
    console.error(error, info);
  }

  render() {
    if (this.state.hasError) {
      return (
        <div role="alert">
          <p>Something went wrong.</p>
          <button onClick={() => this.setState({ hasError: false, error: null })}>
            Try again
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

The error boundary wraps the component that might throw. When a child throws during render, the boundary catches it and renders the fallback.

The error boundary catches render errors, not fetch errors. A fetch error is an error in the state, and it is rendered by the component itself. The two are complementary: the fetch error state handles the request failure, and the error boundary handles the render failure.

A fetch error that is thrown in the render is caught by the boundary.

function UserProfile({ userId }) {
  const { data, error } = useFetch(`/api/users/${userId}`);

  if (error) throw error; // caught by the boundary

  return <p>{data.name}</p>;
}

The throw in the render is the pattern for “the component cannot render.” The boundary catches it and renders the fallback. The pattern is common in libraries like React Query, where the useQuery hook can be configured to throw on error.

The retry is the action that re-runs the request. In the fetch error state, the retry is a button that increments a counter. In the error boundary, the retry is a button that resets the boundary’s state.

The retry should be idempotent. Re-running the request should not have side effects that accumulate. A GET request is idempotent. A POST request is not, and the retry should be guarded.

The retry should have a limit. An infinite retry loop on a failing request exhausts the server and the client. The retry should stop after a few attempts, or back off exponentially.

const [attempt, setAttempt] = useState(0);

const retry = () => {
  if (attempt < 3) {
    setAttempt((a) => a + 1);
  }
};

The attempt counter is capped at three. The retry button is disabled after the third attempt.

An automatic retry with backoff is the pattern in the data-fetching libraries.

useEffect(() => {
  const controller = new AbortController();
  let retries = 0;

  const load = async () => {
    try {
      const res = await fetch(url, { signal: controller.signal });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      setState({ status: 'success', data: await res.json(), error: null });
    } catch (err) {
      if (err.name === 'AbortError') return;
      if (retries < 3) {
        retries++;
        setTimeout(load, 2 ** retries * 1000);
      } else {
        setState({ status: 'error', data: null, error: err });
      }
    }
  };

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

The retry is delayed by 2 ** retries * 1000 milliseconds: 2 seconds, 4 seconds, 8 seconds. The backoff gives the server time to recover. The setTimeout should be cleared in the cleanup, or the retry continues after the component unmounts.


Complete Example Session

// ============================================
// PART 1: SINGLE STATE OBJECT
// ============================================
const [state, setState] = useState({
  status: 'idle',
  data: null,
  error: null,
});
// ============================================
// PART 2: TRANSITIONS IN THE EFFECT
// ============================================
useEffect(() => {
  const controller = new AbortController();

  setState({ status: 'loading', data: null, error: null });

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

  return () => controller.abort();
}, [url]);
// ============================================
// PART 3: REDUCER
// ============================================
function fetchReducer(state, action) {
  switch (action.type) {
    case 'loading':
      return { status: 'loading', data: null, error: null };
    case 'success':
      return { status: 'success', data: action.data, error: null };
    case 'error':
      return { status: 'error', data: null, error: action.error };
    default:
      return state;
  }
}
// ============================================
// PART 4: RENDER BRANCHES
// ============================================
if (state.status === 'loading') return <Spinner />;
if (state.status === 'error') return <ErrorMessage error={state.error} />;
if (state.status === 'success' && state.data.length === 0) return <EmptyState />;
if (state.status === 'success') return <DataView data={state.data} />;
return null;
// ============================================
// PART 5: LOADING COMPONENT
// ============================================
function Spinner() {
  return (
    <div role="status" aria-live="polite">
      <span className="spinner" aria-hidden="true" />
      <span className="sr-only">Loading…</span>
    </div>
  );
}
// ============================================
// PART 6: ERROR COMPONENT WITH RETRY
// ============================================
function ErrorMessage({ error, onRetry }) {
  return (
    <div role="alert">
      <p>Something went wrong: {error.message}</p>
      <button onClick={onRetry}>Retry</button>
    </div>
  );
}
// ============================================
// PART 7: EMPTY STATE
// ============================================
function EmptyState({ message = 'No results found' }) {
  return (
    <div className="empty">
      <p>{message}</p>
    </div>
  );
}
// ============================================
// PART 8: RETRY WITH COUNTER
// ============================================
const [attempt, setAttempt] = useState(0);

useEffect(() => {
  // fetch
}, [url, attempt]);

<ErrorMessage
  error={state.error}
  onRetry={() => setAttempt((a) => a + 1)}
/>
// ============================================
// PART 9: RETRY WITH LIMIT
// ============================================
const [attempt, setAttempt] = useState(0);

const retry = () => {
  if (attempt < 3) setAttempt((a) => a + 1);
};

<button onClick={retry} disabled={attempt >= 3}>
  Retry
</button>
// ============================================
// PART 10: ERROR BOUNDARY
// ============================================
class ErrorBoundary extends React.Component {
  state = { hasError: false, error: null };

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  componentDidCatch(error, info) {
    console.error(error, info);
  }

  render() {
    if (this.state.hasError) {
      return (
        <div role="alert">
          <p>Something went wrong.</p>
          <button onClick={() => this.setState({ hasError: false, error: null })}>
            Try again
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

The ten parts covered the single state object, the transitions, the reducer, the render branches, the loading component, the error component, the empty state, the retry counter, the retry limit, and the error boundary.


Quick Reference

State Model

StatusDataErrorRender
idlenullnullNothing
loadingnullnullSpinner
success (empty)[]nullEmpty state
success (data)the datanullData view
errornullthe errorError message

Render Branches

ConditionComponent
status === 'loading'<Spinner />
status === 'error'<ErrorMessage />
status === 'success' && data.length === 0<EmptyState />
status === 'success'<DataView />

Retry

ApproachPattern
ManualCounter in dependencies
LimitedCap the counter
BackoffsetTimeout with increasing delay
AutomaticLibrary (React Query, SWR)

Error Boundary

MethodPurpose
getDerivedStateFromErrorUpdate state on error
componentDidCatchLog the error
renderRender the fallback
ResetsetState({ hasError: false })

Accessibility

ElementAttribute
Loadingrole="status", aria-live="polite"
Errorrole="alert"
Spinneraria-hidden="true"
Screen reader text.sr-only

Best Practices

✅ Do This:

// Use a single state object
const [state, setState] = useState({ status, data, error });   // ✅

// Set loading before the request
setState({ status: 'loading', data: null, error: null });      // ✅

// Set success with the data
setState({ status: 'success', data, error: null });            // ✅

// Set error with the error
setState({ status: 'error', data: null, error: err });         // ✅

// Distinguish empty from loading and error
if (state.data.length === 0) return <EmptyState />;            // ✅

// Add role="status" to the spinner
<div role="status" aria-live="polite">                         // ✅

// Add role="alert" to the error
<div role="alert">                                             // ✅

// Use a retry button with a limit
<button onClick={retry} disabled={attempt >= 3}>Retry</button> // ✅

❌ Don’t Do This:

// Don't use three separate states without coordination
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// allows loading: false, data: null, error: null             // ⚠️

// Don't forget the empty state
if (state.data) return <DataView />;
return <Spinner />; // empty looks like loading                 // ❌

// Don't show the spinner on every render
// The loading state should be set only when the request starts  // ⚠️

// Don't retry infinitely
setAttempt((a) => a + 1); // no limit                            // ❌

// Don't ignore the abort error
.catch((err) => setError(err)); // AbortError is not a real error // ❌

// Don't forget role="alert" on the error
<div>Error</div>; // screen readers do not announce              // ❌

// Don't throw a fetch error in render without a boundary
if (error) throw error; // no boundary catches it                // ❌

Common Pitfalls

PitfallWhy It HappensFix
Inconsistent stateThree separate statesSingle object
Empty renders as loadingNo empty checkAdd empty branch
Spinner flashesLoading set on every renderSet only on request
Retry loopsNo limitCap the counter
AbortError shownCatch does not ignoreCheck err.name
Screen reader silentNo roleAdd role="alert"
Render error uncaughtNo boundaryAdd ErrorBoundary

Real-World Examples

1. Single State Object

const [state, setState] = useState({
  status: 'idle',
  data: null,
  error: null,
});

2. Loading Spinner

if (state.status === 'loading') return <Spinner />;

3. Error with Retry

if (state.status === 'error') {
  return <ErrorMessage error={state.error} onRetry={retry} />;
}

4. Empty State

if (state.status === 'success' && state.data.length === 0) {
  return <EmptyState />;
}

5. Success State

if (state.status === 'success') return <DataView data={state.data} />;

6. Skeleton

function Skeleton() {
  return <div className="skeleton" aria-hidden="true" />;
}

7. Retry Counter

const [attempt, setAttempt] = useState(0);
useEffect(() => { /* fetch */ }, [url, attempt]);

8. Retry Limit

const retry = () => {
  if (attempt < 3) setAttempt((a) => a + 1);
};

9. Error Boundary

<ErrorBoundary>
  <UserProfile userId={userId} />
</ErrorBoundary>

10. Throw in Render

if (error) throw error; // caught by boundary

Visual

State Transitions

┌─────────────────────────────────────────────────────────────┐
│  IDLE                                                       │
│    │                                                        │
│    ▼  effect runs                                           │
│  LOADING                                                    │
│    │                                                        │
│    ├── success ──▶ SUCCESS                                  │
│    │                 │                                      │
│    │                 ├── data empty ──▶ EMPTY               │
│    │                 └── data present ──▶ DATA              │
│    │                                                        │
│    └── failure ──▶ ERROR                                    │
│                      │                                      │
│                      └── retry ──▶ LOADING                  │
│                                                             │
└─────────────────────────────────────────────────────────────┘

The Four Renders

┌─────────────────────────────────────────────────────────────┐
│  LOADING                                                    │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  ⟳  Loading…                                        │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  ERROR                                                      │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  ⚠  Something went wrong: HTTP 500                  │    │
│  │  [Retry]                                            │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  EMPTY                                                      │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  No results found                                   │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  DATA                                                       │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  • Item 1                                           │    │
│  │  • Item 2                                           │    │
│  │  • Item 3                                           │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Single Object vs Separate States

┌─────────────────────────────────────────────────────────────┐
│  SEPARATE STATES                                            │
│                                                             │
│  const [data, setData] = useState(null);                    │
│  const [loading, setLoading] = useState(true);              │
│  const [error, setError] = useState(null);                  │
│                                                             │
│  Possible combination:                                      │
│    data: null, loading: false, error: null                  │
│                                                             │
│  This renders nothing. It is impossible in a correct        │
│  state machine, but the separate states allow it.           │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  SINGLE OBJECT                                              │
│                                                             │
│  const [state, setState] = useState({                       │
│    status: 'idle', data: null, error: null                  │
│  });                                                        │
│                                                             │
│  The status determines the render.                          │
│  The impossible combination is not representable.           │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Retry with Backoff

┌─────────────────────────────────────────────────────────────┐
│  ATTEMPT 1: fetch fails                                     │
│    wait 2 seconds                                           │
│                                                             │
│  ATTEMPT 2: fetch fails                                     │
│    wait 4 seconds                                           │
│                                                             │
│  ATTEMPT 3: fetch fails                                     │
│    wait 8 seconds                                           │
│                                                             │
│  ATTEMPT 4: fetch fails                                     │
│    give up, set error state                                 │
│                                                             │
│  The delay doubles each time.                               │
│  The server has time to recover.                            │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Summary

ItemValue
Statesloading, success, error
Optional statesidle, empty
ModelSingle state object
TransitionReducer or setState
Loading renderSpinner or skeleton
Error renderMessage with retry
Empty renderMessage
Success renderData view
RetryCounter in dependencies
Retry limitCap the counter
Error boundaryCatches render errors
Accessibilityrole="status", role="alert"

Key takeaways:

  • The states are mutually exclusive. A component is loading, or it succeeded, or it failed. The single state object enforces this. The three separate useState calls do not, and the combinations they allow produce rendering bugs.
  • The empty state is distinct from the loading state. A successful request with no results is not the same as a request in flight. The component should render an empty message, not a spinner.
  • The loading state is set when the request starts, not on every render. A spinner that appears on every re-render flashes and is distracting. The loading state should transition once per request.
  • The error state is rendered with a message and a retry. An error that is not displayed is an error the user cannot act on. The retry re-runs the request, and the retry should have a limit.
  • The abort error is not a real error. The AbortError is the result of the cleanup aborting the request. The catch should check for it and return early.
  • The error boundary complements the fetch error state. The fetch error state handles the request failure. The error boundary handles the render failure. The two work together.
  • The accessibility attributes make the states usable by screen readers. role="status" for the loading state, role="alert" for the error state, and aria-hidden="true" for the visual spinner.

Remember: Loading and error are not afterthoughts. They are the states that the user sees while the data is on its way and when it fails to arrive. The single state object models them correctly, the render branches handle them exhaustively, and the retry and the error boundary complete the picture. The four renders—loading, error, empty, data—are the complete set. Write them, and the component is robust. Skip them, and the component shows a blank screen when the network is slow and a console error when the server is down.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!