01 / The pressure
The first boolean was reasonable.
An upload starts with isLoading. That is enough to show a spinner. Then you add
an error message, a server processing stage, a Cancel button, and a retry. Each callback now
needs to remember which flags to set and which old flags to clear.
Nothing in three independent booleans prevents “loading, failed, and complete” from being true together. You can keep them consistent through discipline, but every new event has to preserve the agreement.
A state machine names the possible states and defines which events can move it from one state to another. The current state becomes part of the decision. A success event may be appropriate while processing and meaningless after cancellation.
Here, a document moves through uploading while bytes are sent and processing while the server prepares a preview. ready means that preview can be used.
Where are we?
Processing attempt 2. Exactly one lifecycle phase is current.
What happened?
A success callback arrives carrying attempt 1.
Does it belong here?
The phase allows success, but the attempt guard refuses this callback.
The phase check and the attempt check solve different problems. Naming a state does not automatically protect you from old work. We need both rules, and we will make both visible.
02 / Name the rules
Draw the paths you intend to allow.
The ordinary path is idle → uploading → processing → ready. Failures and
cancellation leave that path. Both allow another start, which allocates a new attempt ID.
Ready ends this upload row’s lifecycle.
A transition connects a source state to a destination in response to an event. A guard adds a condition. Every network event here must carry the current attempt ID; user actions, Start and Cancel, apply to the current row.
| Current phase | Event | Next phase | Requested effect |
|---|---|---|---|
| idle, failed, cancelled | start | uploading, with a new attempt | Start that upload |
| uploading | uploaded | processing | None |
| processing | succeeded | ready | None |
| uploading, processing | failed | failed | None |
| uploading, processing | cancel | cancelled | Request an abort |
| Every other pair | Any unlisted event | Unchanged | None |
uploaded means the adapter has confirmed receipt of the bytes. Preview processing
has not succeeded yet. The server’s processing belongs to the original upload operation, so this
event requests no additional effect.
Keeping a refused event as a no-op is a policy choice. We also return a reason so a caller can distinguish an old callback from an event that makes no sense in this phase. A missing transition does not throw or silently invent a destination.
Is this a finite state machine if it also has an attempt number?Control state and context
The six phases are the finite control states. The attempt number is additional context used by a guard. This is often called an extended state machine: we do not draw a new node for every attempt. File names, progress values, and error details could also be context without becoming separate lifecycle phases.
Our model keeps the attempt number after cancellation or failure so the next start can advance it.
03 / See the shape
Turn an event into a decision.
Begin with the phase table. The useful version wraps it with an attempt check and returns three things: the next state, the acceptance or refusal reason, and an effect request. Calculating that answer does no I/O.
The call site owns the current state. It captures attempt 1, cancels, starts attempt 2, and then delivers both success callbacks. Copy the complete file to run that sequence locally.
The phase rules alone: a table, switch, dictionary, enum, and match express the same nine allowed pairs. Missing pairs are refused.
export type Phase = 'idle' | 'uploading' | 'processing' | 'ready' | 'failed' | 'cancelled';
export type EventKind = 'start' | 'cancel' | 'uploaded' | 'succeeded' | 'failed';
export const transitions: Readonly<Record<Phase, Partial<Record<EventKind, Phase>>>> = {
idle: { start: 'uploading' },
uploading: { uploaded: 'processing', failed: 'failed', cancel: 'cancelled' },
processing: { succeeded: 'ready', failed: 'failed', cancel: 'cancelled' },
ready: {},
failed: { start: 'uploading' },
cancelled: { start: 'uploading' }
};
export function nextPhase(phase: Phase, event: EventKind): Phase | undefined {
return transitions[phase][event];
} type Phase string
type EventKind string
const (
Idle Phase = "idle"
Uploading Phase = "uploading"
Processing Phase = "processing"
Ready Phase = "ready"
Failed Phase = "failed"
Cancelled Phase = "cancelled"
Start EventKind = "start"
Cancel EventKind = "cancel"
Uploaded EventKind = "uploaded"
Succeeded EventKind = "succeeded"
Failure EventKind = "failed"
)
// A switch expresses the same transition table without a mutable package map.
func NextPhase(phase Phase, event EventKind) (Phase, bool) {
switch phase {
case Idle, Failed, Cancelled:
if event == Start {
return Uploading, true
}
case Uploading:
switch event {
case Uploaded:
return Processing, true
case Failure:
return Failed, true
case Cancel:
return Cancelled, true
}
case Processing:
switch event {
case Succeeded:
return Ready, true
case Failure:
return Failed, true
case Cancel:
return Cancelled, true
}
}
return phase, false
} Reading the TypeScriptA table, a tagged event, and a returned snapshot
Record describes a row for every phase. Partial lets each row
contain only its allowed events. Looking up a missing pair returns undefined. The event union lets the 'attempt' in event check narrow
to network events.
The transition function returns a new state for an accepted event and preserves the
input for a refusal. Readonly is a TypeScript restriction, not a runtime freeze.
Reading the PythonA dictionary table and frozen values
TRANSITIONS is a dictionary keyed by the current phase and event. A missing
key returns None, so the table has the same refusal behavior as the other
implementations.
Frozen, slotted dataclasses make the example’s state, event, and decision values
immutable by normal use. Python type aliases and annotations help readers and tools, but
are not runtime validation; untrusted JSON still needs a boundary before transition.
Reading the GoValue copies and a switch over phases
NextPhase returns a phase and a boolean. The boolean distinguishes “there is
no transition” from a valid result. Its switch expresses the same table without exporting
a mutable map.
State contains only a string-like phase and an integer, so passing it by
value gives the function its own copy. Event.Attempt is ignored for Start and
Cancel. Go’s string-based types can still hold unknown values, and its event struct cannot
require an attempt only for network variants. Unknown event kinds have no transition.
The shared input contractInternal values and attempt exhaustion
Begin with the initial state and feed back returned states. Attempts are monotonically
increasing positive integers local to that owner; zero means no start yet, so in idle a
network event carrying attempt zero passes the identity check and is refused by the
phase table. For equivalent number behavior, every example stops at JavaScript’s maximum
safe integer, 9,007,199,254,740,991. A further allowed Start returns attempt-limit without wrapping or reusing an ID.
These are internal model types: decode and validate network JSON or persisted state before it reaches the transition function. Network attempts must come from the owner that started the work. The examples check the same event sequences, including stale failures, duplicate starts, cancellation, and the counter boundary, through shared cases and native tests.
04 / Follow the events
A valid transition can still belong to the wrong attempt.
Before delivering the old success, predict what each version will do. Both are processing attempt 2. Both allow a success from processing. Only one checks whose success it is.
Then try success after cancellation or success before processing. Those sequences reveal what the phase rules already protect, even before we add identity.
05 / Try a decision
Keep the rule where every caller meets it.
A disabled button helps the person using the UI. It cannot stop a late callback, an overlapping retry handler, or another caller. The transition decision remains the common gate.
06 / Give it a real job
The row owns state. The adapter owns work.
Imagine a document library with one upload row per selected file. That row’s controller creates the state, receives Start and Cancel from the UI, and receives Uploaded, Succeeded, or Failed from a transport adapter. It processes one event at a time.
The controller first calculates a decision, then commits its state, then handles the
requested effect. For upload, the adapter starts that attempt and captures its
ID in every callback. For abort, it requests cancellation of that attempt’s
client work. Rendering derives from the committed phase.
Capturing the ID matters. Reading the controller’s current ID when a callback finally arrives would relabel an old result as new. The event must retain the identity of the work that produced it.
An adapter failure to start should come back as a Failed event for that attempt. Repeated cancellation emits no second abort because Cancel has no transition from cancelled. If a real adapter’s abort operation can fail, handle that failure explicitly; it does not make the cancelled UI ready.
Now preview processing needs human approval. Add awaiting-review, route
processing success there, and define approval, rejection, and cancellation behavior. The
table, tests, and rendering change together. Callbacks still report events instead of each
inventing the whole lifecycle.
What cancellation does—and what it cannot promiseLocal state, client work, and server work
The returned abort is a request for an adapter to act. The examples only
print it. In a browser implementation, a controller might own an AbortController per attempt and abort fetch or monitoring when Cancel is accepted. The DOM standard defines
the signal and abort notification mechanism; it does not make arbitrary server work reversible. Read the AbortController contract.
A callback can already be queued, and a server may have stored the document or continued processing. Server cancellation, cleanup of abandoned uploads, retries without duplicate server effects, and access control need their own protocols.
When the row is removed, its owner should stop subscriptions, release file references, and cancel relevant client work. Scope callbacks to that owner and dispose of it. Starting a new owner must not route an old owner’s events into a counter that happens to begin at 1 again.
A pure transition is not a concurrency primitiveSerialize the read, decision, and commit
The transition calculation is deterministic and does not mutate its inputs. Two callers can still read the same old state and race to install different answers. The owner must serialize event handling.
In this TypeScript caller there is no await between reading and committing state.
A Go service could give ownership to one goroutine receiving events, or protect the full operation
with appropriate synchronization. Sharing a machine across tasks or threads requires an ownership
or synchronization design.
Persisted workflows also need versioned writes, recovery, and a defined relationship between an effect and its committed state.
07 / Recognize the family
You can write the rules as data or as behavior.
This lesson uses a transition table, switch, or match. The GoF State pattern organizes state-dependent behavior into objects: a context holds its current State object and delegates operations to it. An Uploading state might handle a cancellation differently from a Ready state. The original catalog includes State among its behavioral patterns. See Design Patterns by Gamma, Helm, Johnson, and Vlissides.
Those are related designs, not interchangeable definitions. A finite state machine describes states and allowed transitions. State objects are one way to organize behavior around states. A table makes this small policy easy to inspect; separate objects may help when each state owns substantial behavior and collaborators.
| Question | Table / switch | State objects |
|---|---|---|
| Where does Cancel get decided? | The entry for the current phase and Cancel. | The current State object’s handler. |
| How do I review allowed paths? | Read the transition table in one place. | Follow handlers and changes to the context’s state. |
| Where does attempt validation belong? | In the shared event boundary before the lookup. | In a shared boundary or consistently enforced handler contract. |
Strategy also swaps behavior through a common interface. Its central question is which policy the caller wants to use. State asks what behavior is appropriate at this point in a lifecycle, and which events can change that point.
XState gives transitions and guards a public API.
XState models events that select enabled transitions from active states. Guards supply conditions for allowing a transition. Our attempt comparison is the kind of application-specific condition a guard can express.
XState transitions ↗XState guards ↗
A reducer can host the transition function.
React’s useReducer calls a reducer with state and an action and uses its returned
next state. That is a useful place to express these rules, though the reducer still has to enforce
the allowed events itself.
Build UIs?A video that plays on hover already refuses a stale success, and one day your upload row has to.
Where it already is in your components
If you have built a video preview that plays on hover, you have probably met this
console error: AbortError: The play() request was interrupted by a call to pause(). The pointer left before the video started, and the fix most of us learn is to handle the promise play() returns. Chrome’s write-up explains where it comes from: a media element runs this lesson’s model.
paused is its phase, and play() is an event whose reply comes
later. The HTML standard sets paused to false straight away, but while the element is still waiting
for data, the promise sits on a list of pending play promises that resolves only when
playback begins. A pause() that arrives first moves the element back to
paused, and its pause steps reject every promise still on that list with AbortError. That is section
04’s old success, refused by the browser: “now playing” belongs to a phase the element
has left. Loading a new source refuses it the same way, “interrupted by a new load
request.”
So the usual video.play().catch((error) => { if (error.name !== 'AbortError') throw error;
}) isn’t hiding a failure. It accepts a refused event and lets real errors through. The refusal
only covers a reply that hasn’t arrived: once the element has enough data, a pause() right after play() still leaves the promise resolved, so
a resolved promise means playback started, not that it is still going.
Your own upload row can read its phase the same way. In React or Svelte, derive the spinner from uploading or processing. Derive retry availability from failed or cancelled. Derive preview visibility from ready. Keeping these as derived values avoids a second set of flags that callbacks must synchronize. React’s state-structure guide makes the same broader recommendation to avoid contradictory and redundant state. Read the guide.
Independent facts can still remain independent. Whether the details panel is expanded does not need to double every upload phase. If two lifecycles really run independently, model them separately; nested or parallel statecharts may become useful when their coordination grows.
When you have to own it
Now the row can be canceled and retried. The first attempt’s reply is still on its way when the second one starts, and the transition function can refuse it only if the reply says which attempt it answers. Capture the attempt when the work starts, put it on every reply, and let the transition compare it with the attempt current at that moment. A reply that arrives after the row is gone needs the same care: whatever the row started, its teardown clears.
Keep effects out of a React reducer, which must be pure. Arrange committed state and effect execution through the surrounding controller or framework lifecycle, and account for that framework’s effect semantics. In React that means an effect that starts the transfer once the uploading state has committed. In Svelte the handler holds the decision, so it can start or abort the work in the same call.
An upload row whose spinner, retry button, and preview all read one phase value, with the lesson’s transition function as the reducer.
import { useEffect, useReducer } from 'react';
import { initialState, transition, type State, type UploadEvent } from '../upload';
// The lesson's transition function already has a reducer's shape: state and event in, next state out.
const reduce = (state: State, event: UploadEvent): State => transition(state, event).state;
// fetch resolves for an HTTP error status too, so a 500 has to become a failure here.
function requireOk(response: Response) {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
}
export function UploadRow({ name }: { name: string }) {
const [{ phase, attempt }, send] = useReducer(reduce, undefined, initialState);
// One phase value, read three ways. There is no isUploading, isFailed, or hasPreview
// flag for a callback to leave out of step (section 01).
const busy = phase === 'uploading' || phase === 'processing';
const canRetry = phase === 'failed' || phase === 'cancelled';
const preview = phase === 'ready';
// The reducer must stay pure, so the request starts here, once a new attempt has committed.
// Every reply names the attempt it answers; the transition insists on it (section 02).
useEffect(() => {
if (attempt === 0) return;
fetch(`/api/uploads/${name}`, { method: 'POST' })
.then(requireOk)
.then(() => send({ type: 'uploaded', attempt }))
.then(() => fetch(`/api/uploads/${name}/processed`))
.then(requireOk)
.then(() => send({ type: 'succeeded', attempt }))
.catch(() => send({ type: 'failed', attempt }));
}, [name, attempt]);
return (
<li>
{name}
{busy && <progress aria-label="Uploading" />}
{phase === 'idle' && <button onClick={() => send({ type: 'start' })}>Upload</button>}
{busy && <button onClick={() => send({ type: 'cancel' })}>Cancel</button>}
{canRetry && <button onClick={() => send({ type: 'start' })}>Retry</button>}
{preview && <img src={`/previews/${name}`} alt="" />}
</li>
);
}
08 / Make the call
Use the model when the paths are the problem.
A state machine earns its place when the answer to “can this happen?” depends on what already happened: canceling a job, reopening a support ticket, reconnecting a socket, or approving a document. Write the events and allowed paths before choosing a library.
A boolean remains a good fit for a genuinely two-state fact such as whether a disclosure is open. A short conditional can express a small transition rule. Naming the model does not require a class hierarchy or a dependency.
Watch for too many combinations. If upload phase, panel expansion, connectivity, and permission are all packed into one giant enum, the number of states may hide the rules. Separate independent facts; define how they constrain transitions where they actually interact.
The table can also be wrong. It will enforce “success before processing” perfectly if you accidentally allow that path. Tests should check forbidden transitions, duplicate events, and adverse event order—not only the happy path.
09 / Take the idea with you
Where are we, what happened, and does it belong?
Explain the idea without its name: “The upload has one current stage. Every event goes through the same rules before it can change that stage. Results also have to belong to the current attempt. The owner commits the answer and then handles any requested work.”
From memory: can a matching attempt succeed while uploading? Can an old attempt succeed while the current one is processing? Does returning cancelled stop the server? All three answers are no.
Pick a workflow you maintain. List its events, then try one event too early, one twice, and one from an abandoned run. Which should change state? Which should produce no effect? Those questions are the start of your transition table.
Connections to follow nextRelated lessons
- Strategy separates policy selection from a lifecycle’s allowed transitions.
- Pub-sub distributes events. Each subscriber still needs to decide which events belong in its current state.
- Ownership, aliasing, and lifetimes helps place the controller, callbacks, and cleanup with the right owner.