Document cover
Sometimes Buttons are Workflows, Not just CallbacksA "restore version" button isn't one operation. It's loading, diffing, confirming, restoring, verifying, and failing — gracefully. Seven states. Model it as a state machine, not a pile of useStates. How I model complex UI workflows in XState, with real patterns from Seed's editor.

Here's how most developers think about a "restore previous version" button:

<button onClick={handleRestore}>Restore</button>

One callback. One function. Easy, right?

Here's what actually happens when you click that button:

  1. Loading — Fetch the previous version from the server. Could take 200ms. Could take 5 seconds. Could fail.

  2. Diffing — Show the user what changed. What are we about to lose? What are we about to restore?

  3. Confirming — The user needs to explicitly approve. This is a destructive operation.

  4. Applying — Write the restored content. Meanwhile, does the user keep editing? Do we block the UI?

  5. Verifying — Did the restore actually work? What does the document look like now?

  6. Cleaning up — Invalidate caches. Update the version history sidebar. Refresh any dependent views.

  7. Failing — At any of these steps. Gracefully.

That's not a callback. That's a workflow with loading, ready, diffing, confirming, restoring, success, and failure states — plus transitions between all of them.

Where useState falls apart

The instinct is to model this with booleans:

const [isLoading, setIsLoading] = useState(false)
const [isDiffing, setIsDiffing] = useState(false)
const [isRestoring, setIsRestoring] = useState(false)
const [error, setError] = useState(null)

Four booleans. That's 16 possible combinations. How many are valid? Maybe 7. The other 9 are bugs waiting to happen.

What happens when isLoading && isRestoring both become true? What happens when the user closes the diff modal while the restore is still in flight? What happens when the error state gets set but isLoading never resets?

You can handle all of this — but you're doing it manually, with discipline, every time. And discipline doesn't scale.

Modeling it as a state machine

Here's the same flow modeled as states:

idle → loading → diffing → confirming → restoring → success
                          ↘ error      ↘ error    ↘ error

Seven states. Every state knows exactly what the UI should render. Transitions are explicit. Impossible states — like "restoring while still loading" — literally can't be represented.

In XState:

const restoreMachine = createMachine({
  id: 'restore',
  initial: 'idle',
  states: {
    idle: {
      on: { RESTORE: 'loading' }
    },
    loading: {
      invoke: {
        src: 'fetchVersion',
        onDone: { target: 'diffing', actions: 'setDiff' },
        onError: { target: 'error', actions: 'setError' }
      }
    },
    diffing: {
      on: { CONFIRM: 'confirming', CANCEL: 'idle' }
    },
    confirming: {
      on: { EXECUTE: 'restoring', BACK: 'diffing' }
    },
    restoring: {
      invoke: {
        src: 'applyRestore',
        onDone: { target: 'success', actions: ['invalidateCaches', 'refreshViews'] },
        onError: { target: 'error' }
      }
    },
    success: {
      after: { 3000: 'idle' } // auto-dismiss after 3 seconds
    },
    error: {
      on: { RETRY: 'loading', DISMISS: 'idle' }
    }
  }
})

This isn't more code than the boolean version — it's the same amount, just structured differently. The difference is that the state machine version has zero impossible states by construction.

The UI becomes trivial

When the logic is explicit, the UI is just a switch statement:

const { state, send } = useMachine(restoreMachine)

switch (state.value) {
  case 'idle': return <RestoreButton onClick={() => send('RESTORE')} />
  case 'loading': return <Spinner />
  case 'diffing': return <DiffModal diff={state.context.diff} onConfirm={() => send('CONFIRM')} onCancel={() => send('CANCEL')} />
  case 'confirming': return <ConfirmDialog onExecute={() => send('EXECUTE')} onBack={() => send('BACK')} />
  case 'restoring': return <ProgressBar />
  case 'success': return <Toast type="success" message="Version restored" />
  case 'error': return <ErrorBanner error={state.context.error} onRetry={() => send('RETRY')} />
}

No if (isLoading && !isRestoring && !error) checks. No wondering whether you forgot to reset a boolean. The state machine guarantees you're in exactly one state at a time.

Why I keep reaching for state machines

This isn't about XState specifically — it's about the pattern. Whether you use XState, a hand-rolled reducer, or even a well-structured useReducer, the principle is the same: explicit states beat implicit booleans.

State machines force you to answer questions you'd otherwise postpone:

  • What happens if the user double-clicks the restore button?

  • What happens if the WebSocket connection drops during the restore?

  • What happens if the user navigates away mid-workflow?

  • Can the user edit the document while the diff is showing?

Answering these questions upfront feels like extra work. It's not. It's the same work you'd eventually do at 11 PM debugging a production bug — just moved earlier and done once.

The takeaway

Next time you're about to add a button that triggers "just one async call," pause. Ask yourself: is this really one operation, or is it a workflow with multiple states, transitions, and failure modes?

If it's the latter, model it explicitly. A state machine takes 20 minutes to write and saves you hours of debugging. The restore button in Seed's editor has six states. Yours probably has more than one too.

Photo by Андрей Сизов on Unsplash

Do you like what you are reading? Subscribe to receive updates.

Unsubscribe anytime