状态机是显式定义合法状态、转移事件及副作用的模型,适用于有明确阶段和强约束的交互流程,如表单提交、设备配网等,而非替代所有useState/useReducer场景。

状态机不是状态管理库,别直接往 useState 里塞状态图
状态机(State Machine)本质是显式定义「哪些状态合法」「什么事件能触发什么转移」「转移时必须执行什么副作用」。它和 useState 或 useReducer 的核心区别在于:后者只管“当前值+更新动作”,前者强制你提前画出所有可能路径。很多团队一上来就试图用 xstate 替换 useReducer,结果把状态图写成网状,反而更难维护。
真正该做的,是先识别出页面中存在明确阶段划分、且阶段之间有强约束的交互模块——比如表单提交流程(草稿 → 校验中 → 成功/失败)、设备配网向导(扫描 → 连接 → 配置 → 完成)、或权限驱动的 UI 切换(未登录 → 登录中 → 权限加载 → 主界面/拒绝页)。这些才是状态机的适用场景。
- 一个页面里混用状态机和普通状态是常态,不用强求“全盘状态机化”
- 状态节点名必须语义清晰,避免
step1、loading2这类命名;推荐用idle、scanning、connected、configuring - 每个转移(transition)必须绑定明确事件名,如
SCAN_SUCCESS、TIMEOUT,而不是靠条件判断隐式跳转
xstate 的 interpret 必须配 devTools,否则调试等于盲开
状态机一旦嵌套层级变深(比如主流程含子流程,子流程又含异步分支),光看控制台 console.log(state) 几乎无法定位问题。xstate 自带的 devTools 插件能实时显示当前状态、可触发事件、历史转移路径,还能回放——这是它比手写 switch 状态块的关键优势。
初始化服务时漏掉 devTools 是高频失误:
import { interpret } from 'xstate';
import { machine } from './wifiSetupMachine';
// ❌ 没开调试,出问题只能靠猜
const service = interpret(machine);
// ✅ 必须加,开发环境默认启用
const service = interpret(machine).withDevTools();
- 生产环境会自动禁用
devTools,无需手动判断process.env.NODE_ENV - 如果用了
useMachineHook,记得在根组件包裹XStateProvider(v5+)或确保全局registerService(v4) - 事件发送必须用
service.send({ type: 'SUBMIT' }),不要直接改service.state.value—— 否则 devTools 不记录,状态也不一致
异步操作不能写在 entry/exit 里,得用 invoke + onDone
初学者常把 API 调用塞进 entry,以为“进这个状态就发请求”。但 entry 是同步执行的,没法 await,强行 async entry 会导致状态机卡住或行为不可预测。
正确做法是用 invoke 声明异步过程,并通过 onDone 和 onError 显式处理成功与失败分支:
scanning: {
invoke: {
src: 'scanDevices',
onDone: {
target: 'connected',
actions: assign({ devices: (_, event) => event.data })
},
onError: {
target: 'scanFailed',
actions: assign({ error: (_, event) => event.data.message })
}
}
}
-
src对应的函数必须返回 Promise,且不能自行catch错误——错误要交给状态机统一收口 - 避免在
onDone里再发新请求;如果后续还有依赖步骤,应该转移到下一个状态里用新的invoke - 超时控制建议在
src函数内实现(如AbortController),不要靠状态机定时器模拟
状态机和 React 组件边界要划清:状态机不负责渲染,只负责回答“我能做什么”
状态机输出的是 state.matches('configuring') 这样的布尔判断,不是 JSX。常见错误是让状态机直接返回 buttonText 或 isLoading 这类视图属性——这会让状态逻辑和 UI 表现耦合,违背单一职责。
正确的分层是:
- 状态机定义原子状态(
idle、submitting、success)和事件(SUBMIT、CANCEL) - 组件根据
state.matches()组合渲染逻辑,例如:{state.matches('submitting') &&} - 按钮的
disabled状态由!state.can({ type: 'SUBMIT' })决定,而不是硬编码条件
真正难的不是写状态机,而是当产品提“点击提交后,如果网络超时就弹 toast,但如果用户已离线就跳错误页,同时按钮要变成‘重试’”时,你能迅速在状态图里补上 TIMEOUT 和 OFFLINE 两个事件分支,并确认它们不会破坏原有转移路径——这种可推演性,才是状态机的价值所在。










