Production
RemoteData grew a fifth state
Every TypeFirst codebase models async data with the classic four-state union instead of isLoading booleans:
// Core/Data/RemoteData.ts
export type RemoteData<E, T> =
| NotAsked
| Loading
| Failure<E>
| Success<T> Four states, one exhaustive switch per view, no { data?, error?, isLoading } objects where all three are somehow set at once. If that pattern is new to you, start with impossible states — this post is about what happens the first time a system discovers the pattern is missing a state, and why that discovery is the whole argument for unions.
When HTTP 200 doesn't mean "done"
The moment any part of your product goes asynchronous — a refund handed to a payment provider, a CSV export handed to a worker, a document sent for signature — the request stops being the operation. The browser calls your API, your API queues the job, and the HTTP response only means "the request was accepted". The truth — did it actually go through? — arrives seconds later, pushed back to the browser over SSE when the worker (or the provider's webhook) reports in.
Map that onto four-state RemoteData and you hit a wall: after the HTTP response, the request isn't Loading (the call finished), and it isn't Success (nothing is confirmed). The workflow has a state the type doesn't. The boolean-flag crowd solves this with isWaitingForConfirmation next to isLoading — sixteen combinations, here we go again. The TypeFirst solution is to say the true thing:
// Core/App/ConfirmedRemoteData.ts
export type ConfirmedRemoteData<E, T> =
| NotAsked
| LoadingApi // HTTP request in flight
| WaitingConfirm // accepted; awaiting worker confirmation via SSE
| Failure<E>
| Success<T> Five states, named after reality. The button spinner and the "waiting for the provider…" indicator are different UI for different variants — not one spinner driven by two booleans that hopefully never disagree.
Timeouts are failures, as values
What if the confirmation never comes — the worker dies mid-job? The WaitingConfirm constructor arms a 30-second timer, and the timeout is not an exception, not a dangling spinner: it's a typed failure injected back into the state machine:
export type WaitingConfirmTimeout = "WAITING_CONFIRM_TIMEOUT"
export type Failure<E> = { _t: "Failure"; error: E | WaitingConfirmTimeout }
export function waitingConfirm<E, T>(
timeoutFn: (failure: ConfirmedRemoteData<E, T>) => void,
): ConfirmedRemoteData<E, T> {
const timer = setTimeout(
() => timeoutFn({ _t: "Failure", error: "WAITING_CONFIRM_TIMEOUT" }),
30_000,
)
return { _t: "WaitingConfirm", timer }
} Note the error type: E | WaitingConfirmTimeout. Every view that renders a failure is forced by the compiler to decide what "we never heard back" looks like — separately from the endpoint's own business errors. That's a failure mode teams usually discover in production, staring at a spinner that never stops. Here it gets decided in a code review, because the union made it a case you have to write.
The race condition that became a switch case
Distributed systems play cruel tricks: sometimes the SSE confirmation arrives before the HTTP response that caused it. With boolean flags, that's a heisenbug. With a five-state machine, it's two lines — the SSE handler simply checks which state the submission is actually in:
// Action/Refund/SubmitRefund.ts — SSE confirmation handler
if (action._t !== "SUBMIT_REFUND" || action.crd._t !== "LoadingApi") {
// Because SSE may come back faster than the API call
return [authState, cmd()]
} The guard reads like the comment. There's no flag to forget to reset, because there are no flags — there is one value, in exactly one of five states, and every transition is a pure function from one variant to the next.
The lesson: states are discovered, unions absorb discovery
Nobody designs ConfirmedRemoteData on day one. You ship with four-state RemoteData, and then the first queue-and-confirm feature reveals a fifth state that genuinely exists in the world. The union absorbs it: add a variant, and the compiler walks you to every switch that must now handle it. Total cost, one afternoon.
Now imagine retrofitting isWaitingForConfirmation into a codebase where a dozen components each combine isLoading and error in their own way. That's the difference between a model you extend and a pile of flags you excavate. Design for the states you know; pick representations that welcome the states you don't.
RemoteData ready to use — and to extend.