Skip to content

State-machine form workflows

12-step form. Conditional validation. Back button has to work.

Modeling a public investor-intake workflow with XState, eleven validation guards, preserved form context, and browser-level tests.

6 min readBy

The transitions were part of the product

A step counter was sufficient only at the beginning. By step five, the flow tracked position, accumulated data, validation errors, loading, completed steps, and conditional requirements. By step ten, one component had reached roughly 800 lines and the question 'can this user continue?' was answered in six different places.

Rendering twelve screens was straightforward. Keeping forward, backward, asynchronous, and submission transitions consistent without losing earlier input was not.

Organized state can still allow impossible states

useState allowed the form to combine values that should not coexist: a later screen after prerequisite data was cleared, a backward transition during submission, or a valid button while a visible conditional field was empty. useReducer could centralize updates, but it would still accept any action the reducer implemented from any current step unless every branch recreated a transition graph.

XState v5 with @xstate/react made that graph explicit. An event can still be sent, but an undefined or guard-rejected transition does not move the machine.

Make the workflow contract explicit

The machine used seven events: NEXT, PREVIOUS, GO_TO_STEP, UPDATE_FORM_DATA, SET_CREATED_BUYER_ID, SUBMIT, and RESET. Context held currentStep, formData, errors, isLoading, investorId, and createdBuyerId.

CONTACT_PREFERENCES: {
  on: {
    NEXT: {
      target: 'TARGET_MARKETS',
      guard: 'CAN_PROGRESS_FROM_CONTACT_PREFERENCES'
    },
    PREVIOUS: 'INTRO',
    UPDATE_FORM_DATA: { actions: 'UPDATE_FORM_DATA' }
  }
}

NEXT was guarded. PREVIOUS stayed available outside submission so a user could correct earlier data without resetting form context. UPDATE_FORM_DATA merged only the changed fields into accumulated context.

Put validation at the transition boundary

Eleven guards followed the CAN_PROGRESS_FROM_[STEP_NAME] convention. A Zod schema required at least a phone or email and validated a US phone only when present. Target markets, property types, and strategies each required a selection; range screens required at least a minimum or maximum.

const canGoNext = state.can({ type: "NEXT" })

CAN_PROGRESS_FROM_CONTACT_CARD: ({ context }) =>
  Boolean(context.createdBuyerId)

The Contact Card guard originally returned true unconditionally. A later fix tied it to createdBuyerId so the machine could not leave that state until backend buyer creation succeeded. The same state.can({ type: "NEXT" }) contract drove the button state and the transition itself.

Keep asynchronous verification from racing the form

The form also integrated generated APIs for contact verification and buyer creation. Phone and email checks were debounced, and a monotonically increasing request ID discarded a stale response when a newer value had already been entered. Target-market recommendations used coordinate-based deduplication before entering machine context.

Buyer creation happened at the Contact Card boundary. SET_CREATED_BUYER_ID stored the confirmed backend ID in machine context; later preference screens used that ID for their API updates.

Keep React components unaware of the route graph

A provider wrapped useMachine and exposed currentStep, formData, canGoNext, isSubmitting, handleNext, handlePrevious, handleUpdateFormData, and the buyer-ID action. Step components rendered their fields and sent updates without knowing which route came before or after them.

The machine owns navigation. Each React step renders fields, updates context, and sends events. I would not use XState for a three-step form; twelve screens, conditional validation, and backend-dependent transitions made the explicit graph worth the extra configuration here.

Test the rules apart from the screens

The implementation added 538 lines of unit coverage for schemas and direct transitions. Those tests sent events into the machine and asserted state without mounting React components. Another 359 lines of Playwright coverage exercised the full flow, phone and email rules, backend buyer creation, and backward data preservation.

  • 12 steps
  • seven events
  • 11 named forward guards
  • one machine file controlling navigation
  • schema and machine tests separated from browser interaction tests