别再用一堆布尔值拼凑状态了,XState 在 React 里的实战踩坑与调试

曾经在一个用户注册流程里,我见过一个组件内部塞了 11 个布尔值——isLoadingisErrorisSuccessisEmailSentisEmailVerifiedisTermsAccepted……当第 7 个 bug 因为状态组合不一致冒出来的时候,我把整个组件删了,换成了 XState。

这不是一个「要不要用状态机」的哲学讨论。当你发现自己在用 useEffect 互相监听状态变化、写一堆 if (A && !B && C) 来推导 UI 该显示什么时,你已经在手动实现一个蹩脚的状态机了。XState 的意义在于,它让你把隐式的、散落在各处的状态转换逻辑,变成显式的、可枚举的、可验证的声明。

什么情况下才值得引入 XState

不是所有状态都值得用状态机。一个表单的 input 值、一个弹窗的开闭,用 useState 完全够用。XState 适用的场景有一个明确的信号:状态的合法组合是有限的,并且从一个状态到另一个状态的转换有明确的规则

具体来说,满足以下两个以上条件就该考虑:

  1. 存在多个互斥的状态,比如一个数据请求不可能同时处于 loadingsuccess
  2. 状态转换有前置条件,比如必须先验证邮箱才能进入下一步
  3. 不同状态下,同样的操作应该有不同行为,比如「点击提交」在 idlesubmitting 状态下做的事情完全不同
  4. 你需要回答「这个状态组合真的可能发生吗」这类问题

我最近的一个真实案例是支付流程:用户选择支付方式、输入金额、确认、等待回调、处理失败重试。用布尔值实现时,出现了用户连点两次按钮导致重复扣款的线上事故。用 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 中,而不是定义 networkErrorvalidationErrortimeoutError 三个状态。状态应该代表行为模式的不同,而不是数据的枚举。

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_CODEstate.can 直接读取这个信息。这意味着按钮的 disabled 属性永远和状态机的规则一致,不会出现「按钮亮了但点了没反应」或者「按钮灰了但其实可以点」的情况。

一个容易被忽略的性能问题:useActor 返回的 state 对象每次状态变化时引用都会变。如果你把整个 state 传给 useEffect 的依赖数组,可能导致不必要的重新执行。只依赖你真正关心的部分:

useEffect(() => {
  if (state.matches('success')) {
    router.push('/dashboard');
  }
}, [state.value]); // 用 state.value 而不是整个 state

真实项目中的副作用管理

XState 的 actions 分三种:entryexittransition。大部分副作用应该放在 entryexit 里,因为副作用通常和「进入某个状态」或「离开某个状态」绑定,而不是和「某个事件发生」绑定。

比如发送验证码后启动一个 60 秒倒计时,应该放在 sent 状态的 entry 里,而不是 SEND_CODE 事件的 transition action 里。因为将来你可能增加一个「重新发送」的路径,也从 sentsending,再回到 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(子状态:cashcardtransfer)、processingresult(子状态:successfailure)。每个子状态机可以独立定义、独立测试。XState 支持 invoke 一个子状态机作为 actor,也支持直接在父状态机里用 states 嵌套定义。如果文件超过 300 行,就该考虑拆了。

在服务端渲染(SSR)的 Next.js 项目里用 XState 有什么坑?

状态机的 createActoractor.start() 是命令式的,如果放在组件内部会在每次渲染时执行,导致 SSR 和客户端状态不一致。解决办法是把 actor 创建放在一个稳定的引用里(比如 useRef 或模块作用域),并且确保初始状态在服务端和客户端一致。另外,XState Inspector 只在浏览器环境可用,需要加 typeof window !== 'undefined' 的判断。