TypeScript / A technical note
Model the states, not the booleans.
A small TypeScript pattern for making async interfaces easier to reason about—and harder to put in an impossible state.
An asynchronous interface often starts with a few booleans: isLoading, hasError, and maybe isEmpty. Each flag is easy to understand on its own. The trouble begins when they describe the same interaction independently.
What should the interface show if isLoading and hasError are both true? Can there be data and an empty state at the same time? The component now needs rules for combinations that should never have existed.
A useful starting point is to describe the states the interface can actually be in.
Give each state a name
For a small request, a TypeScript discriminated union can express the possibilities directly:
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "empty" }
| { status: "error"; message: string };
The status property is the discriminant. When you check it, TypeScript can narrow the rest of the value to the matching shape. A success has data. An error has a message. The loading state does not pretend to have either.
This does not make every invalid application state impossible. It does remove a particular source of ambiguity: several unrelated flags competing to describe one request.
Let the rendering follow the model
The component can now choose a view using the same vocabulary:
switch (state.status) {
case "idle":
return <StartPrompt />;
case "loading":
return <LoadingMessage />;
case "success":
return <Results items={state.data} />;
case "empty":
return <EmptyMessage />;
case "error":
return <ErrorMessage message={state.message} />;
}
These component names are illustrative. The important part is the relationship: the model names the possible situations, and the interface handles each one deliberately.
An empty result deserves its own thought. It can mean a search returned no matches, an account has no records yet, or a filter is too restrictive. The message should help someone understand which situation they are in and what they can do next.
Recovery is part of the interaction
An error message alone is rarely enough. A retry button should repeat the intended action. A reset should return to a known starting point. If the user changes the query while a request is pending, the result of the old request should not overwrite the new one.
For real network requests, that last detail may need cancellation or a way to ignore stale responses. A discriminated union helps describe the result, but it does not solve request ordering by itself.
The same care applies to accessibility. Status changes should be understandable without relying on color. A live region can announce meaningful updates while focus remains on the control the person is using. Avoid moving keyboard focus just because a request finished.
Keep the model proportionate
A single request with a handful of states does not need a state-machine library. A union, a small transition function, and a few focused checks can be enough.
More involved interfaces may need to preserve existing data while refreshing, distinguish a background failure from an initial failure, or track multiple concurrent operations. Extend the model to represent those requirements when they appear. Do not squeeze them into a model that no longer tells the truth.
You can explore a small implementation in the interface states demo. It uses simulated data so the states and recovery paths can be inspected without a backend.