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.
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.
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.
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.
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
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.
Frequently Asked Questions
Why use one status value instead of booleans?
Should empty be a separate state?
Do I need aria-live for loading messages?
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.
More in this topic
How to Build a Dropdown Menu in React
Build a React dropdown menu with the ARIA menu button pattern. Handle open and close, keyboard arrows, and clicks outside the menu.
How to Animate React Components with Motion
Animate React components with the Motion library. Set up motion, add enter, hover, and exit animations, and respect reduced motion.
Headless UI Components Explained: Logic Without Locked Styling
Understand headless UI components and how libraries like Radix give you unstyled, accessible behavior that you style yourself.