The Problem
You're building a document editor. Users can navigate to older versions of a document. If they start editing an old version, it silently creates a branch in the document history — often unintentional. You need to warn them first.
Sounds simple, right? But your app has multiple ways to enter edit mode:
Clicking the document title
Clicking the editor area
Auto-entering edit mode when a draft exists
Keyboard shortcuts
With traditional useState-based approaches, you'd need to find every single trigger and add a guard. Miss one? Bug. Add a new entry point later? Probably another bug.
The State Machine Approach
With a state machine, editing is a state — not a boolean. Every path into that state goes through the machine. Add a guard in one place, and it applies everywhere.
Here's the mental model:
Empty Mermaid block
The key insight: we didn't touch any UI code that triggers edit.start. The machine routes the transition based on context.
Before: Boolean Spaghetti
Without a state machine, you'd end up with something like this scattered across your components:
// ❌ Every edit trigger needs the guard
function onTitleClick() {
if (!isLatest) {
setShowConfirmDialog(true)
return
}
setIsEditing(true)
}
function onEditorClick() {
if (!isLatest) {
setShowConfirmDialog(true) // duplicated!
return
}
setIsEditing(true)
}
// And what about auto-edit when a draft exists?
useEffect(() => {
if (hasDraft && !isLatest) {
// Wait, do we show the dialog here too?
// What if the user navigates to latest while the dialog is open?
}
}, [hasDraft, isLatest])
Every new entry point means another place to remember the guard. Every edge case (draft exists + old version + account switch) multiplies the conditionals.
After: One Machine, One Guard
With XState, the guard lives in the machine definition:
// ✅ One place, all entry points covered
'edit.start': [
{
target: 'confirmingOldVersionEdit',
guard: 'canEditOldVersion', // old version → confirm first
},
{
target: 'editing',
guard: 'canTransitionToEditing', // latest version → go ahead
},
],
The auto-edit transition for existing drafts gets the same treatment:
// Auto-edit guard: only on latest version
always: {
target: 'editing',
guard: ({context}) => context.shouldAutoEdit && context.isLatestVersion,
actions: ['clearShouldAutoEdit', 'setDepsFromPublished'],
},
The confirmation state handles only its own concern:
confirmingOldVersionEdit: {
on: {
'edit.confirm': {
target: 'editing',
actions: ['setDepsFromPublished'],
},
'edit.cancel': {
target: 'loaded',
},
},
},
The UI: Dead Simple
The dialog component is purely declarative — it reads state and sends events:
function OldVersionEditDialog() {
const isConfirming = useDocumentSelector(selectIsConfirmingOldVersionEdit)
const send = useDocumentSend()
return (
<AlertDialog open={isConfirming}>
<AlertDialogContent>
<AlertDialogTitle>Edit older version?</AlertDialogTitle>
<AlertDialogDescription>
Editing this version will create a new branch in the document history.
</AlertDialogDescription>
<AlertDialogCancel onClick={() => send({type: 'edit.cancel'})}>
Cancel
</AlertDialogCancel>
<AlertDialogAction onClick={() => send({type: 'edit.confirm'})}>
Edit Anyway
</AlertDialogAction>
</AlertDialogContent>
</AlertDialog>
)
}
Drop this component anywhere in the tree. It works because it talks to the machine, not to local state.
The Full Picture
Here's the complete document lifecycle with the new confirmation guard:
Empty Mermaid block
Why This Matters
ConcernBoolean approachState machineNew edit entry pointMust add guard manuallyAutomatic — goes through machineEdge case (draft + old version)Another if somewhereGuard on always transitionTestingMock click handlers, check booleansSend events, assert statesVisualizing behaviorRead code, build mental modelGenerate diagram from definitionAdding new states (e.g. "merging")Refactor everythingAdd a state node
The real win isn't fewer lines of code — it's fewer places where things can go wrong. When behavior lives in the machine, you can reason about it in one place, test it in isolation, and visualize it as a diagram.
Testing: Events In, States Out
State machine tests are clean because they're just input → output:
test('edit.start on old version → confirmation state', () => {
const actor = createActor(documentMachine, {
input: { isLatest: false, canEdit: true, /* ... */ }
})
actor.start()
// Simulate document load
actor.send({type: 'document.loaded', document: mockDoc})
actor.send({type: 'draft.resolved', draftId: null, content: null})
// Try to edit
actor.send({type: 'edit.start'})
// Machine routes to confirmation, not editing
expect(actor.getSnapshot().matches('confirmingOldVersionEdit')).toBe(true)
})
test('edit.confirm → enters editing', () => {
// ... setup in confirmingOldVersionEdit state ...
actor.send({type: 'edit.confirm'})
expect(actor.getSnapshot().matches('editing')).toBe(true)
})
No DOM, no mocking click handlers, no rendering components. Pure logic.
Getting Started
If you're new to state machines in UI:
Start small — model one complex interaction (a multi-step form, a data loading flow)
Use the visualizer — stately.ai/viz turns your machine definition into an interactive diagram
Think in states, not booleans — instead of isLoading && !isError && hasData, model loading, error, loaded as distinct states
The code from this post is from a real production app. You can see the full implementation in PR #459, specifically:
Machine definition: document-machine.ts (commit 73c6efee7)
React hooks & selectors: use-document-machine.ts
UI dialog: resource-page-common.tsx
Photo by Joshua Ferrer on Unsplash
Do you like what you are reading?. Subscribe to receive updates.
Unsubscribe anytime