State machines for unreliable networks
Boolean flags describe a UI that is always online. A finite state machine describes the one your users actually have.
- Published
- Reading time
- 3 min read
- Filed under
- Architecture, State
- Author
- Bibaswan Prasai
Most interfaces model progress with independent booleans: isLoading,
isSaved, isSyncing, hasError. Four flags describe sixteen states, of
which perhaps five are legal. The other eleven are bugs waiting for a bad
connection to find them — saved and syncing, loading and errored, a spinner
over a success message.
A finite state machine inverts the default. Instead of enumerating conditions and hoping the combinations never occur, you enumerate the legal states and make everything else unrepresentable.
The delivery example
A courier marks a package delivered. The device has no signal. The write has to survive the app being backgrounded, the battery dying, and the same package being marked twice.
type State =
| "queued"
| "assigned"
| "in_transit"
| "offline_pending"
| "delivered"
| "failed";
type Event =
| { type: "ASSIGN" }
| { type: "DEPART" }
| { type: "COMPLETE" }
| { type: "CONNECTION_LOST" }
| { type: "CONNECTION_RESTORED" }
| { type: "REJECT"; reason: string };
const transitions: Record<State, Partial<Record<Event["type"], State>>> = {
queued: { ASSIGN: "assigned" },
assigned: { DEPART: "in_transit", REJECT: "failed" },
in_transit: { COMPLETE: "delivered", CONNECTION_LOST: "offline_pending" },
offline_pending: { CONNECTION_RESTORED: "in_transit", REJECT: "failed" },
delivered: {},
failed: {},
};
export function next(state: State, event: Event): State {
return transitions[state][event.type] ?? state;
}Thirty lines, no dependency. The table is the specification, and the fallback
?? state means an event arriving in the wrong state is ignored rather than
corrupting anything. A duplicate COMPLETE while already delivered is a
no-op, which is exactly the retry semantics you want.
Why this beats flags offline
Impossible states stop compiling. delivered has no outgoing transitions,
so no sequence of events walks back out of it. With booleans, isDelivered && isSyncing is just an unfortunate afternoon.
The queue has a schema. Every pending mutation is a state plus an event. Replaying the queue after reconnection is a fold over that list, and the result is deterministic regardless of the order the network happened to recover in.
Conflict resolution has somewhere to live. When the server says failed
and the device says delivered, the resolution is a rule about two known
states — not an archaeology exercise across four flags captured at different
moments.
The UI becomes a lookup. One switch over State renders the whole
component. There is no combination of conditions to reason about, which means
there is no combination to get wrong.
switch (state) {
case "offline_pending":
return <Banner tone="warning">Saved on device — will sync</Banner>;
case "delivered":
return <Banner tone="success">Delivered</Banner>;
case "failed":
return <Banner tone="error">Rejected</Banner>;
default:
return <Progress state={state} />;
}Testing it is trivial
A transition table is a pure function, so the tests need no renderer, no network mock and no fake timers:
expect(next("in_transit", { type: "CONNECTION_LOST" })).toBe("offline_pending");
expect(next("delivered", { type: "COMPLETE" })).toBe("delivered");You can also assert properties over the whole table — that every state is
reachable from queued, that terminal states have no exits, that no event
appears in a state where it makes no sense. Those are loops over the object,
and they catch the transitions someone adds in a hurry six months from now.
When not to reach for one
A machine is overhead when the thing genuinely has two states. A modal is open
or closed; useState<boolean> is the right answer and wrapping it in a
transition table is ceremony.
The signal to switch is a bug report of the form “it showed both at once.” That sentence means the states are not independent and never were. Write the table, delete the flags, and the class of bug goes with them.
Reach for a library — XState and friends — when you need hierarchy, parallel regions, or delayed transitions. For a linear flow with a retry loop, the thirty-line object above has no runtime cost, no version to keep current, and fits on one screen where the whole team can read it.