跳到主内容

React 19 Actions 实战:干掉九成手写 loading ​

问题背景:表单提交为什么总在重复写 loading ​

在 React 18 及更早版本里,一个普通的“点赞、收藏、修改昵称、提交评论”流程,往往要同时维护三套状态:请求中的 pending、请求失败后的 error、成功后的 data。如果再追求更好的交互体验,还要先改界面、失败后回滚,于是组件里会出现大量 try/catch/finally、setSubmitting(true)、按钮禁用与错误提示逻辑。

这类代码的问题不是难,而是太容易分散:请求函数处理业务,组件处理 loading,表单又处理禁用态;一旦多个入口触发同一个异步操作,状态很容易不同步。React 19 将这类异步场景抽象为 Actions,并配合 useActionState、useFormStatus 与 useOptimistic,把“提交中、成功、失败、乐观更新”收敛成一套内建模型。

下面先看典型状态流,再给出可直接运行的实现。

用户操作点击或提交Action 执行自动 pending成功:写入结果提交队列归并失败:展示错误乐观值自动回滚UI 保持响应无需手写布尔值

方案对比:useState 手写状态 vs React 19 Actions ​

传统方案通常长这样:

javascript
import { useState } from "react";

export default function OldLikeButton({ initialLiked, initialCount, onLike }) {
  const [liked, setLiked] = useState(initialLiked);
  const [count, setCount] = useState(initialCount);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");

  async function handleClick() {
    const nextLiked = !liked;
    setLoading(true);
    setError("");
    setLiked(nextLiked);
    setCount((value) => value + (nextLiked ? 1 : -1));

    try {
      await onLike(nextLiked);
    } catch (err) {
      // 失败后必须手动恢复 UI
      setLiked(!nextLiked);
      setCount((value) => value + (nextLiked ? -1 : 1));
      setError("操作失败,请稍后重试");
    } finally {
      setLoading(false);
    }
  }

  return (
    <>
      <button disabled={loading} onClick={handleClick}>
        {loading ? "提交中..." : liked ? "已点赞" : "点赞"} {count}
      </button>
      {error && <p role="alert">{error}</p>}
    </>
  );
}

这段代码能工作,但它把“视觉状态”和“服务端真实状态”混在一起。快速点击、并发请求、组件卸载后继续 setState、失败回滚顺序不一致,都会制造边界 bug。

React 19 Actions 的核心收益有三点:

  1. 异步动作即状态:传给 <form action={fn}> 或通过 useActionState 返回的 dispatch 执行,React 自动跟踪 pending。
  2. 表单状态可穿透组件:按钮组件不必层层接收 loading,使用 useFormStatus 即可读取最近一次表单提交状态。
  3. 乐观值有独立通道:useOptimistic 维护临时值,Action 完成或抛出后由 React 统一处理提交与回滚,减少手写镜像状态。

实操步骤:用 useActionState 实现评论提交 ​

下面示例不依赖服务端框架,在任意 React 19 项目中都可运行。它模拟一个评论框:点击提交后立即把评论插入列表,请求失败时显示错误并回滚。

第一步:定义 Action 与初始状态 ​

tsx
import { useActionState, useOptimistic, useRef } from "react";

type Comment = {
  id: number;
  name: string;
  content: string;
  pending?: boolean;
};

type FormState = {
  comments: Comment[];
  error: string;
};

const initialState: FormState = {
  comments: [
    { id: 1, name: "小林", content: "Actions 把提交态收敛了。" },
  ],
  error: "",
};

// 模拟接口:内容包含“失败”时 reject,便于测试回滚
function publishComment(content: string) {
  return new Promise<Comment>((resolve, reject) => {
    setTimeout(() => {
      if (content.includes("失败")) {
        reject(new Error("网络异常"));
        return;
      }
      resolve({
        id: Date.now(),
        name: "我",
        content,
      });
    }, 800);
  });
}

Action 的第一个参数是当前状态,第二个参数是表单数据;返回值会成为下一次状态。这个约定让状态流转更像 reducer,而不是散落在事件处理函数里的多次 setState。

第二步:在 Action 中写入乐观更新 ​

tsx
async function commentAction(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  const content = String(formData.get("content") || "").trim();

  if (!content) {
    return {
      ...prevState,
      error: "评论内容不能为空",
    };
  }

  // 这个临时评论会在异步请求期间显示
  const optimisticComment: Comment = {
    id: -Date.now(),
    name: "我",
    content,
    pending: true,
  };

  // addOptimistic 只触发过渡期间的临时渲染
  addOptimistic(optimisticComment);

  try {
    const saved = await publishComment(content);
    return {
      error: "",
      comments: [...prevState.comments, saved],
    };
  } catch {
    return {
      ...prevState,
      error: "评论发布失败,请重试",
    };
  }
}

注意,addOptimistic 不直接修改最终列表;它只是告诉 React:在当前 Transition 提交期间,界面希望先呈现什么。Action 成功后返回服务端确认的数据,失败后返回旧状态,临时评论自然消失。

第三步:组装表单与提交按钮 ​

tsx
function SubmitButton() {
  // 必须渲染在 <form> 内部,读取最近表单的 pending
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? "发布中..." : "发布评论"}
    </button>
  );
}

export default function CommentBox() {
  const formRef = useRef<HTMLFormElement>(null);
  const [state, formAction, pending] = useActionState(commentAction, initialState);

  const [optimisticComments, addOptimistic] = useOptimistic<Comment[], Comment>(
    state.comments,
    (currentComments, newComment) => [newComment, ...currentComments]
  );

  return (
    <section style={{ padding: 16 }}>
      <form ref={formRef} action={formAction}>
        <textarea name="content" rows={3} placeholder="输入评论,包含“失败”可测试回滚" />
        <SubmitButton />
      </form>

      {state.error && <p role="alert" style={{ color: "#dc2626" }}>{state.error}</p>}

      <ul>
        {optimisticComments.map((comment) => (
          <li key={comment.id} style={{ opacity: comment.pending ? 0.55 : 1 }}>
            <strong>{comment.name}:</strong>
            {comment.content}
            {comment.pending && <span>(发送中)</span>}
          </li>
        ))}
      </ul>
    </section>
  );
}

这里最值得关注的是:父组件没有定义 submitting,按钮也没有接收 loading props。useActionState 负责 Action 生命周期,useFormStatus 负责深层按钮读取表单状态,useOptimistic 负责临时列表。三者组合后,手写布尔 loading 的场景会明显减少。

非表单场景:普通按钮也能使用 Actions ​

Actions 并不只属于 <form>。普通点击事件可以把异步函数包在 Transition 中执行:

tsx
import { useTransition, useOptimistic } from "react";

export default function FollowButton({ following, onToggle }) {
  const [isPending, startTransition] = useTransition();
  const [optimisticFollowing, setOptimistic] = useOptimistic(
    following,
    (_state, next: boolean) => next
  );

  function handleClick() {
    startTransition(async () => {
      setOptimistic(!following);
      await onToggle(!following);
    });
  }

  return (
    <button onClick={handleClick} disabled={isPending}>
      {isPending ? "处理中" : optimisticFollowing ? "已关注" : "关注"}
    </button>
  );
}

在支持 Server Components 或 Server Actions 的框架中,action 还可以直接引用服务端函数,实现渐进增强:即使用户禁用 JavaScript,表单仍可提交。但在纯客户端项目里,Action 本质上仍然是一个异步函数,不要误以为必须搭配服务端才能使用。

避坑总结:乐观更新不是“先 setState 再说” ​

1. 把乐观值写进正式 state,导致失败后闪烁或重复回滚 ​

常见错误现象:点击后先 setComments([temp, ...list]),请求成功后又插入一条服务端返回评论,失败时再手动删除。网络慢或用户连续点击时,列表可能出现重复项、短暂消失或顺序错乱。

修正方法:临时值只通过 useOptimistic 的 updater 注入,正式 state.comments 只保存服务端确认的数据。渲染时使用 optimisticComments,状态提交仍以 Action 返回值为准。

2. 在组件顶层随意调用 useFormStatus,结果 pending 永远是 false ​

常见错误现象:把 const { pending } = useFormStatus() 写在表单所在的父组件里,按钮 loading 不变化;或者把按钮放在 <form> 外部,状态读取不到。

修正方法:useFormStatus 只能读取组件树上最近的表单提交上下文。应将提交按钮、提示文案拆成 <form> 的子组件,例如示例中的 SubmitButton。父组件如果也需要 pending,请使用 useActionState 返回的第三个值或 useTransition。

3. Action 中吞掉异常,React 无法进入失败流程 ​

常见错误现象:在 Action 内部写了 catch 但没有返回包含错误信息的新状态,或者捕获后只 console.log,界面既不报错也不更新。

修正方法:客户端 Action 应返回一个明确的状态对象,如 { comments, error };如果希望 React 的错误边界接管,则不要吞异常。对于可恢复的表单错误,推荐“捕获并返回错误状态”;对于真正的渲染异常,再交给 Error Boundary。

4. 在 pending 期间允许重复提交,制造并发覆盖 ​

常见错误现象:用户快速双击发布按钮,发出两个请求;较慢的旧请求后返回,反而覆盖新结果。

修正方法:提交按钮应使用 disabled={pending};对点赞、关注这类切换操作,还应在接口层增加请求序号、版本号或幂等键。Actions 能管理界面提交态,但不能替代业务幂等设计。

5. 误以为乐观更新适用于所有异步请求 ​

常见错误现象:删除订单、支付、权限变更等不可逆操作也立即显示成功,一旦失败用户会误以为操作已经完成。

修正方法:乐观更新适合高频、低风险、可补偿的交互,例如点赞、收藏、草稿输入、列表排序;涉及资金、权限和删除的操作,应展示明确 pending 并等待服务端确认。

结语 ​

React 19 Actions 的价值不只是少写几个 useState(false),而是把异步交互中的 pending、错误、过渡更新与回滚变成框架内建的状态机。配合 useActionState、useFormStatus 和 useOptimistic,大多数表单与轻量按钮都可以删掉重复 loading 模板。

实际迁移时建议从评论、收藏、点赞这类低风险模块开始:先把请求函数改成 Action,再用 useOptimistic 替换手写镜像状态,最后把按钮 loading 迁移到 useFormStatus。当这些模式稳定后,你会发现团队新增交互时讨论的重点,已经从“布尔值放哪”回到了“失败如何提示、数据如何确认”。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。