Skip to content
Kamau Wanyee

State machines for messy human workflows

By ··
·7 min read
#posts#notes#react-native#architecture

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:

PeerSends or owns
UIUser intent such as start, stop, retry, or discard
Native platformInterruptions, route changes, permissions, and recorder errors
NetworkAvailable or unavailable
Remote serviceConnected, reconnecting, failed, or timed out
DiskFiles and metadata that survive process death
State machineLegal 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:

ModeRole
Local captureWrites evidence to the device and works without a network
Live captureSends 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.

local readynetwork up + policy allowsuser stopconnection details readypolicy keeps localremote track readytimeout / join failurelocal stopped, live startedhandoff failedremote reconnectingtrack lostreconnect succeedsreconnect gives uplive stopped, local startedaudio interruptionaudio interruptionresume policyuser stopuser stopStartingLocalLocalActiveAttemptingLiveFinalizingAwaitingRemoteSwitchingToLiveLiveActiveErrorReconnectingSwitchingToLocalSuspended

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

ShapeFits whenExample
Discriminated union or reducerShort linear flowA request with retry
Pure policy functionOne source already owns lifecycleStatus to available commands
Statechart libraryDistributed peers, nested modes, and ordered handoffsMobile 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.