跳到主要内容

状态机表单工作流

12 步表单。条件验证。返回按钮必须可用。

使用 XState 为公开的 Investor Intake 工作流建模,包含十一个验证守卫、保留的表单上下文和浏览器级测试。

阅读时间:6分钟作者

状态转换本身就是产品的一部分

步骤计数器只在最初足够使用。到第五步,流程需要跟踪位置、累积数据、验证错误、加载状态、已完成步骤和条件要求。到第十步,一个组件已增长到约 800 行,而“该用户能否继续?”这个问题分散在六个位置回答。

渲染十二个界面并不难;难的是让前进、后退、异步和提交转换保持一致,同时不丢失先前输入。

组织好状态仍可能允许不可能状态

useState 允许表单组合本不该共存的值,例如清除前置数据后仍显示后续界面、提交期间执行后退转换,或可见条件字段为空时按钮仍显示有效。useReducer 可以集中更新,但除非每个分支都重建转换图,否则它仍会在任何当前步骤接受 reducer 已实现的任意操作。

XState v5 与 @xstate/react 让该图变得明确。事件仍可发送,但未定义或被守卫拒绝的转换不会移动状态机。

明确工作流约定

状态机使用七个事件:NEXT、PREVIOUS、GO_TO_STEP、UPDATE_FORM_DATA、SET_CREATED_BUYER_ID、SUBMIT 和 RESET。上下文保存 currentStep、formData、errors、isLoading、investorId 和 createdBuyerId。

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

NEXT 受守卫控制。提交之外始终可以使用 PREVIOUS,让用户无需重置表单上下文即可修正先前数据。UPDATE_FORM_DATA 只把已更改字段合并进累积上下文。

在转换边界执行验证

十一个守卫遵循 CAN_PROGRESS_FROM_[STEP_NAME] 约定。Zod 模式要求至少提供电话号码或电子邮件,并且只在提供美国电话号码时验证它。目标市场、房产类型和策略都至少需要选择一项;范围界面则至少需要最小值或最大值。

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

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

Contact Card 守卫最初始终返回 true。后续修复把它与 createdBuyerId 绑定,使后端买家创建成功前状态机无法离开该状态。同一个 state.can({ type: "NEXT" }) 约定同时驱动按钮状态和转换本身。

防止异步验证与表单发生竞态

该表单还集成了用于联系人验证和买家创建的代码生成 API。电话号码与电子邮件检查使用防抖;如果用户已经输入新值,单调递增的请求 ID 会丢弃旧响应。目标市场建议在进入状态机上下文前按坐标去重。

买家创建发生在 Contact Card 边界。SET_CREATED_BUYER_ID 把后端确认的 ID 保存到状态机上下文,后续偏好设置界面使用该 ID 更新 API。

不要让 React 组件感知路由图

一个提供程序包装 useMachine,并暴露 currentStep、formData、canGoNext、isSubmitting、handleNext、handlePrevious、handleUpdateFormData 和买家 ID 操作。各步骤组件只渲染字段并发送更新,无需知道前后是哪条路由。

状态机负责导航。每个 React 步骤负责渲染字段、更新上下文并发送事件。我不会为三步表单使用 XState;但这里有十二个界面、条件验证和依赖后端的转换,显式图值得额外配置。

将规则与界面分开测试

该实现为模式定义和直接转换增加了 538 行单元测试覆盖。这些测试把事件发送到状态机并断言状态,无需挂载 React 组件。另外 359 行 Playwright 测试覆盖完整流程、电话号码与电子邮件规则、后端买家创建和后退时的数据保留。

  • 12 个步骤
  • 七个事件
  • 11 个命名的前进守卫
  • 一个控制导航的状态机文件
  • 模式与状态机测试和浏览器交互测试相互分离