State machines for messy human workflows
A state machine names what a workflow is doing now and which transitions are allowed next. You can implement the idea with a pure function, a reducer, or a statechart library.
The contract matters when peers fail on their own schedules: a user acts, the operating system interrupts audio, the network disappears, a remote room reconnects, and a file still needs to reach disk. Booleans hide their combined state. A machine names it.
Start with a decision table
Many workflows need a state machine before they need a statechart library. If a screen must decide which commands are legal, write a pure policy function.
type WorkflowStatus =
| "in_progress"
| "generating"
| "failed"
| "ready"
| "submitted"
type CommandKind = "generate" | "retry" | "update" | "submit"
function commandsFor(status: WorkflowStatus, hasOutput: boolean, uploadPending: boolean): CommandKind[] {
if (uploadPending || status === "generating") return []
if (status === "in_progress") return ["generate"]
if (status === "failed") return ["retry"]
return hasOutput ? ["update", "submit"] : ["update"]
}
The screen renders the returned commands. It does not reconstruct the workflow from several flags. A dispatch path can execute the selected command and reject a second command while the first is in flight.
A reviewer can answer “what is legal in ready?” by reading one place. This workflow needs no runtime library because it is linear and its state has one source.
Events are facts. State owns policy.
A long-running mobile workflow has peers that do not share memory or timing:
| Peer | Sends or owns |
|---|---|
| UI | User intent such as start, stop, retry, or discard |
| Native platform | Interruptions, route changes, permissions, and recorder errors |
| Network | Available or unavailable |
| Remote service | Connected, reconnecting, failed, or timed out |
| Disk | Files and metadata that survive process death |
| State machine | Legal transitions and ordered handoffs |
Each peer should report facts. The machine decides what those facts mean in the current state.
Without that boundary, one callback sets isLive, another clears isRecording, and cleanup releases whichever object it can find. The UI may show a live badge over a dead connection. A retry may start during native teardown. A file may disappear before recovery reaches disk.
The same event can mean different things
An event has meaning in context.
NETWORK_AVAILABLE can update a fact and leave a local session alone. In a session that permits live delivery, it can enter an attempt-live state. During finalization, it may have no effect.
REMOTE_TRACK_READY matters while the machine waits for the remote side. After a session chooses local-only mode, the late event should not trigger an unexpected handoff.
USER_STOP should reach finalization from recording, reconnecting, and a mode switch. In idle, it should do nothing. That keeps a late tap from creating a second teardown path.
AUDIO_INTERRUPTION_BEGIN should suspend an active capture session. From idle, it is noise. During suspension, the machine can preserve the current segment before releasing native resources. The broader recording design treats the phone as a small distributed system, where local files and metadata form a recovery boundary when network work fails. See Recording is a distributed system.
Model mode handoffs explicitly
Consider a recorder with two modes:
| Mode | Role |
|---|---|
| Local capture | Writes evidence to the device and works without a network |
| Live capture | Sends audio to a remote service and depends on connection health |
The product rule may be simple: prefer live delivery when allowed, keep local capture available, and continue locally when the remote path fails. Switching still has an order.
The handoff states carry the order: SwitchingToLive stops local before starting live; SwitchingToLocal reverses it. A user stop remains legal during either operation.
The current recording machine models these transitions directly. It has local and live branches, connection and room-join states, explicit switching states, and finalization. LiveKit failures can fall back to local recording. A timeout waiting for the remote path has a defined destination.
The machine keeps network state separate from recorder state. A network event can change what the machine may attempt without claiming that local audio stopped. That distinction protects the local artifact when the remote path disappears.
Async work needs a state and a deadline
Every awaited operation creates a temporary state. Starting a recorder, fetching connection details, joining a room, stopping native audio, and finalizing a file can hang or fail.
Put those operations in named states and give them deadlines:
awaiting_connection
-> connection_ready
-> awaiting_remote_track
-> switching_mode
-> active
any async state
-> success destination
-> explicit failure destination
-> timeout destination
The current machine wraps start, stop, finalization, permissions, and remote connection work with timeouts. A timeout becomes an event in the graph, with no indefinite UI wait or guessed cleanup.
Effects also need an owner. Entering switchingToLocal stops the live recorder. A successful stop leads to local startup; a failed stop leads to cleanup. The UI observes those states. It does not call both recorders from an effect chain.
Keep platform recovery in the graph
Native audio adds states that ordinary request flows do not have. The operating system can interrupt capture. The recorder can report a route loss. PCM health can become invalid. The machine needs places for those facts to go.
The current recording design has a suspended branch for audio interruptions and a capture-recovery state for unusable input. The capture monitor can report flatline, repeated buffers, static-like input, clipping, or recorder errors. The machine cleans up the active recorder, preserves what it can, and offers a retry path.
“Recording” is too broad to drive safe cleanup. starting, active, switching, suspended, finalizing, and captureRecovery each carry different legal actions.
Choose the smallest useful machine
| Shape | Fits when | Example |
|---|---|---|
| Discriminated union or reducer | Short linear flow | A request with retry |
| Pure policy function | One source already owns lifecycle | Status to available commands |
| Statechart library | Distributed peers, nested modes, and ordered handoffs | Mobile capture with local and live paths |
Use a pure function when it answers the question. Use a reducer when events and state fit in one small module. Use a statechart when events change meaning across nested phases and asynchronous handoffs need explicit ownership.
In review, ask:
- What named state answers “where are we?”
- Which states handle this event, and which ignore it?
- Does every async operation have success, failure, and timeout destinations?
- Can a user stop safely during a handoff?
- Does remote failure preserve local evidence?
- Does interruption preserve the segment before native teardown?
If those answers live in scattered callbacks, the system already has a state machine. Writing it down makes the rules testable, makes the illegal paths visible, and keeps the interface honest about what actually happened.