别再用一堆布尔值拼凑状态了,XState 在 React 里的实战踩坑与调试
曾经在一个用户注册流程里,我见过一个组件内部塞了 11 个布尔值——isLoading、isError、isSuccess、isEmailSent、isEmailVerified、isTermsAccepted……当第 7 个 bug 因为状态组合不一致冒出来的时候,我把整个组件删了,换成了 XState。
这不是一个「要不要用状态机」的哲学讨论。当你发现自己在用 useEffect 互相监听状态变化、写一堆 if (A && !B && C) 来推导 UI 该显示什么时,你已经在手动实现一个蹩脚的状态机了。XState 的意义在于,它让你把隐式的、散落在各处的状态转换逻辑,变成显式的、可枚举的、可验证的声明。
什么情况下才值得引入 XState
不是所有状态都值得用状态机。一个表单的 input 值、一个弹窗的开闭,用 useState 完全够用。XState 适用的场景有一个明确的信号:状态的合法组合是有限的,并且从一个状态到另一个状态的转换有明确的规则。
具体来说,满足以下两个以上条件就该考虑:
- 存在多个互斥的状态,比如一个数据请求不可能同时处于
loading和success - 状态转换有前置条件,比如必须先验证邮箱才能进入下一步
- 不同状态下,同样的操作应该有不同行为,比如「点击提交」在
idle和submitting状态下做的事情完全不同 - 你需要回答「这个状态组合真的可能发生吗」这类问题
我最近的一个真实案例是支付流程:用户选择支付方式、输入金额、确认、等待回调、处理失败重试。用布尔值实现时,出现了用户连点两次按钮导致重复扣款的线上事故。用 XState 重构后,submitting 状态下根本不响应点击——不是通过 disabled 属性防的,而是状态机里根本没有那条转换路径。
把业务逻辑装进状态机的正确姿势
开始写代码之前,最重要的一步是在纸上画出状态图。这一步跳过去直接写配置,后面一定会反复改。
拿一个「手机号验证」流程举例。状态有这么几个:
idle:初始状态,输入框可用sending:正在发送验证码sent:验证码已发送,等待用户输入verifying:正在验证success:验证成功error:发送或验证失败,可重试
关键不在于列出状态,而在于明确每个状态下能发生什么事件、事件会把状态带到哪里。sending 状态下再触发 SEND 事件应该被忽略,而不是再发一次请求。这就是状态机比布尔值强的地方——不需要到处写 if (isSending) return。
XState v5 的 API 比 v4 简洁了不少,创建状态机的推荐方式是用 setup:
import { setup, assign } from 'xstate';
const phoneVerificationMachine = setup({
types: {
context: {} as {
phone: string;
code: string;
errorMessage: string;
retryCount: number;
},
events: {} as
| { type: 'INPUT_PHONE'; phone: string }
| { type: 'SEND_CODE' }
| { type: 'INPUT_CODE'; code: string }
| { type: 'VERIFY' }
| { type: 'RETRY' },
},
guards: {
retryLimitNotExceeded: ({ context }) => context.retryCount < 3,
},
}).createMachine({
id: 'phoneVerification',
initial: 'idle',
context: {
phone: '',
code: '',
errorMessage: '',
retryCount: 0,
},
states: {
idle: {
on: {
INPUT_PHONE: {
actions: assign({ phone: ({ event }) => event.phone }),
},
SEND_CODE: 'sending',
},
},
sending: {
invoke: {
src: 'sendVerificationCode',
onDone: { target: 'sent', actions: assign({ retryCount: 0 }) },
onError: {
target: 'error',
actions: assign({
errorMessage: ({ event }) => event.error.message,
retryCount: ({ context }) => context.retryCount + 1,
}),
},
},
},
sent: {
on: {
INPUT_CODE: {
actions: assign({ code: ({ event }) => event.code }),
},
VERIFY: 'verifying',
SEND_CODE: 'sending',
},
},
verifying: {
invoke: {
src: 'verifyCode',
onDone: 'success',
onError: 'error',
},
},
success: { type: 'final' },
error: {
on: {
RETRY: [
{ guard: 'retryLimitNotExceeded', target: 'sending' },
{ target: 'idle' },
],
INPUT_PHONE: {
actions: assign({ phone: ({ event }) => event.phone }),
target: 'idle',
},
},
},
},
});
几个容易踩坑的点:
不要把业务数据放到状态名里。error 状态下具体的错误信息应该放在 context 中,而不是定义 networkError、validationError、timeoutError 三个状态。状态应该代表行为模式的不同,而不是数据的枚举。
invoke 的 actor 需要处理竞态。如果用户在 sending 状态时切换到别的页面,invoke 的 Promise 返回后尝试更新一个已经不存在的状态,XState 会静默丢弃这个事件,但如果你在 Promise 里有副作用(比如弹 toast),它照样会执行。解决办法是把副作用放在状态机的 actions 里,而不是 invoke 的 service 函数里。
guard 函数要纯。retryLimitNotExceeded 只依赖 context,不读外部变量。如果你的 guard 需要依赖当前时间、localStorage 里的值,应该先通过事件把这些数据传入 context,再让 guard 去读 context。
在 React 组件里接入 XState
@xstate/react 提供了 useMachine hook(v4)和 useActor hook(v5)。v5 里创建 actor 的方式变了:
import { useActor, createActor } from 'xstate';
import { phoneVerificationMachine } from './machine';
// 在组件外部创建 actor,避免每次渲染重新创建
const actor = createActor(phoneVerificationMachine, {
input: {
/* 可选的初始 context 覆盖 */
},
});
actor.start();
function PhoneVerification() {
const [state, send] = useActor(actor);
return (
<div>
{state.matches('idle') && (
<input
value={state.context.phone}
onChange={(e) => send({ type: 'INPUT_PHONE', phone: e.target.value })}
placeholder="请输入手机号"
/>
)}
{state.matches('sending') && <p>正在发送验证码...</p>}
{state.matches('sent') && (
<input
value={state.context.code}
onChange={(e) => send({ type: 'INPUT_CODE', code: e.target.value })}
placeholder="请输入验证码"
/>
)}
{state.matches('error') && (
<p style={{ color: 'red' }}>{state.context.errorMessage}</p>
)}
<button
onClick={() => send({ type: 'SEND_CODE' })}
disabled={!state.can({ type: 'SEND_CODE' })}
>
发送验证码
</button>
</div>
);
}
state.can(event) 这个 API 是 XState 被严重低估的功能。它直接告诉你某个事件在当前状态下是否合法,你不用手写 disabled 的判断逻辑——状态机已经声明了哪些状态能响应 SEND_CODE,state.can 直接读取这个信息。这意味着按钮的 disabled 属性永远和状态机的规则一致,不会出现「按钮亮了但点了没反应」或者「按钮灰了但其实可以点」的情况。
一个容易被忽略的性能问题:useActor 返回的 state 对象每次状态变化时引用都会变。如果你把整个 state 传给 useEffect 的依赖数组,可能导致不必要的重新执行。只依赖你真正关心的部分:
useEffect(() => {
if (state.matches('success')) {
router.push('/dashboard');
}
}, [state.value]); // 用 state.value 而不是整个 state
真实项目中的副作用管理
XState 的 actions 分三种:entry、exit 和 transition。大部分副作用应该放在 entry 和 exit 里,因为副作用通常和「进入某个状态」或「离开某个状态」绑定,而不是和「某个事件发生」绑定。
比如发送验证码后启动一个 60 秒倒计时,应该放在 sent 状态的 entry 里,而不是 SEND_CODE 事件的 transition action 里。因为将来你可能增加一个「重新发送」的路径,也从 sent 到 sending,再回到 sent——倒计时逻辑只需要在 entry 里写一次。
sent: {
entry: assign({ countdown: 60 }),
// ...
}
但 entry action 里不适合做异步操作。XState 的 action 是同步执行的,如果你需要在进入某个状态时发起一个异步请求,应该用 invoke,或者把副作用放在 React 组件里通过 useEffect 监听 state.matches。
我在实际项目中的做法是:与后端交互的逻辑全部放在状态机的 invoke 里,UI 副作用(弹窗、跳转、上报)放在 React 组件里监听状态变化来处理。这样状态机本身是平台无关的,可以在测试环境里脱离 React 独立运行。
调试:状态机出问题怎么快速定位
XState 的调试体验远远好于散落的布尔值,前提是你用对了工具。
第一步,启用 XState Inspector。在创建状态机时加上 inspect:
import { inspect } from '@xstate/inspect';
if (process.env.NODE_ENV === 'development') {
inspect({ iframe: false });
}
const actor = createActor(machine);
actor.start();
然后在浏览器里打开 https://stately.ai/inspect,你能看到实时的状态图、当前状态高亮、所有已触发的事件序列。线上 bug 排查时,看一眼事件序列就能复现用户的操作路径——这比翻日志快得多。
第二步,给事件加上足够的信息。事件里携带的数据会在 Inspector 里展示。如果你发现状态机卡在了某个意想不到的状态,回溯事件序列,看哪个事件携带的数据不符合预期。
第三步,写单元测试时直接测试状态机,不涉及 React。XState 的状态机是纯 JavaScript 对象,测试起来不需要渲染组件:
import { createActor } from 'xstate';
import { phoneVerificationMachine } from './machine';
test('重复发送验证码时不会触发两次请求', () => {
const actor = createActor(phoneVerificationMachine);
actor.start();
actor.send({ type: 'SEND_CODE' });
expect(actor.getSnapshot().matches('sending')).toBe(true);
// 在 sending 状态下再次发送,应该被忽略
actor.send({ type: 'SEND_CODE' });
expect(actor.getSnapshot().matches('sending')).toBe(true);
});
你可以在完全不启动浏览器的情况下验证所有状态转换逻辑。状态机的测试覆盖率做到 80% 以上是很容易的,因为路径是有限的、可枚举的。
第四步,利用 state.can 做防御性编程。在关键操作前加断言:
if (!state.can({ type: 'SUBMIT_PAYMENT' })) {
reportError(new Error('非法操作:当前状态下不允许提交支付'));
return;
}
send({ type: 'SUBMIT_PAYMENT' });
这在生产环境里救过我一次。一个第三方 SDK 的回调在错误的时间触发了事件,state.can 直接拦了下来并上报了异常,而不是让状态机进入一个未定义的状态。
常见问题
XState 和 useReducer 有什么区别,什么时候该用哪个?
useReducer 本质上还是一个「状态 + dispatch」的模式,reducer 函数里你可以写任意逻辑,包括不合法的状态转换。XState 强制你声明所有合法状态和转换路径,它会在运行时拒绝非法事件。如果你的 reducer 里开始出现大量对当前状态的 if/switch 判断来决定如何处理 action,那就是该换 XState 的信号。
XState v5 和 v4 的主要区别是什么,老项目要不要升级?
v5 最大的变化是用 setup 函数创建状态机,类型推断比 v4 强很多,不再需要手动写一堆泛型。另外 v5 废弃了 useMachine,统一用 createActor + useActor。如果你的 v4 项目运行稳定,不升级也没问题;但新项目建议直接上 v5,类型安全性的提升值得花半天学习新 API。
状态机配置越来越复杂怎么办?
用分层状态机(nested states)拆分。比如支付流程可以拆成 paymentMethod(子状态:cash、card、transfer)、processing、result(子状态:success、failure)。每个子状态机可以独立定义、独立测试。XState 支持 invoke 一个子状态机作为 actor,也支持直接在父状态机里用 states 嵌套定义。如果文件超过 300 行,就该考虑拆了。
在服务端渲染(SSR)的 Next.js 项目里用 XState 有什么坑?
状态机的 createActor 和 actor.start() 是命令式的,如果放在组件内部会在每次渲染时执行,导致 SSR 和客户端状态不一致。解决办法是把 actor 创建放在一个稳定的引用里(比如 useRef 或模块作用域),并且确保初始状态在服务端和客户端一致。另外,XState Inspector 只在浏览器环境可用,需要加 typeof window !== 'undefined' 的判断。