Why State Machines Are a Game-Changer for UI DevelopmentA practical example: adding an "edit older version" confirmation guard to a document editor — with zero changes to existing UI triggers.

    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 visualizerstately.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