How to Handle Loading, Error, Empty, and Success States

Model loading, error, empty, and success states in React with one status value. Show a clear, accessible message for every request outcome.

7 min read

Every React view that loads data has four possible states: loading, error, empty, and success. Modeling them as one status value keeps the UI from showing contradictory messages. This guide builds a small data view that handles all four clearly and accessibly.

Model the request with one status value

The request moves through states in a fixed shape. It starts loading, then lands in error, empty, or success, and an error can send it back to loading when the user retries.

Data view request states

The diagram shows loading as the only entry point, with three possible outcomes. Empty and success both come from a successful response, which is why they need separate checks: a request can succeed and still return nothing to show.

Keep this as one status string rather than several booleans.

App.jsxApp.jsx
const [status, setStatus] = useState("loading");

A single value means the states are mutually exclusive by construction. Separate flags such as isLoading and hasError can both be true at the same time, which forces the render code to pick a winner.

Render each state clearly

Render the status first, before the data, so every outcome gets its own branch.

App.jsxApp.jsx
if (status === "loading") return <p role="status">Loading posts...</p>;
if (status === "error") return <p role="alert">Could not load posts.</p>;
if (posts.length === 0) return <p>No posts yet.</p>;
return (
  <ul>
    {posts.map((post) => (
      <li key={post.id}>{post.title}</li>
    ))}
  </ul>
);

The loading and error branches return early. The empty branch checks the data length, then the final return shows the populated list. Each branch is short, so the user always sees a clear signal instead of a blank screen.

The order matters too. Loading and error are checked before the data, because posts can still hold the previous result while a new request runs. Checking the status first stops the old list from flashing under the new state, and it makes the transitions predictable. Keep all four branches short and consistent.

Drive the status from the request

Update the status at each step of the fetch, and reset it to loading when the Effect runs again.

App.jsxApp.jsx
useEffect(() => {
  setStatus("loading");
  fetch("/api/posts")
    .then((res) => res.json())
    .then((data) => {
      setPosts(data);
      setStatus("success");
    })
    .catch(() => setStatus("error"));
}, []);

Setting status to loading first prevents a stale success message from lingering while a new request starts. The success branch means a response arrived, but it says nothing about whether the list has items.

The render step checks posts.length separately, so a valid empty response gets its own message instead of a blank list. This is the empty state from the diagram. This example omits the ignore flag to keep the states in focus; add that guard exactly as shown in how to fetch API data in React.

Keep the states accessible

Screen readers do not see a message that appears silently. The role attributes in the branches above announce the change, but choose the right one for the tone.

  • Use role="status" for polite, non-urgent messages such as loading and success.
  • Use role="alert" for errors that need attention.
  • Keep focus where it is, since neither role moves the keyboard focus.

This turns a visual-only loading and error state into something assistive technology can hear too. The text also sits in the normal page flow, so sighted users see it when they glance back after a slow request, with no extra work from the user.

Avoid boolean flag soup

The four-state model falls apart when each state gets its own boolean. A component with isLoading, isError, and isEmpty needs nine combinations to reason about, even though only a few are possible.

The status string encodes exactly the valid states. If a new situation does not fit one of the four states, add a new status value rather than a boolean alongside it.

The same idea extends to retries. An error state usually needs a Retry button that sends the request back to loading, which is covered in how to retry failed API requests in React. For the underlying Effect logic, see React useEffect explained.

Rune AI

Rune AI

Key Insights

  • Model the request with one status value.
  • Keep empty separate from success when the response can have no items.
  • Render a clear message for every state.
  • Use role="status" and role="alert" for announcements.
  • Avoid separate boolean flags that can contradict each other.
RunePowered by Rune AI

Frequently Asked Questions

Why use one status value instead of booleans?

A single status value keeps the states mutually exclusive. Separate isLoading and hasError booleans can both be true at once, and the UI has to decide which one wins.

Should empty be a separate state?

Yes when a successful response can contain zero items. Rendering a helpful empty message is different from rendering a list of results, and different again from showing an error.

Do I need aria-live for loading messages?

Use role="status" for polite announcements such as loading or success, and role="alert" for errors. These tell screen readers about changes without moving focus.

Conclusion

Track the request as one status value that moves between loading, error, empty, and success. Render a distinct, accessible message for each, and keep empty separate from success when a valid response can contain no items.