外观
React 19 Actions 实战:干掉九成手写 loading
问题背景:表单提交为什么总在重复写 loading
在 React 18 及更早版本里,一个普通的“点赞、收藏、修改昵称、提交评论”流程,往往要同时维护三套状态:请求中的 pending、请求失败后的 error、成功后的 data。如果再追求更好的交互体验,还要先改界面、失败后回滚,于是组件里会出现大量 try/catch/finally、setSubmitting(true)、按钮禁用与错误提示逻辑。
这类代码的问题不是难,而是太容易分散:请求函数处理业务,组件处理 loading,表单又处理禁用态;一旦多个入口触发同一个异步操作,状态很容易不同步。React 19 将这类异步场景抽象为 Actions,并配合 useActionState、useFormStatus 与 useOptimistic,把“提交中、成功、失败、乐观更新”收敛成一套内建模型。
下面先看典型状态流,再给出可直接运行的实现。
方案对比: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 的核心收益有三点:
- 异步动作即状态:传给
<form action={fn}>或通过useActionState返回的dispatch执行,React 自动跟踪 pending。 - 表单状态可穿透组件:按钮组件不必层层接收
loading,使用useFormStatus即可读取最近一次表单提交状态。 - 乐观值有独立通道:
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。当这些模式稳定后,你会发现团队新增交互时讨论的重点,已经从“布尔值放哪”回到了“失败如何提示、数据如何确认”。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。