When a React screen loads data from a server, its first design often starts with a few booleans: isLoading, hasError, perhaps isSaving. As the screen grows, those flags begin to contradict one another. Is data visible while it refreshes in the background? Did the first request fail, or did an error occur while the user was editing existing data? Should an empty result, missing permission and a temporary network failure look the same?
These questions do not become difficult because of a component. The deeper issue is that the possible states of the interface were never modelled explicitly. A state-machine approach treats user-visible states and the transitions between them as a contract. The goal is not to draw an academic diagram; it is to remove impossible combinations from the code and make the experience predictable.
Why boolean flags stop being enough
Independent flags allow combinations that make no product sense. For example, isLoading: true, data: [] and error can exist at the same time. It is unclear which screen this represents. One developer may show a spinner, another an error panel, and a third the empty-state component.
The problem is not only visual. Every new flag increases the number of combinations that need testing. Three booleans create eight possibilities; five create thirty-two. Most have no product meaning, yet the code considers them possible.
It is safer to write states as named, mutually exclusive options:
type ResourceState<T> =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "ready"; data: T; isRefreshing: boolean }
| { kind: "empty" }
| { kind: "error"; message: string; canRetry: boolean };
In this model, a resource cannot be both empty and ready. The state “data is on screen and refreshing in the background” does not disappear either; it is an explicit property of ready.
List user-visible states first
Before writing code, describe the screen’s journey in plain language. A list page may need these states:
- initial loading while no content is available,
- a successful result with items,
- a successful result with no items,
- quiet refresh while existing data remains visible,
- a retryable error,
- a permission-required or access-revoked state,
- a save operation initiated by the user.
This list reveals that an empty state is not an error and that refresh should not behave like an initial load. For every state, note the next action a person can take: wait, retry, remove a filter, sign in or keep editing.
Naming states in product language matters. Names such as refreshing, permissionDenied or savingDraft make conversations between design, development and testing easier than phase2 or isBusy.
Define transitions with events
A state machine is more than a list of states; it also says which event can produce which transition. A successful response received during loading goes to ready when it contains items or empty when it does not. A refresh requested in ready sets isRefreshing: true without removing visible data. If that refresh fails, keep the old data and give the user a small piece of feedback.
This distinction matters during network delays. A person should not wait through a full-screen spinner to see a list they already had. Likewise, a late response from an old request must not overwrite the result of a newer filter. A request identifier, cancellation signal or current-parameter check ensures that only a valid response can change state.
These rules can live in a reducer, a custom hook or the state object of a data-fetching library. The critical point, regardless of the tool, is that transitions are readable in one place.
Separate resource state from mutation state
Loading a list and saving a record do not share the same lifecycle. When one isLoading flag represents both, a whole screen can be locked unnecessarily while the user submits a form. Model the resource and user actions separately instead.
A profile, for example, can stay ready while a Save button passes through submitting, succeeded and failed. If an error belongs to a form field, show it beside that field. If the network has a broader problem, preserve form data and offer a retry. This design does not hide the error’s context from the user.
When using an optimistic update, the rollback path must be part of the transition model too. A count can increase immediately after a person adds a reaction, but the previous value must return when the request is rejected, with an explanation when useful. Assuming success does not eliminate the failure path.
Move tests from screenshots to transitions
The most practical gain from a state-machine approach is testing. Instead of producing every state through random UI conditions, test the question “which state should follow this event?” Empty results, retries, two consecutive refreshes and late responses can be tested independently of the component tree.
Component tests remain valuable, but they should verify how a state appears to the user. A transition test proves that the state itself is correct. The distinction protects flows that users see rarely but that become critical when something fails.
Not every screen needs a complete machine
Thinking in state machines does not mean using a large library or a visual diagram for every button. A simple interaction may need only two well-named states. Complexity appears when concurrent requests, rollback, drafts, permissions, multi-step forms or real-time updates arrive.
The test is simple: does the product encounter a screen state that is difficult to explain to a user? If so, naming the states and transitions is usually better than adding another boolean. An interface cannot remove all network uncertainty, but it can make uncertainty understandable for users and testable for the team.