状态机表单工作流
12 步表单。条件验证。返回按钮必须可用。
使用 XState 为公开的 Investor Intake 工作流建模,包含十一个验证守卫、保留的表单上下文和浏览器级测试。
阅读时间:6分钟作者 Abdullah Raheel
状态转换本身就是产品的一部分
步骤计数器只在最初足够使用。到第五步,流程需要跟踪位置、累积数据、验证错误、加载状态、已完成步骤和条件要求。到第十步,一个组件已增长到约 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 个命名的前进守卫
- 一个控制导航的状态机文件
- 模式与状态机测试和浏览器交互测试相互分离

