Skip to content

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.

lib/delivery-machine.ts
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.