React 19 useActionState 深度解析 & Vue 2.7 移植实战
一、useActionState 是什么?
useActionState 是 React 19 新增的 Hook,专门用于处理表单提交/异步操作的状态管理。它将「异步操作」「loading 状态」「错误处理」「结果数据」统一收敛到一个 Hook 中。
前身是
useFormState(React Canary),在 React 19 正式版中重命名为useActionState。
1.1 解决了什么痛点?
没有 useActionState 之前,处理一个表单提交你需要:
jsx
function LoginForm() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [isPending, setIsPending] = useState(false);
const handleSubmit = async (formData) => {
setIsPending(true);
setError(null);
try {
const result = await loginAPI(formData);
setData(result);
} catch (err) {
setError(err.message);
} finally {
setIsPending(false);
}
};
return (
<form onSubmit={handleSubmit}>
{isPending && <Spinner />}
{error && <p className="error">{error}</p>}
{data && <p>欢迎, {data.username}</p>}
{/* ... */}
</form>
);
}
三个 useState + try/catch + 手动管理 loading ------ 模板代码太多了!
1.2 函数签名
typescript
const [state, formAction, isPending] = useActionState(
actionFn, // 异步操作函数
initialState, // 初始状态
permalink? // 可选,用于 SSR 的永久链接
);
参数说明:
| 参数 | 类型 | 说明 |
|---|---|---|
actionFn |
(previousState, formData) => newState |
异步操作函数,接收上一次的 state 和表单数据 |
initialState |
any |
状态初始值 |
permalink |
string(可选) |
SSR 场景下 JS 未加载时的回退 URL |
返回值说明:
| 返回值 | 类型 | 说明 |
|---|---|---|
state |
any |
当前状态(action 执行后的返回值) |
formAction |
function |
传给 <form action={}> 或按钮的 action |
isPending |
boolean |
操作是否正在进行中 |
二、useActionState 使用方式详解
2.1 基础用法:表单提交
jsx
import { useActionState } from 'react';
// 异步 action 函数
async function submitForm(previousState, formData) {
const username = formData.get('username');
const password = formData.get('password');
try {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
return {
success: false,
message: '登录失败:' + response.statusText,
data: null
};
}
const data = await response.json();
return {
success: true,
message: '登录成功!',
data
};
} catch (err) {
return {
success: false,
message: '网络错误:' + err.message,
data: null
};
}
}
// 初始状态
const initialState = {
success: false,
message: '',
data: null,
};
function LoginForm() {
const [state, formAction, isPending] = useActionState(
submitForm,
initialState
);
return (
<form action={formAction}>
<h2>用户登录</h2>
{/* 显示操作结果 */}
{state.message && (
<div className={state.success ? 'success' : 'error'}>
{state.message}
</div>
)}
<div>
<label htmlFor="username">用户名:</label>
<input id="username" name="username" required />
</div>
<div>
<label htmlFor="password">密码:</label>
<input id="password" name="password" type="password" required />
</div>
<button type="submit" disabled={isPending}>
{isPending ? '登录中...' : '登录'}
</button>
{/* 展示登录成功后的用户信息 */}
{state.success && state.data && (
<div className="user-info">
<p>欢迎回来,{state.data.username}!</p>
<p>角色:{state.data.role}</p>
</div>
)}
</form>
);
}
2.2 非表单场景:普通按钮操作
useActionState 不仅限于表单,任何异步操作都可以用:
jsx
import { useActionState } from 'react';
async function addToCart(previousState, productId) {
try {
const res = await fetch('/api/cart', {
method: 'POST',
body: JSON.stringify({ productId }),
headers: { 'Content-Type': 'application/json' },
});
const cart = await res.json();
return {
success: true,
itemCount: cart.items.length,
message: '已加入购物车',
};
} catch (err) {
return {
...previousState,
success: false,
message: '操作失败',
};
}
}
function ProductCard({ product }) {
const [cartState, addAction, isPending] = useActionState(
addToCart,
{ success: false, itemCount: 0, message: '' }
);
return (
<div className="product-card">
<h3>{product.name}</h3>
<p>¥{product.price}</p>
{/* 注意:非表单场景通过手动调用 */}
<button
onClick={() => addAction(product.id)}
disabled={isPending}
>
{isPending ? '添加中...' : '加入购物车'}
</button>
{cartState.message && <p>{cartState.message}</p>}
{cartState.success && <p>购物车共 {cartState.itemCount} 件</p>}
</div>
);
}
2.3 利用 previousState 做累积操作
actionFn 的第一个参数是上一次返回的 state,这非常适合做列表追加:
jsx
async function loadMoreComments(previousState, page) {
const res = await fetch(`/api/comments?page=${page}`);
const newComments = await res.json();
return {
comments: [...previousState.comments, ...newComments],
currentPage: page,
hasMore: newComments.length === 10,
};
}
function CommentList() {
const [state, loadMore, isPending] = useActionState(
loadMoreComments,
{ comments: [], currentPage: 0, hasMore: true }
);
return (
<div>
{state.comments.map(c => (
<div key={c.id}>{c.content}</div>
))}
{state.hasMore && (
<button
onClick={() => loadMore(state.currentPage + 1)}
disabled={isPending}
>
{isPending ? '加载中...' : '加载更多'}
</button>
)}
</div>
);
}
2.4 与 useFormStatus 配合使用(完整表单方案)
jsx
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
// 子组件:自动感知表单提交状态
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? '提交中...' : '提交'}
</button>
);
}
// 父组件
function ContactForm() {
const [state, formAction] = useActionState(
async (prev, formData) => {
const res = await fetch('/api/contact', {
method: 'POST',
body: formData,
});
if (res.ok) return { success: true, message: '提交成功!' };
return { success: false, message: '提交失败' };
},
{ success: false, message: '' }
);
return (
<form action={formAction}>
<input name="email" type="email" placeholder="邮箱" required />
<textarea name="message" placeholder="留言内容" required />
{state.message && (
<p style={{ color: state.success ? 'green' : 'red' }}>
{state.message}
</p>
)}
<SubmitButton />
</form>
);
}
三、useActionState 的内部原理(简化)
ini
调用 useActionState(actionFn, initialState)
│
▼
内部创建一个 reducer:
┌────────────────────────────────┐
│ state = initialState │
│ isPending = false │
│ │
│ 当 formAction 被触发时: │
│ 1. isPending = true │
│ 2. result = await actionFn( │
│ previousState, │
│ formData/payload │
│ ) │
│ 3. state = result │
│ 4. isPending = false │
│ 5. 触发重新渲染 │
└────────────────────────────────┘
│
▼
返回 [state, formAction, isPending]
本质 :useActionState ≈ useReducer + useTransition 的封装,用 transition 包裹异步操作来自动管理 pending 状态。
四、在 Vue 2.7.15 中实现 useActionState
Vue 2.7 引入了 Composition API(ref、computed、watch 等),这让我们有能力实现类似的功能。
4.1 核心实现
创建文件 src/composables/useActionState.js:
javascript
import { ref, shallowRef, readonly } from 'vue';
/**
* Vue 2.7 版本的 useActionState
* 模拟 React 19 的 useActionState Hook
*
* @param {Function} actionFn - 异步操作函数 (previousState, payload) => newState
* @param {any} initialState - 初始状态
* @param {Object} options - 可选配置
* @param {Function} options.onSuccess - 成功回调
* @param {Function} options.onError - 失败回调
* @param {boolean} options.resetOnError - 失败时是否重置为初始状态
* @returns {Object} { state, action, isPending, reset }
*/
export function useActionState(actionFn, initialState, options = {}) {
const {
onSuccess = null,
onError = null,
resetOnError = false,
} = options;
// ---- 核心状态 ----
// 使用 shallowRef 存储 state(避免深层对象的深度响应式带来的性能问题)
// 类似 React 中 useState 的 state
const state = shallowRef(
typeof initialState === 'function' ? initialState() : initialState
);
// 是否正在执行中(对应 React 的 isPending)
const isPending = ref(false);
// 内部:防止竞态条件的版本号
let actionVersion = 0;
// ---- 核心方法 ----
/**
* 触发 action(对应 React 返回的 formAction)
* @param {any} payload - 传递给 actionFn 的数据(对应 React 中的 formData)
* @returns {Promise<any>} action 的执行结果
*/
async function action(payload) {
// 递增版本号,用于处理竞态
const currentVersion = ++actionVersion;
// 设置 loading 状态
isPending.value = true;
try {
// 调用用户传入的 actionFn
// 参数1: previousState(上一次的状态,对应 React 的 previousState)
// 参数2: payload(用户传入的数据,对应 React 的 formData)
const result = await actionFn(state.value, payload);
// 竞态检查:如果在等待期间又触发了新的 action,
// 则丢弃旧的结果(只保留最新一次的结果)
if (currentVersion !== actionVersion) {
return;
}
// 更新状态为 action 的返回值
state.value = result;
// 触发成功回调
if (onSuccess && typeof onSuccess === 'function') {
onSuccess(result);
}
return result;
} catch (error) {
// 竞态检查
if (currentVersion !== actionVersion) {
return;
}
// 失败时是否重置状态
if (resetOnError) {
state.value = typeof initialState === 'function'
? initialState()
: initialState;
}
// 触发失败回调
if (onError && typeof onError === 'function') {
onError(error);
}
// 将错误继续抛出,让调用方也能 catch
throw error;
} finally {
// 竞态检查:只有最新的 action 才能关闭 loading
if (currentVersion === actionVersion) {
isPending.value = false;
}
}
}
/**
* 重置为初始状态(React 的 useActionState 没有这个,这是增强功能)
*/
function reset() {
state.value = typeof initialState === 'function'
? initialState()
: initialState;
isPending.value = false;
actionVersion++; // 取消正在进行的 action
}
// ---- 返回值 ----
// 对应 React 的 [state, formAction, isPending]
// Vue 中用对象返回,更符合 Composition API 惯例
return {
state: readonly(state), // 只读,防止外部直接修改(必须通过 action 更新)
action, // 触发操作的函数
isPending: readonly(isPending), // 只读的 loading 状态
reset, // 重置方法(增强功能)
};
}
4.2 代码逐行解析
为什么用 shallowRef 而不是 ref?
javascript
const state = shallowRef(initialState);
ref 会对对象进行深度响应式转换 (递归 Proxy),如果 state 是一个包含大量数据的对象(如列表数据),会有性能开销。shallowRef 只对 .value 本身做响应式,赋新值时才触发更新------和 React 的 useState(引用比较)行为一致。
竞态处理是什么意思?
javascript
let actionVersion = 0;
async function action(payload) {
const currentVersion = ++actionVersion;
// ... await 异步操作
if (currentVersion !== actionVersion) return; // 不是最新的,丢弃
}
场景:用户快速点击了3次提交按钮,发出了3个请求。第1个请求最慢,第3个最快。如果不做竞态处理,最终 state 会被第1个请求的结果覆盖(而不是第3个),导致数据不一致。
为什么返回 readonly?
javascript
return {
state: readonly(state),
isPending: readonly(isPending),
};
模拟 React 中 state 不可直接修改的约束------状态只能通过 action 函数更新,不能在外部直接 state.value = xxx。
4.3 支持表单的增强版本
为了更好地配合 Vue 的表单处理,我们增加一个专门处理表单提交的包装:
创建文件 src/composables/useFormActionState.js:
javascript
import { useActionState } from './useActionState';
/**
* 表单专用版本,自动收集表单数据
* 模拟 React 19 中 <form action={formAction}> 的行为
*
* @param {Function} actionFn - (previousState, formDataObject) => newState
* @param {any} initialState - 初始状态
* @param {Object} options - 配置项
* @returns {Object} { state, handleSubmit, isPending, reset }
*/
export function useFormActionState(actionFn, initialState, options = {}) {
const { state, action, isPending, reset } = useActionState(
actionFn,
initialState,
options
);
/**
* 表单提交处理函数
* 可以直接绑定到 <form @submit="handleSubmit">
* 自动收集 FormData 并转换为普通对象
*
* @param {Event} event - 表单提交事件
*/
async function handleSubmit(event) {
// 阻止默认提交行为
event.preventDefault();
// 获取表单元素
const form = event.target;
// 使用 FormData API 收集表单数据
const formData = new FormData(form);
// 将 FormData 转换为普通对象(方便使用)
const formDataObject = {};
formData.forEach((value, key) => {
// 处理同名字段(如多选框)
if (formDataObject[key] !== undefined) {
if (!Array.isArray(formDataObject[key])) {
formDataObject[key] = [formDataObject[key]];
}
formDataObject[key].push(value);
} else {
formDataObject[key] = value;
}
});
// 调用 action,传入收集到的表单数据
try {
await action(formDataObject);
} catch (error) {
// 错误已在 useActionState 内部处理
console.error('[useFormActionState] 表单提交失败:', error);
}
}
return {
state,
handleSubmit, // 直接绑定到 @submit
isPending,
reset,
};
}
4.4 完整使用示例
示例1:登录表单
src/components/LoginForm.vue
vue
<template>
<form @submit="handleSubmit" class="login-form">
<h2>用户登录</h2>
<!-- 状态提示 -->
<div v-if="state.message" :class="['alert', state.success ? 'success' : 'error']">
{{ state.message }}
</div>
<!-- 用户名 -->
<div class="form-group">
<label for="username">用户名:</label>
<input
id="username"
name="username"
type="text"
required
:disabled="isPending"
placeholder="请输入用户名"
/>
</div>
<!-- 密码 -->
<div class="form-group">
<label for="password">密码:</label>
<input
id="password"
name="password"
type="password"
required
:disabled="isPending"
placeholder="请输入密码"
/>
</div>
<!-- 记住我 -->
<div class="form-group">
<label>
<input name="remember" type="checkbox" value="true" />
记住我
</label>
</div>
<!-- 提交按钮 -->
<button type="submit" :disabled="isPending" class="btn-primary">
<span v-if="isPending" class="spinner"></span>
{{ isPending ? '登录中...' : '登录' }}
</button>
<!-- 登录成功后显示用户信息 -->
<div v-if="state.success && state.data" class="user-info">
<p>🎉 欢迎回来,{{ state.data.username }}!</p>
<p>角色:{{ state.data.role }}</p>
<button type="button" @click="reset">退出</button>
</div>
</form>
</template>
<script>
import { useFormActionState } from '@/composables/useFormActionState';
// 模拟登录 API
async function loginAPI(previousState, formData) {
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 1500));
const { username, password } = formData;
// 模拟校验
if (username === 'admin' && password === '123456') {
return {
success: true,
message: '登录成功!',
data: {
username: 'admin',
role: '管理员',
token: 'mock-token-xxx',
},
};
}
// 登录失败:返回新 state(不是抛错误!)
// 这和 React 的 useActionState 设计一致------通过返回值表达状态
return {
success: false,
message: '用户名或密码错误',
data: null,
};
}
export default {
name: 'LoginForm',
setup() {
// 初始状态
const initialState = {
success: false,
message: '',
data: null,
};
// 使用 useFormActionState ------ 一行搞定所有状态管理!
const { state, handleSubmit, isPending, reset } = useFormActionState(
loginAPI,
initialState,
{
onSuccess: (result) => {
if (result.success) {
console.log('登录成功,token:', result.data.token);
}
},
}
);
return {
state,
handleSubmit,
isPending,
reset,
};
},
};
</script>
<style scoped>
.login-form {
max-width: 400px;
margin: 40px auto;
padding: 24px;
border: 1px solid #e0e0e0;
border-radius: 8px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 4px;
font-weight: 500;
}
.form-group input[type="text"],
.form-group input[type="password"] {
width: 100%;
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
}
.alert {
padding: 10px 14px;
border-radius: 4px;
margin-bottom: 16px;
}
.alert.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.btn-primary {
width: 100%;
padding: 10px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-primary:disabled {
background: #91caff;
cursor: not-allowed;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid #ffffff80;
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.user-info {
margin-top: 16px;
padding: 12px;
background: #f0f9ff;
border-radius: 4px;
}
</style>
示例2:加载更多列表(利用 previousState 累积)
src/components/CommentList.vue
vue
<template>
<div class="comment-list">
<h2>评论列表</h2>
<!-- 评论列表 -->
<div
v-for="comment in state.comments"
:key="comment.id"
class="comment-item"
>
<strong>{{ comment.author }}</strong>
<p>{{ comment.content }}</p>
<span class="time">{{ comment.time }}</span>
</div>
<!-- 空状态 -->
<p v-if="!state.comments.length && !isPending">暂无评论</p>
<!-- 加载更多按钮 -->
<button
v-if="state.hasMore"
@click="loadMore(state.currentPage + 1)"
:disabled="isPending"
class="btn-load-more"
>
{{ isPending ? '加载中...' : '加载更多' }}
</button>
<p v-if="!state.hasMore && state.comments.length" class="no-more">
------ 没有更多了 ------
</p>
</div>
</template>
<script>
import { onMounted } from 'vue';
import { useActionState } from '@/composables/useActionState';
// 模拟获取评论 API
async function fetchComments(previousState, page) {
await new Promise(resolve => setTimeout(resolve, 800));
// 模拟分页数据
const pageSize = 5;
const total = 13;
const start = (page - 1) * pageSize;
const newComments = Array.from(
{ length: Math.min(pageSize, total - start) },
(_, i) => ({
id: start + i + 1,
author: `用户${start + i + 1}`,
content: `这是第 ${start + i + 1} 条评论的内容`,
time: new Date(Date.now() - (start + i) * 3600000).toLocaleString(),
})
);
return {
// 关键:利用 previousState 累积数据!
comments: [...previousState.comments, ...newComments],
currentPage: page,
hasMore: start + pageSize < total,
};
}
export default {
name: 'CommentList',
setup() {
const { state, action: loadMore, isPending } = useActionState(
fetchComments,
{
comments: [],
currentPage: 0,
hasMore: true,
}
);
// 组件挂载时自动加载第一页
onMounted(() => {
loadMore(1);
});
return {
state,
loadMore,
isPending,
};
},
};
</script>
<style scoped>
.comment-list {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.comment-item {
padding: 12px;
border-bottom: 1px solid #eee;
}
.comment-item strong {
color: #333;
}
.comment-item p {
margin: 8px 0 4px;
color: #555;
}
.time {
font-size: 12px;
color: #999;
}
.btn-load-more {
display: block;
width: 100%;
padding: 12px;
margin-top: 16px;
background: white;
border: 1px solid #1890ff;
color: #1890ff;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-load-more:disabled {
border-color: #91caff;
color: #91caff;
cursor: not-allowed;
}
.no-more {
text-align: center;
color: #999;
margin-top: 16px;
}
</style>