Antigravity × XState v5 State Machine Design Guide — Build Type-Safe UI Flows, Workflows, and Form Validation
Replace state management that boolean flags can no longer explain with XState v5 machines. Covers five production patterns — multi-step forms, OAuth and MFA, WebSocket reconnection, the Actor Model, and the Inspect API — plus the snapshot-persistence trap that fails silently.
Tap the back button on a checkout form fast enough and the request that already went out goes out again. I responded to that report by adding isSubmitting, then hasSubmitted, then a third flag — and somewhere in the middle of staring at a useEffect dependency array I realized I could no longer explain, out loud, which combination of flags meant what.
The real culprit wasn't the race condition. It was that nothing in the code said which state the form was actually in. Five booleans give you thirty-two combinations. Maybe four of them are meaningful. The rest are states that should never happen but are perfectly reachable.
XState v5 brings finite state machines (FSMs) and statecharts to TypeScript with full type safety. When combined with Antigravity's AI agents, you can go from designing state transition diagrams to generating code and tests in a single workflow.
What follows runs from writing machine definitions to the parts that only bite you after the code is in production. It assumes React and TypeScript experience (the basics are in Antigravity × React Complete Guide) and that you've reached the point where adding one more boolean is no longer an option.
XState v5 Core Architecture and Antigravity Integration
State Machine Fundamentals
In XState v5, you declare state transitions as a "machine definition." The API was redesigned from v4 to be far more TypeScript-friendly.
// Basic XState v5 machine definitionimport { setup, createActor, assign } from 'xstate';// Use setup() for type-safe machine definitionconst toggleMachine = setup({ types: { context: {} as { count: number }, events: {} as { type: 'TOGGLE' } | { type: 'RESET' }, }, actions: { increment: assign({ count: ({ context }) => context.count + 1, }), },}).createMachine({ id: 'toggle', initial: 'inactive', context: { count: 0 }, states: { inactive: { on: { TOGGLE: { target: 'active', actions: 'increment', }, }, }, active: { on: { TOGGLE: 'inactive', RESET: { target: 'inactive', actions: assign({ count: 0 }), }, }, }, },});// Create an actor to execute state transitionsconst actor = createActor(toggleMachine);actor.subscribe((state) => { console.log(`State: ${state.value}, Count: ${state.context.count}`); // State: inactive, Count: 0});actor.start();actor.send({ type: 'TOGGLE' });// State: active, Count: 1
Leveraging Antigravity for Machine Design
Antigravity's AI agents can generate state transition diagrams from natural language and convert them into XState v5 code. Adding the following rules to your .antigravity/rules directory improves the quality of AI-generated state machines.
// .antigravity/rules/xstate.md
## XState v5 Code Generation Rules
- Always use the setup() API and define context and event types
- Do not use v4's Machine() or interpret() (v5 uses createMachine / createActor)
- Pre-define actions in setup()'s actions object and reference them by string
- Define guard conditions in setup()'s guards
- Implement async operations using invoke (Promise/Observable) or spawn (child Actors)
When you tell Antigravity's chat panel to "design a state machine for the auth flow," it generates type-safe code that follows these rules.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You'll be able to diagnose and eliminate 'impossible state' bugs in complex UIs using state machine visualization and formal verification
✦You can lift five production patterns — multi-step forms, auth flows, WebSocket reconnection, parallel actors, and runtime inspection — straight into your own project
✦You'll avoid the silent-hang failure that happens when a persisted snapshot outlives the machine definition, using versioned storage and a restricted restore set
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Multi-step forms for user registration, subscription sign-ups, and checkout flows are a classic state management challenge. Designing them as state machines lets you prevent invalid transitions at the type level — for example, making it impossible to reach the payment screen before completing the address form.
// Using with React (@xstate/react v5)import { useMachine } from '@xstate/react';function CheckoutForm() { const [state, send] = useMachine(multiStepFormMachine, { actors: { submitOrder: fromPromise(async ({ input }) => { const res = await fetch('/api/checkout', { method: 'POST', body: JSON.stringify(input), }); if (!res.ok) throw new Error('Payment failed'); return res.json(); }), }, }); // Render the appropriate component based on current state return ( <div> <StepIndicator current={state.context.currentStep} /> {state.matches('personalInfo') && ( <PersonalInfoStep onSubmit={(data) => send({ type: 'SUBMIT_PERSONAL', data })} errors={state.context.errors} /> )} {state.matches('address') && ( <AddressStep onSubmit={(data) => send({ type: 'SUBMIT_ADDRESS', data })} onBack={() => send({ type: 'BACK' })} /> )} {state.matches('payment') && ( <PaymentStep onSubmit={(data) => send({ type: 'SUBMIT_PAYMENT', data })} onBack={() => send({ type: 'BACK' })} /> )} {state.matches('processing') && <LoadingSpinner />} {state.matches('success') && <SuccessMessage />} {state.matches('paymentError') && ( <ErrorMessage onRetry={() => send({ type: 'RETRY' })} /> )} </div> );}
Pattern 2: Auth Flow — OAuth, MFA, and Session Management
Modern authentication flows involve numerous state transitions: OAuth redirects, multi-factor authentication (MFA), token refreshes, and more. Modeling these as a state machine helps prevent security gaps.
The key insight here is how MFA branching is expressed declaratively through guard conditions. With useState, you'd end up with scattered conditionals like isMfaRequired && isAuthenticated && !isLoading, which become a breeding ground for bugs.
Exponential backoff is expressed with after and dynamic delays. Compared to the usual setTimeout plus flag juggling, the answer to "how long do we wait on the fourth attempt" now lives in exactly one place. Fewer places to read while debugging is the practical win.
Pattern 4: Actor Model for Parallel Processing
The real power of XState v5 lies in the Actor Model. You can spawn child actors from a parent machine and run independent state management in parallel.
Pattern 5: Inspect API for Visualization and Debugging
XState v5's Inspect API is a powerful tool for monitoring state machine behavior in real time, even in production.
import { createActor } from 'xstate';import { createBrowserInspector } from '@statelyai/inspect';// Enable Inspector only in developmentconst inspector = process.env.NODE_ENV === 'development' ? createBrowserInspector() : undefined;const actor = createActor(authMachine, { inspect: inspector?.inspect,});// Custom Inspect handler for production telemetryconst productionInspector = (inspectionEvent: any) => { if (inspectionEvent.type === '@xstate.snapshot') { analytics.track('state_transition', { machineId: inspectionEvent.actorRef.id, state: inspectionEvent.snapshot.value, timestamp: Date.now(), }); } if (inspectionEvent.type === '@xstate.event') { if (inspectionEvent.event.type.includes('error')) { errorTracking.capture({ machineId: inspectionEvent.actorRef.id, event: inspectionEvent.event, }); } }};const productionActor = createActor(authMachine, { inspect: productionInspector,});
When debugging in Antigravity, you can integrate the Stately Inspector with Chrome DevTools to verify AI-generated state machine behavior in real time.
Testing Strategy for XState v5 with Antigravity
One of the greatest advantages of state machines is that they enable model-based testing. You can automatically generate test paths from the state transition graph.
For a deeper dive into test-driven development strategies, check out Antigravity × TDD Mastery. When you ask Antigravity's AI agent to "generate tests covering all transition paths for this state machine," it produces test code that covers every reachable state transition. You can also use the @xstate/test package's createTestModel to automatically enumerate test paths from the state graph.
When the Machine Definition Changes After You've Saved a Snapshot
Everything so far assumed the page stays open. In practice someone will eventually ask you to keep a half-filled form across a reload, or to let a checkout resume where it left off. XState v5 has actor.getPersistedSnapshot() for exactly this: serialize the whole actor, restore it later with createActor(machine, { snapshot }).
This is where I got caught. A persisted snapshot encodes the machine definition as it existed at the moment of saving. Rename a state, drop a step, and the restored actor boots pointing at a state that no longer exists.
The day after I renamed confirming to reviewing, a handful of reports came in: "the form is blank and nothing happens." The nasty part is that nothing throws. The actor sits quietly in a state that isn't there, so Sentry stays silent. Only users returning from the previous day hit it.
As an indie developer I'm also the support inbox, which means bugs that never raise an error have to be reconstructed from whatever the user wrote. It took me a full day to get from the words "blank screen" to suspecting localStorage.
The fix is unglamorous. Store a version alongside the snapshot and throw it away when it doesn't match.
import { createActor, type Actor } from 'xstate';import { checkoutMachine } from './checkoutMachine';// Bump this whenever the machine definition changes.// Forgetting to bump it is the only real operational risk here.const MACHINE_VERSION = 3;const STORAGE_KEY = 'checkout-machine';const MAX_AGE_MS = 1000 * 60 * 60 * 24; // expire after 24htype StoredSnapshot = { version: number; savedAt: number; snapshot: unknown;};// Only states that are safe to resume into.// Never include a state with an in-flight invoke (submitting, verifying, ...).const RESTORABLE = new Set(['personalInfo', 'addressInfo', 'confirming']);export function persist(actor: Actor<typeof checkoutMachine>) { const snapshot = actor.getSnapshot(); if (!RESTORABLE.has(String(snapshot.value))) { localStorage.removeItem(STORAGE_KEY); return; } const payload: StoredSnapshot = { version: MACHINE_VERSION, savedAt: Date.now(), snapshot: actor.getPersistedSnapshot(), }; localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));}export function restore(): unknown | undefined { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return undefined; let stored: StoredSnapshot; try { stored = JSON.parse(raw) as StoredSnapshot; } catch { localStorage.removeItem(STORAGE_KEY); return undefined; } const expired = Date.now() - stored.savedAt > MAX_AGE_MS; if (stored.version !== MACHINE_VERSION || expired) { // Don't try to migrate. Dropping it and starting clean causes fewer incidents. localStorage.removeItem(STORAGE_KEY); return undefined; } return stored.snapshot;}// On startupconst actor = createActor(checkoutMachine, { snapshot: restore() });actor.subscribe(() => persist(actor));actor.start();
The RESTORABLE set is the part of this implementation that earns its keep. getPersistedSnapshot() records that the actor was in a given state, but it does not preserve the promise that was running. Save submitting and restore it, and you get an actor parked in submitting with no request in flight — a spinner that never resolves.
It gets worse if you make that restored state retryable, because now a payment that actually succeeded can be charged a second time. "Don't persist while a request is in flight" isn't a UX preference here; it's how I keep double charges out of the system.
Here's how each kind of change behaves against an old snapshot.
Change to the machine
Restoring an old snapshot
What to do
Adding a state, existing names untouched
Usually fine
Works as-is; bumping the version is still safer
Renaming or removing a state
Hangs without throwing
Bump the version and discard
Adding a context field
Restores as undefined
Backfill a default after restore, or bump the version
Changing a guard condition
Restores fine, behaves differently
Bump the version — this is the hardest one to catch
Persisting a state with an active invoke
Stalls in that state
Exclude it from persistence
Row four is the one that hurts in practice. The restore succeeds, the screen renders, the buttons respond — only the branch taken is different from before. Tests rarely catch it, so I turned it into a mechanical rule instead: touch the machine file, bump the version. Rules that require no judgment are the ones that actually get followed.
If you work in Antigravity, one line in .antigravity/rules.md covers this: "when editing checkoutMachine.ts, also update MACHINE_VERSION." Any diff that touches the machine definition then comes back with the bump suggested. This is precisely the kind of rule humans forget, so I'm comfortable delegating it.
Where My Own Judgment Shifted
Right after adopting XState I turned nearly every screen into a machine. Modals that only open and close ended up with a createMachine of their own, and all I gained was line count.
The bar I hold myself to now is a single question: are there three or more states, and does the order of transitions carry meaning? If not, useState reads better. A modal's open/closed has no meaningful ordering. A multi-step form does. Since drawing that line the number of machines went down and the value of the remaining ones went up.
I also changed how I subscribe. useMachine is the obvious choice, but any change anywhere in context re-renders the consumer. In a form that writes to context on every keystroke, that shows up as perceived lag.
// Straightforward, but re-renders on any context changeconst [state, send] = useMachine(checkoutMachine);// Re-renders only when the value you actually read changesconst actorRef = CheckoutContext.useActorRef();const step = useSelector(actorRef, (s) => s.value);const canSubmit = useSelector(actorRef, (s) => s.can({ type: 'SUBMIT_PERSONAL' }));
I've grown fond of s.can() because it keeps button-enablement logic out of the component. Whether the user may proceed is already written once, in the guard. The UI just asks. Keeping that decision in one place pays off later, when the condition inevitably grows a third clause.
My sense of what to delegate to an AI agent shifted too. Sketching a transition diagram, or writing the tests for a state you just added — those come back fast and usually usable. Guard bodies are a different story. The rule for when someone is allowed to advance lives in my head and nowhere else, so I end up rewriting those by hand every time. In hindsight that's the expected outcome.
The Inspect API needed tuning as well. Sending every event was pleasant in development; in production it turned into a dozen-plus events per user action, and the analytics bill started to stand out more than the insight did. Now I ship only the failure-side @xstate.event entries and sample the happy path at one percent. Full visibility turned out to be unnecessary — the side that fails is the side you need.
State machines are a tool that pays you back three months later, on the day the requirements change, rather than on the day you write them. Instead of counting boolean combinations and hoping, the question becomes whether to draw one more arrow.
Summary
The first move isn't writing a new machine. Pick one screen you already have that carries three or more booleans whose combinations you can't explain out loud, and write its state names on paper. That exercise does more than learning the API does.
Auth flows and multi-step forms are the best places to start porting, because the boundaries between states are business-visible and someone can confirm the correct transitions. Starting with something fuzzy, like a dashboard, means the machine definition itself keeps moving and you end up redoing the work.
Once you've ported something, settle the MACHINE_VERSION convention before you need it. Snapshot persistence is always a later addition, and by then there are already stale states sitting in production browsers. Carrying the same idea to the server raises a different set of questions — idempotency and retry triage — which I wrote up separately in Trusting Temporal Workflows in Production.
Thank you for staying with a long one. I hope counting states gets a little easier from here.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.