Designing React Screens for Loading, Empty, Error and Retry States
Learn to model every state a React screen and form can be in, from skeletons and refetches to empty results, useful errors and double-submit protection.
This article is published in English.
A screen that renders perfectly against a seeded local database can still fall apart in production, where users meet slow networks, empty tables and failing endpoints. This guide walks through the states a data-driven React screen and its forms actually pass through, and gives you a checklist for designing each one before you write any JSX.
The mental model behind most fragile screens
When a component is first sketched, the flow tends to look like a straight line: request something, then render it.
Fetch data → Show data
That picture has no room for failure. A production screen branches: it starts loading, lands in success or an empty result, can fall into an error, and needs a way back through retry or refetch.
Loading → Success → Empty
↓
Error
↓
Retry / Refetch
Treat loading as a real state with its own design
"Show a spinner" sounds like a complete answer in the same way that "wrap it in try/catch" does: it is not wrong, but it rarely covers what users experience. Two questions expose the gap. What does the screen look like when the response takes four seconds? And what happens when it arrives in 40 milliseconds: does the user see a spinner blink for a single frame and read the interface as jittery?
Initial load versus refetch
It helps to split loading into two distinct situations:
- Initial load. Nothing is on screen yet, so a skeleton that mirrors the final layout is a reasonable and even pleasant choice.
- Refetch. Data is already visible, for example after the user presses refresh or changes a filter. Wiping it out and replacing it with a full-page loader throws away useful context.
During a refetch, keep the previous data rendered and show a small inline indicator near the thing being updated. Spinners do not reassure anyone; at best they are tolerated. For very fast responses, consider delaying the indicator briefly so it only appears when the wait is noticeable.
Why a working fetch call is only a small part of the job
The request itself is the easy piece:
const res = await fetch("/api/students");
const data = await res.json();
setStudents(data);
What this snippet leaves unanswered is everything around it. What does the user see while the promise is pending? What if fetch rejects, or the server responds with a non-2xx status (which fetch does not treat as an error by itself)? What if the body is an empty array? The useful question is not how to get the data but which states this request can be in at any moment.
If students is the only piece of state, the rest of the UI has to infer the situation from whether that variable is null, empty or populated. null might mean "still loading" or "the request failed", and there is no way to tell them apart. That is guessing dressed up as state management.
An empty result is a success, not an error
This point is worth isolating because many codebases get it subtly wrong. A 200 OK response carrying [] is the server doing its job correctly: it is reporting that there is nothing to show.
"No students found" and "Failed to load students" describe completely different situations, yet plenty of apps render the same grey placeholder for both. A shared <p>Something went wrong</p> for both merges two conditions that need different fixes: an empty state might invite the user to clear a filter, while an error state should explain the failure and offer recovery.
What a useful error state looks like
The generic "Something went wrong" helps nobody. A better error state:
- tells the user something they can act on, such as checking their connection or trying again later;
- offers a retry button when retrying has a realistic chance of working;
- never exposes a raw stack trace or internal error payload in the UI;
- stays contained to the part of the screen that failed.
The last item is the one most often skipped. On a dashboard with five widgets, one failing request should not blank out the four that loaded successfully. Scope error handling per section, through local state or error boundaries around independent regions.
Forms move through their own life cycle
Writing data has even more states than reading it:
Idle → Editing → Submitting → Success / Error
Guarding against duplicate submissions
Consider a user who clicks Submit three times because the button gave no visible feedback after the first two clicks. Unless the button is disabled during submission, or the handler checks an isSubmitting flag and returns early when a request is already in flight, the server receives three requests. For an order form, that means one customer billed three times for a single t-shirt. For payments, the backend should also deduplicate, for example with an idempotency key.
Keeping user input when a submission fails
When the request fails, leave the form populated. Making someone retype a long message after a network blip wastes their time. Validation errors, whether they come from client-side rules or from the server, should appear next to the field they refer to rather than in a vague banner at the top of the form.
Replace scattered booleans with named states
Left unchecked, a component tends to accumulate a set of flags like these:
isLoading, isError, isSubmitting, hasData, isEmpty
Each flag is sensible alone. Together, five booleans allow 32 combinations, only a few of them meaningful, and nothing prevents isLoading && isError from both being true.
A student list page does not need a formal state-machine library. What it does need is a deliberate decision, made before the first useState call, about which distinct states the screen can occupy. Represent the current one with a single value, such as a status field holding "loading", "success", "empty" or "error", and let rendering branch on that value instead of reconstructing the situation from several independent flags. In TypeScript, a discriminated union makes the impossible combinations unrepresentable; the post on typing useState, useReducer and custom hooks covers the typing side in more depth.
A checklist to run before building the next screen
Answer these before writing the component's return:
- What is visible before any data arrives?
- What happens when the request fails?
- What happens when it succeeds but returns nothing?
- What does the user see while existing data is being refreshed?
- What happens during a form submission?
- What happens if the form is submitted twice?
- What happens when validation fails?
Wrapping up
The gap between a demo and a production interface is rarely in the happy path. It lies in the loading, empty, error, refetching and submitting states that appear the moment real users and real networks get involved. Name those states explicitly, design each one deliberately, keep failures local, and protect user input and server effects from repeated or failed submissions.