← 返回文章

JavaScript / 前端工程 / React

React 19 Actions 实战:把提交、等待和乐观更新写清楚

表单提交看起来只是“拿到数据,发起请求,更新界面”,实际项目里却总会长出一串状态:isSubmitting 防止重复点击,error 展示业务错误,成功后清空输入,列表等待刷新,还要处理网络失败时的回滚。

React 19 把这类异步状态更新统一到 Actions 的思路下,并提供 useActionStateuseOptimistic 等能力。它们不是替你设计接口,也不会让错误处理自动消失;真正的价值是让“提交动作”和它产生的等待、结果、乐观状态形成更清晰的生命周期。

先理解 React 里的 Action

在这里,Action 可以理解为一个会引发状态变化、可能包含异步副作用的函数。表单的 action 属性可以接收函数,React 会围绕它管理提交过程。React 19 官方发布说明也把 pending 状态、错误、表单处理和乐观更新作为这套模型的重要部分。

最简单的函数式表单如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function SearchForm({ onSearch }) {
async function search(formData) {
const keyword = formData.get('keyword')?.toString().trim();
if (!keyword) return;

await onSearch(keyword);
}

return (
<form action={search}>
<input name="keyword" aria-label="搜索关键词" />
<button type="submit">搜索</button>
</form>
);
}

传给函数的 FormData 来自表单中带 name 的控件。没有 name 的输入不会进入提交数据,这是从传统 HTML 表单延续下来的规则,也是实际开发中最常见的“为什么取不到值”原因之一。

用 useActionState 管理结果和等待状态

当 Action 需要返回校验错误或成功信息时,可以使用 useActionState

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import { useActionState } from 'react';

const initialState = {
status: 'idle',
message: '',
};

async function createAccount(previousState, formData) {
const email = formData.get('email')?.toString().trim() ?? '';

if (!email.includes('@')) {
return {
status: 'error',
message: '请输入有效的邮箱地址',
};
}

const response = await fetch('/api/accounts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});

if (!response.ok) {
return {
status: 'error',
message: '提交失败,请稍后重试',
};
}

return {
status: 'success',
message: '创建成功',
};
}

function AccountForm() {
const [state, formAction, isPending] = useActionState(
createAccount,
initialState,
);

return (
<form action={formAction}>
<label>
邮箱
<input
name="email"
type="email"
autoComplete="email"
aria-describedby="account-message"
/>
</label>

<button type="submit" disabled={isPending}>
{isPending ? '提交中…' : '创建账号'}
</button>

<p id="account-message" aria-live="polite">
{state.message}
</p>
</form>
);
}

它的签名可以概括为:

1
const [state, formAction, isPending] = useActionState(action, initialState);

最容易写错的是 Action 参数顺序。使用 useActionState 后,回调第一个参数是上一次状态,表单提交的 FormData 是后续参数。把函数仍写成 async function action(formData),你拿到的其实会是 previous state。

三个返回值各有明确职责:

  • state:最近一次 Action 返回的状态;
  • formAction:应该交给 <form action={...}> 的新 Action;
  • isPending:本次 Action 是否仍在执行。

这种模型比手动维护多个互相关联的布尔值更紧凑,但状态结构仍要克制。通常 status + message + 必要数据 就够了,不要把所有服务端响应原样塞进组件状态。

业务错误应返回,异常错误应进入错误边界

“邮箱已存在”属于用户可以理解并修正的业务结果,适合从 Action 返回并在表单附近展示。网络代码抛出的未知异常、程序错误或无法恢复的错误,则应交给错误边界和监控系统。

可以对预期错误做显式转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
async function updateProfile(previousState, formData) {
try {
const result = await api.updateProfile({
nickname: formData.get('nickname'),
});

return {
status: 'success',
message: '资料已更新',
profile: result,
};
} catch (error) {
if (error instanceof ValidationError) {
return {
status: 'error',
message: error.message,
fields: error.fields,
};
}

throw error;
}
}

不要用一个宽泛的 catch 把所有错误都变成“请重试”。那样会掩盖真正的代码缺陷,也让线上监控失去信号。

用 useOptimistic 提前展示结果

评论、点赞、待办事项这类操作,如果总要等网络返回后才变化,界面会显得迟钝。useOptimistic 允许我们在 Action 进行期间展示推测结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { useOptimistic } from 'react';

function TodoList({ todos, onCreate }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(currentTodos, newTodo) => [newTodo, ...currentTodos],
);

async function createTodo(formData) {
const title = formData.get('title')?.toString().trim();
if (!title) return;

addOptimisticTodo({
id: `optimistic-${crypto.randomUUID()}`,
title,
pending: true,
});

await onCreate(title);
}

return (
<section>
<form action={createTodo}>
<input name="title" required />
<button type="submit">添加</button>
</form>

<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id} aria-busy={todo.pending || undefined}>
{todo.title}
{todo.pending && <span>(同步中)</span>}
</li>
))}
</ul>
</section>
);
}

更新函数 (currentTodos, newTodo) => ... 应保持纯净:不要在里面发请求、改外部变量或生成依赖调用次数的副作用。临时 ID 在调用 addOptimisticTodo 前创建,更容易理解和测试。

乐观状态只在 Action 进行期间覆盖真实状态。Action 成功后,外部 todos 仍需要通过缓存更新、父组件状态或重新请求变成最新结果;否则乐观项会消失,看起来像“提交成功后反而回滚”。如果 Action 失败,乐观状态回退是合理的,但还必须告诉用户发生了什么,并保留重试入口。

哪些操作不适合乐观更新

乐观更新适合成功率高、结果容易预测、失败可恢复的操作,例如点赞、关注、排序或新增普通待办。下面这些场景则应更谨慎:

  • 支付、转账和库存扣减;
  • 权限审批等不可轻易撤销的动作;
  • 服务端会生成大量不可预测结果的操作;
  • 失败后用户可能基于错误状态继续做关键决策的流程。

“看起来更快”不能以展示虚假成功为代价。高风险操作可以先显示明确的处理中状态,等待服务端确认后再给出成功反馈。

Pending 状态不只是禁用按钮

isPending 最直观的用途是防止重复提交,但完整体验还应考虑:

  1. 按钮文案说明当前正在做什么,而不只是变灰。
  2. 使用 aria-live="polite" 宣布成功或错误信息。
  3. 不要禁用整个表单到让用户无法复制已输入内容。
  4. 对耗时较长的操作提供取消或离开提示。
  5. 服务端接口仍要具备幂等或去重能力,前端禁用按钮不是数据安全保证。

如果提交按钮被拆成深层子组件,可以使用 useFormStatus 读取所在表单的 pending 状态。但它依赖表单上下文,调用它的组件必须位于目标 <form> 内部。不要为了少传一个 prop,就把本来清楚的组件边界变得隐晦。

从旧写法迁移时怎么做

旧表单常见结构是 onSubmit + preventDefault + 多个 useState。迁移不需要一次推翻所有逻辑,可以按下面的顺序进行:

  1. 把提交副作用提取成一个独立的 async 函数。
  2. 区分可展示的业务结果和应该抛出的异常。
  3. useActionState 接管结果状态与 pending 状态。
  4. 把表单改为函数 action,确认所有控件都有正确的 name
  5. 只有真实用户体验受网络等待影响时,再引入 useOptimistic
  6. 为重复提交、失败回滚、慢请求和无障碍提示编写测试。

Actions 的重点不是少写几行代码,而是让异步状态变化拥有一致的结构:输入来自表单,等待状态跟随动作,业务结果明确返回,意外异常正常上抛,乐观结果有真实数据接管。只要边界清楚,复杂表单也不必退回一堆难以同步的布尔状态。

参考资料