
useActionState()
useActionState 是一个 React Hook,可以让你使用 Actions 来更新具有副作用的状态。
js
const [state, dispatchAction, isPending] = useActionState(reducerAction, initialState, permalink?);
参考 {/reference/}
useActionState(reducerAction, initialState, permalink?) {/useactionstate/}
在组件的顶层调用 useActionState 来创建一个表示 Action 结果的 state。
js
import { useActionState } from 'react';
function reducerAction(previousState, actionPayload) {
// ...
}
function MyCart({initialState}) {
const [state, dispatchAction, isPending] = useActionState(reducerAction, initialState);
// ...
}
参数 {/parameters/}
reducerAction:触发 Action 时调用的函数。该函数接收当前的 state(最初是你提供的initialState,之后是它上一次的返回值)作为第一个参数,然后是传递给dispatchAction的actionPayload作为第二个参数。initialState:你希望 state 初始时的值。在dispatchAction首次被调用后,React 会忽略此参数。- 可选
permalink:一个包含此表单所修改页面的唯一 URL 的字符串。- 用于带有React 服务器组件的页面,以支持渐进增强。
- 如果
reducerAction是一个服务器函数,并且表单在 JavaScript bundle 加载完成之前被提交,浏览器将导航到指定的permalinkURL,而不是当前页面的 URL。
返回值 {/returns/}
useActionState 返回一个包含三个值的数组:
- 当前的 state。在首次渲染期间,该值等于你传入的
initialState参数。在dispatchAction被调用后,该值会变为reducerAction的返回值。 - 一个
dispatchAction函数,可以在 Actions 中调用。 - 一个
isPending标识,用于表明当前 Actions 是否处于 pending 状态。
注意 {/caveats/}
useActionState是一个 Hook,所以你只能在组件的顶层或自定义 Hook 中调用它。你不能在循环或条件语句中调用它。如果你需要这样做,请提取一个新组件并将 state 移入其中。- React 会按顺序对多次
dispatchAction调用进行排队并执行。每次reducerAction调用都会接收上一次调用的结果。 dispatchAction函数具有稳定的标识,因此你通常会看到它被从 Effect 的依赖项中省略,但包含它不会导致 Effect 触发。如果 linter 允许你省略依赖项而不报错,那么这样做是安全的。了解更多关于移除 Effect 依赖的信息。- 使用
permalink选项时,请确保目标页面渲染了相同的表单组件(包括相同的reducerAction和permalink),以便 React 知道如何传递 state。一旦页面变为可交互状态,此参数将不再起作用。 - 使用服务器函数时,
initialState需要是 可序列化的(例如普通对象、数组、字符串和数字等值)。 - 如果
dispatchAction抛出错误,React 会取消所有已排队的 Actions,并显示最近的 错误边界。 - 如果有多个正在进行的 Actions,React 会将它们分批处理。这是一个限制,可能会在未来的版本中移除。
<注意>
dispatchAction 必须从 Action 中调用。
你可以将其包装在 startTransition 中,或者将其传递给 Action prop。在该作用域之外调用将不会被视作 Transition 的一部分,并且在开发模式下会记录错误。
</注意>
reducerAction 函数 {/reduceraction/}
传递给 useActionState 的 reducerAction 函数接收上一个 state 并返回一个新的 state。
与 useReducer 中的 reducer 不同,reducerAction 可以是异步的,并且可以执行副作用:
js
async function reducerAction(previousState, actionPayload) {
const newState = await post(actionPayload);
return newState;
}
每次你调用 dispatchAction 时,React 都会使用 actionPayload 调用 reducerAction。reducer 将执行诸如提交数据之类的副作用,并返回新的 state。如果 dispatchAction 被多次调用,React 会对它们进行排队并按顺序执行,以便将上一次调用的结果作为 previousState 传递给当前调用。
参数 {/reduceraction-parameters/}
-
previousState:上一次的 state。最初等于initialState。在dispatchAction首次被调用后,它等于上一次返回的 state。 -
可选
actionPayload:传递给dispatchAction的参数。它可以是任何类型的值。与useReducer的约定类似,它通常是一个带有type属性来标识它的对象,并且可以包含其他带有附加信息的属性。
返回值 {/reduceraction-returns/}
reducerAction 返回新的 state,并触发一个 Transition 以使用该 state 进行重新渲染。
注意事项 {/reduceraction-caveats/}
reducerAction可以是同步的或异步的。它可以执行同步操作,例如显示通知,或执行异步操作,例如向服务器提交更新。- 由于
reducerAction被设计为允许副作用,因此在<StrictMode>中reducerAction不会 被调用两次。 reducerAction的返回类型必须与initialState的类型匹配。如果 TypeScript 推断出类型不匹配,你可能需要显式标注 state 的类型。- 如果你在
reducerAction中的await之后设置 state,目前需要将状态更新包装在额外的startTransition中。有关更多信息,请参阅startTransition文档。 - 使用服务器函数时,
actionPayload需要是 可序列化的(例如普通对象、数组、字符串和数字等值)。
<深入探讨>
为什么叫 reducerAction? {/why-is-it-called-reduceraction/}
传递给 useActionState 的函数被称为 reducer action,因为:
- 它将之前的 state reduce 为新的 state,就像
useReducer一样。 - 它是一个 Action,因为它在 Transition 内部被调用,并且可以执行副作用。
从概念上讲,useActionState 就像是 useReducer,但你可以在 reducer 中执行副作用。
</深入探讨>
用法 {/usage/}
为 Action 添加状态 {/adding-state-to-an-action/}
在组件的顶层调用 useActionState 来为 Action 的结果创建一个 state。
js [[1, 7, "count"], [2, 7, "dispatchAction"], [3, 7, "isPending"]]
import { useActionState } from 'react';
async function addToCartAction(prevCount) {
// ...
}
function Counter() {
const [count, dispatchAction, isPending] = useActionState(addToCartAction, 0);
// ...
}
useActionState 返回一个包含以下三个值的数组:
- 该表单的
当前 state ,初始值是传入的参数。 - 该表单的
action 调度函数 ,用于让你触发reducerAction。 - 一个
pending state ,用于指示 Action 是否还在执行中。
要调用 addToCartAction,请调用 addToCartAction 的调用进行排队。
js src/App.js
import { useActionState, startTransition } from 'react';
import { addToCart } from './api';
import Total from './Total';
export default function Checkout() {
const [count, dispatchAction, isPending] = useActionState(async (prevCount) => {
return await addToCart(prevCount)
}, 0);
function handleClick() {
startTransition(() => {
dispatchAction();
});
}
return (
<div className="checkout">
<h2>Checkout</h2>
<div className="row">
<span>Eras Tour Tickets</span>
<span>Qty: {count}</span>
</div>
<div className="row">
<button onClick={handleClick}>Add Ticket{isPending ? ' 🌀' : ' '}</button>
</div>
<hr />
<Total quantity={count} />
</div>
);
}
js src/Total.js
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
});
export default function Total({quantity}) {
return (
<div className="row total">
<span>Total</span>
<span>{formatter.format(quantity * 9999)}</span>
</div>
);
}
js src/api.js
export async function addToCart(count) {
await new Promise(resolve => setTimeout(resolve, 1000));
return count + 1;
}
export async function removeFromCart(count) {
await new Promise(resolve => setTimeout(resolve, 1000));
return Math.max(0, count - 1);
}
css
.checkout {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid #ccc;
border-radius: 8px;
font-family: system-ui;
}
.checkout h2 {
margin: 0 0 8px 0;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
}
.row button {
margin-left: auto;
min-width: 150px;
}
.total {
font-weight: bold;
}
hr {
width: 100%;
border: none;
border-top: 1px solid #ccc;
margin: 4px 0;
}
button {
padding: 8px 16px;
cursor: pointer;
}
每次你点击 "Add Ticket",React 都会对 addToCartAction 的调用进行排队。React 会显示 pending 状态,直到所有票都被添加,然后使用最终的 state 重新渲染。
<深入探讨>
useActionState 的排队机制如何工作 {/how-useactionstate-queuing-works/}
尝试多次点击 "Add Ticket"。每次你点击,一个新的 addToCartAction 就会被排队。由于存在人为的 1 秒延迟,这意味着点击 4 次将需要大约 4 秒才能完成。
这是 useActionState 设计上的有意为之。
我们必须等待上一次 addToCartAction 的结果,才能将 prevCount 传递给下一次 addToCartAction 调用。这意味着 React 必须等待上一个 Action 完成才能调用下一个 Action。
你通常可以通过与 useOptimistic 结合使用来解决这个问题,但对于更复杂的情况,你可能需要考虑取消已排队的 Actions 或不使用 useActionState。
</深入探讨>
使用多种 Action 类型 {/using-multiple-action-types/}
为了处理多种类型,你可以向 dispatchAction 传递一个参数。
按照惯例,通常将其编写为 switch 语句。对于 switch 中的每个 case,计算并返回某个下一个 state。该参数可以有任何形状,但通常传递带有 type 属性的对象来标识 action。
js src/App.js
import { useActionState, startTransition } from 'react';
import { addToCart, removeFromCart } from './api';
import Total from './Total';
export default function Checkout() {
const [count, dispatchAction, isPending] = useActionState(updateCart Action, 0);
function handleAdd() {
startTransition(() => {
dispatchAction({ type: 'ADD' });
});
}
function handleRemove() {
startTransition(() => {
dispatchAction({ type: 'REMOVE' });
});
}
return (
<div className="checkout">
<h2>Checkout</h2>
<div className="row">
<span>Eras Tour Tickets</span>
<span className="stepper">
<span className="qty">{isPending ? '🌀' : count}</span>
<span className="buttons">
<button onClick={handleAdd}>▲</button>
<button onClick={handleRemove}>▼</button>
</span>
</span>
</div>
<hr />
<Total quantity={count} isPending={isPending}/>
</div>
);
}
async function updateCartAction(prevCount, actionPayload) {
switch (actionPayload.type) {
case 'ADD': {
return await addToCart(prevCount);
}
case 'REMOVE': {
return await removeFromCart(prevCount);
}
}
return prevCount;
}
js src/Total.js
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
});
export default function Total({quantity, isPending}) {
return (
<div className="row total">
<span>Total</span>
{isPending ? '🌀 Updating...' : formatter.format(quantity * 9999)}
</div>
);
}
js src/api.js hidden
export async function addToCart(count) {
await new Promise(resolve => setTimeout(resolve, 1000));
return count + 1;
}
export async function removeFromCart(count) {
await new Promise(resolve => setTimeout(resolve, 1000));
return Math.max(0, count - 1);
}
css
.checkout {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid #ccc;
border-radius: 8px;
font-family: system-ui;
}
.checkout h2 {
margin: 0 0 8px 0;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
}
.stepper {
display: flex;
align-items: center;
gap: 8px;
}
.qty {
min-width: 20px;
text-align: center;
}
.buttons {
display: flex;
flex-direction: column;
gap: 2px;
}
.buttons button {
padding: 0 8px;
font-size: 10px;
line-height: 1.2;
cursor: pointer;
}
.pending {
width: 20px;
text-align: center;
}
.total {
font-weight: bold;
}
hr {
width: 100%;
border: none;
border-top: 1px solid #ccc;
margin: 4px 0;
}
当你点击增加或减少数量时,会派发一个 "ADD" 或 "REMOVE" 操作。在 reducerAction 中,调用不同的 API 来更新数量。
在此示例中,我们使用 Actions 的 pending 状态来替换数量和总数。如果你想提供即时反馈,例如立即更新数量,可以使用 useOptimistic。
<深入探讨>
useActionState 与 useReducer 有何不同? {/useactionstate-vs-usereducer/}
你可能会注意到此示例看起来很像 useReducer,但它们的用途不同:
-
使用
useReducer来管理 UI 的 state。reducer 必须是纯函数。 -
使用
useActionState来管理 Actions 的 state。reducer 可以执行副作用。
你可以将 useActionState 视为用于用户 Action 副作用的 useReducer。由于它基于之前的 Action 来计算下一个要执行的 Action,因此必须按顺序调用。如果你想并行执行 Actions,请直接使用 useState 和 useTransition。
</深入探讨>
与 useOptimistic 结合使用 {/using-with-useoptimistic/}
你可以将 useActionState 与 useOptimistic 结合使用,以显示即时 UI 反馈:
js src/App.js
import { useActionState, startTransition, useOptimistic } from 'react';
import { addToCart, removeFromCart } from './api';
import Total from './Total';
export default function Checkout() {
const [count, dispatchAction, isPending] = useActionState(updateCartAction, 0);
const [optimisticCount, setOptimisticCount] = useOptimistic(count);
function handleAdd() {
startTransition(() => {
setOptimisticCount(c => c + 1);
dispatchAction({ type: 'ADD' });
});
}
function handleRemove() {
startTransition(() => {
setOptimisticCount(c => c - 1);
dispatchAction({ type: 'REMOVE' });
});
}
return (
<div className="checkout">
<h2>Checkout</h2>
<div className="row">
<span>Eras Tour Tickets</span>
<span className="stepper">
<span className="pending">{isPending && '🌀'}</span>
<span className="qty">{optimisticCount}</span>
<span className="buttons">
<button onClick={handleAdd}>▲</button>
<button onClick={handleRemove}>▼</button>
</span>
</span>
</div>
<hr />
<Total quantity={optimisticCount} isPending={isPending}/>
</div>
);
}
async function updateCartAction(prevCount, actionPayload) {
switch (actionPayload.type) {
case 'ADD': {
return await addToCart(prevCount);
}
case 'REMOVE': {
return await removeFromCart(prevCount);
}
}
return prevCount;
}
js src/Total.js
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
});
export default function Total({quantity, isPending}) {
return (
<div className="row total">
<span>Total</span>
<span>{isPending ? '🌀 Updating...' : formatter.format(quantity * 9999)}</span>
</div>
);
}
js src/api.js hidden
export async function addToCart(count) {
await new Promise(resolve => setTimeout(resolve, 1000));
return count + 1;
}
export async function removeFromCart(count) {
await new Promise(resolve => setTimeout(resolve, 1000));
return Math.max(0, count - 1);
}
css
.checkout {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid #ccc;
border-radius: 8px;
font-family: system-ui;
}
.checkout h2 {
margin: 0 0 8px 0;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
}
.stepper {
display: flex;
align-items: center;
gap: 8px;
}
.qty {
min-width: 20px;
text-align: center;
}
.buttons {
display: flex;
flex-direction: column;
gap: 2px;
}
.buttons button {
padding: 0 8px;
font-size: 10px;
line-height: 1.2;
cursor: pointer;
}
.pending {
width: 20px;
text-align: center;
}
.total {
font-weight: bold;
}
hr {
width: 100%;
border: none;
border-top: 1px solid #ccc;
margin: 4px 0;
}
setOptimisticCount 会立即更新数量,而 dispatchAction() 会将 updateCartAction 排队。数量和总数上都会出现 pending 指示器,以告知用户他们的更新仍在应用中。
与 Action props 结合使用 {/using-with-action-props/}
当你将 dispatchAction 函数传递给一个暴露了 Action prop 的组件时,你不需要自己调用 startTransition 或 useOptimistic。
此示例展示了如何使用 QuantityStepper 组件的 increaseAction 和 decreaseAction props:
js src/App.js
import { useActionState } from 'react';
import { addToCart, removeFromCart } from './api';
import QuantityStepper from './QuantityStepper';
import Total from './Total';
export default function Checkout() {
const [count, dispatchAction, isPending] = useActionState(updateCartAction, 0);
function addAction() {
dispatchAction({type: 'ADD'});
}
function removeAction() {
dispatchAction({type: 'REMOVE'});
}
return (
<div className="checkout">
<h2>Checkout</h2>
<div className="row">
<span>Eras Tour Tickets</span>
<QuantityStepper
value={count}
increaseAction={addAction}
decreaseAction={removeAction}
/>
</div>
<hr />
<Total quantity={count} isPending={isPending} />
</div>
);
}
async function updateCartAction(prevCount, actionPayload) {
switch (actionPayload.type) {
case 'ADD': {
return await addToCart(prevCount);
}
case 'REMOVE': {
return await removeFromCart(prevCount);
}
}
return prevCount;
}
js src/QuantityStepper.js
import { startTransition, useOptimistic } from 'react';
export default function QuantityStepper({value, increaseAction, decreaseAction}) {
const [optimisticValue, setOptimisticValue] = useOptimistic(value);
const isPending = value !== optimisticValue;
function handleIncrease() {
startTransition(async () => {
setOptimisticValue(c => c + 1);
await increaseAction();
});
}
function handleDecrease() {
startTransition(async () => {
setOptimisticValue(c => Math.max(0, c - 1));
await decreaseAction();
});
}
return (
<span className="stepper">
<span className="pending">{isPending && '🌀'}</span>
<span className="qty">{optimisticValue}</span>
<span className="buttons">
<button onClick={handleIncrease}>▲</button>
<button onClick={handleDecrease}>▼</button>
</span>
</span>
);
}
js src/Total.js
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
});
export default function Total({quantity, isPending}) {
return (
<div className="row total">
<span>Total</span>
{isPending ? '🌀 Updating...' : formatter.format(quantity * 9999)}
</div>
);
}
js src/api.js hidden
export async function addToCart(count) {
await new Promise(resolve => setTimeout(resolve, 1000));
return count + 1;
}
export async function removeFromCart(count) {
await new Promise(resolve => setTimeout(resolve, 1000));
return Math.max(0, count - 1);
}
css
.checkout {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid #ccc;
border-radius: 8px;
font-family: system-ui;
}
.checkout h2 {
margin: 0 0 8px 0;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
}
.stepper {
display: flex;
align-items: center;
gap: 8px;
}
.qty {
min-width: 20px;
text-align: center;
}
.buttons {
display: flex;
flex-direction: column;
gap: 2px;
}
.buttons button {
padding: 0 8px;
font-size: 10px;
line-height: 1.2;
cursor: pointer;
}
.pending {
width: 20px;
text-align: center;
}
.total {
font-weight: bold;
}
hr {
width: 100%;
border: none;
border-top: 1px solid #ccc;
margin: 4px 0;
}
由于 <QuantityStepper> 内置了对 transitions、pending state 和乐观更新计数的支持,你只需要告诉 Action 要更改什么,如何更改则由它为你处理。
取消已排队的 Actions {/cancelling-queued-actions/}
你可以使用 AbortController 来取消 pending 状态的 Actions:
js src/App.js
import { useActionState, useRef } from 'react';
import { addToCart, removeFromCart } from './api';
import QuantityStepper from './QuantityStepper';
import Total from './Total';
export default function Checkout() {
const abortRef = useRef(null);
const [count, dispatchAction, isPending] = useActionState(updateCartAction, 0);
async function addAction() {
if (abortRef.current) {
abortRef.current.abort();
}
abortRef.current = new AbortController();
await dispatchAction({ type: 'ADD', signal: abortRef.current.signal });
}
async function removeAction() {
if (abortRef.current) {
abortRef.current.abort();
}
abortRef.current = new AbortController();
await dispatchAction({ type: 'REMOVE', signal: abortRef.current.signal });
}
return (
<div className="checkout">
<h2>Checkout</h2>
<div className="row">
<span>Eras Tour Tickets</span>
<QuantityStepper
value={count}
increaseAction={addAction}
decreaseAction={removeAction}
/>
</div>
<hr />
<Total quantity={count} isPending={isPending} />
</div>
);
}
async function updateCartAction(prevCount, actionPayload) {
switch (actionPayload.type) {
case 'ADD': {
try {
return await addToCart(prevCount, { signal: actionPayload.signal });
} catch (e) {
return prevCount + 1;
}
}
case 'REMOVE': {
try {
return await removeFromCart(prevCount, { signal: actionPayload.signal });
} catch (e) {
return Math.max(0, prevCount - 1);
}
}
}
return prevCount;
}
js src/QuantityStepper.js
import { startTransition, useOptimistic } from 'react';
export default function QuantityStepper({value, increaseAction, decreaseAction}) {
const [optimisticValue, setOptimisticValue] = useOptimistic(value);
const isPending = value !== optimisticValue;
function handleIncrease() {
startTransition(async () => {
setOptimisticValue(c => c + 1);
await increaseAction();
});
}
function handleDecrease() {
startTransition(async () => {
setOptimisticValue(c => Math.max(0, c - 1));
await decreaseAction();
});
}
return (
<span className="stepper">
<span className="pending">{isPending && '🌀'}</span>
<span className="qty">{optimisticValue}</span>
<span className="buttons">
<button onClick={handleIncrease}>▲</button>
<button onClick={handleDecrease}>▼</button>
</span>
</span>
);
}
js src/Total.js
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
});
export default function Total({quantity, isPending}) {
return (
<div className="row total">
<span>Total</span>
{isPending ? '🌀 Updating...' : formatter.format(quantity * 9999)}
</div>
);
}
js src/api.js hidden
class AbortError extends Error {
name = 'AbortError';
constructor(message = 'The operation was aborted') {
super(message);
}
}
function sleep(ms, signal) {
if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
if (signal.aborted) return Promise.reject(new AbortError());
return new Promise((resolve, reject) => {
const id = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(id);
reject(new AbortError());
};
signal.addEventListener('abort', onAbort, { once: true });
});
}
export async function addToCart(count, opts) {
await sleep(1000, opts?.signal);
return count + 1;
}
export async function removeFromCart(count, opts) {
await sleep(1000, opts?.signal);
return Math.max(0, count - 1);
}
css
.checkout {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid #ccc;
border-radius: 8px;
font-family: system-ui;
}
.checkout h2 {
margin: 0 0 8px 0;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
}
.stepper {
display: flex;
align-items: center;
gap: 8px;
}
.qty {
min-width: 20px;
text-align: center;
}
.buttons {
display: flex;
flex-direction: column;
gap: 2px;
}
.buttons button {
padding: 0 8px;
font-size: 10px;
line-height: 1.2;
cursor: pointer;
}
.pending {
width: 20px;
text-align: center;
}
.total {
font-weight: bold;
}
hr {
width: 100%;
border: none;
border-top: 1px solid #ccc;
margin: 4px 0;
}
尝试多次点击增加或减少,并注意无论你点击多少次,总数都会在 1 秒内更新。这是因为使用 AbortController 来“完成”之前的 Action,以便下一个 Action 可以继续进行。
<陷阱>
取消一个 Action 并不总是安全的。
例如,如果 Action 执行了一个 mutation(例如写入数据库),中止网络请求并不会撤销服务器端的更改。这就是为什么 useActionState 默认不会中止的原因。只有当你确定副作用可以被安全地忽略或重试时,这样做才是安全的。
</陷阱>
与 <form> Action props 结合使用 {/use-with-a-form/}
你可以将 dispatchAction 函数作为 action prop 传递给 <form>。
以这种方式使用时,React 会自动将提交包装在 Transition 中,因此你无需自己调用 startTransition。reducerAction 会接收之前的 state 和提交的 FormData:
js src/App.js
import { useActionState, useOptimistic } from 'react';
import { addToCart, removeFromCart } from './api';
import Total from './Total';
export default function Checkout() {
const [count, dispatchAction, isPending] = useActionState(updateCartAction, 0);
const [optimisticCount, setOptimisticCount] = useOptimistic(count);
async function formAction(formData) {
const type = formData.get('type');
if (type === 'ADD') {
setOptimisticCount(c => c + 1);
} else {
setOptimisticCount(c => Math.max(0, c - 1));
}
return dispatchAction(formData);
}
return (
<form action={formAction} className="checkout">
<h2>Checkout</h2>
<div className="row">
<span>Eras Tour Tickets</span>
<span className="stepper">
<span className="pending">{isPending && '🌀'}</span>
<span className="qty">{optimisticCount}</span>
<span className="buttons">
<button type="submit" name="type" value="ADD">▲</button>
<button type="submit" name="type" value="REMOVE">▼</button>
</span>
</span>
</div>
<hr />
<Total quantity={count} isPending={isPending} />
</form>
);
}
async function updateCartAction(prevCount, formData) {
const type = formData.get('type');
switch (type) {
case 'ADD': {
return await addToCart(prevCount);
}
case 'REMOVE': {
return await removeFromCart(prevCount);
}
}
return prevCount;
}
js src/Total.js
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
});
export default function Total({quantity, isPending}) {
return (
<div className="row total">
<span>Total</span>
{isPending ? '🌀 Updating...' : formatter.format(quantity * 9999)}
</div>
);
}
js src/api.js hidden
export async function addToCart(count) {
await new Promise(resolve => setTimeout(resolve, 1000));
return count + 1;
}
export async function removeFromCart(count) {
await new Promise(resolve => setTimeout(resolve, 1000));
return Math.max(0, count - 1);
}
css
.checkout {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid #ccc;
border-radius: 8px;
font-family: system-ui;
}
.checkout h2 {
margin: 0 0 8px 0;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
}
.stepper {
display: flex;
align-items: center;
gap: 8px;
}
.qty {
min-width: 20px;
text-align: center;
}
.buttons {
display: flex;
flex-direction: column;
gap: 2px;
}
.buttons button {
padding: 0 8px;
font-size: 10px;
line-height: 1.2;
cursor: pointer;
}
.pending {
width: 20px;
text-align: center;
}
.total {
font-weight: bold;
}
hr {
width: 100%;
border: none;
border-top: 1px solid #ccc;
margin: 4px 0;
}
在此示例中,当用户点击步进器箭头时,按钮会提交表单,useActionState 会使用表单数据调用 updateCartAction。该示例使用 useOptimistic 在服务器确认更新时立即显示新的数量。
当与 服务器函数 结合使用时,useActionState 允许在 hydration(React 附加到服务器渲染的 HTML 上)完成之前显示服务器的响应。你还可以在具有动态内容的页面上使用可选的 permalink 参数来实现渐进增强(允许表单在 JavaScript 加载之前工作)。这通常由你的框架为你处理。
有关将 Actions 与表单结合使用的更多信息,请参阅 <form> 文档。
处理错误 {/handling-errors/}
使用 useActionState 有两种处理错误的方法。
对于已知错误,例如来自后端的“数量不可用”验证错误,你可以将其作为 reducerAction state 的一部分返回,并在 UI 中显示。
对于未知错误,例如 undefined is not a function,你可以抛出错误。React 将取消所有已排队的 Actions,并通过 useActionState 钩子重新抛出错误,显示最近的错误边界。
js src/App.js
import {useActionState, startTransition} from 'react';
import {ErrorBoundary} from 'react-error-boundary';
import {addToCart} from './api';
import Total from './Total';
function Checkout() {
const [state, dispatchAction, isPending] = useActionState(
async (prevState, quantity) => {
const result = await addToCart(prevState.count, quantity);
if (result.error) {
// Return the error from the API as state
return {...prevState, error: `Could not add quanitiy ${quantity}: ${result.error}`};
}
if (!isPending) {
// Clear the error state for the first dispatch.
return {count: result.count, error: null};
}
// Return the new count, and any errors that happened.
return {count: result.count, error: prevState.error};
},
{
count: 0,
error: null,
}
);
function handleAdd(quantity) {
startTransition(() => {
dispatchAction(quantity);
});
}
return (
<div className="checkout">
<h2>Checkout</h2>
<div className="row">
<span>Eras Tour Tickets</span>
<span>
{isPending && '🌀 '}Qty: {state.count}
</span>
</div>
<div className="buttons">
<button onClick={() => handleAdd(1)}>Add 1</button>
<button onClick={() => handleAdd(10)}>Add 10</button>
<button onClick={() => handleAdd(NaN)}>Add NaN</button>
</div>
{state.error && <div className="error">{state.error}</div>}
<hr />
<Total quantity={state.count} isPending={isPending} />
</div>
);
}
export default function App() {
return (
<ErrorBoundary
fallbackRender={({resetErrorBoundary}) => (
<div className="checkout">
<h2>Something went wrong</h2>
<p>The action could not be completed.</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)}>
<Checkout />
</ErrorBoundary>
);
}
js src/Total.js
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
});
export default function Total({quantity, isPending}) {
return (
<div className="row total">
<span>Total</span>
<span>
{isPending ? '🌀 Updating...' : formatter.format(quantity * 9999)}
</span>
</div>
);
}
js src/api.js hidden
export async function addToCart(count, quantity) {
await new Promise((resolve) => setTimeout(resolve, 1000));
if (quantity > 5) {
return {error: 'Quantity not available'};
} else if (isNaN(quantity)) {
throw new Error('Quantity must be a number');
}
return {count: count + quantity};
}
css
.checkout {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid #ccc;
border-radius: 8px;
font-family: system-ui;
}
.checkout h2 {
margin: 0 0 8px 0;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
}
.total {
font-weight: bold;
}
hr {
width: 100%;
border: none;
border-top: 1px solid #ccc;
margin: 4px 0;
}
button {
padding: 8px 16px;
cursor: pointer;
}
.buttons {
display: flex;
gap: 8px;
}
.error {
color: red;
font-size: 14px;
}
json package.json hidden
{
"dependencies": {
"react": "19.0.0",
"react-dom": "19.0.0",
"react-scripts": "^5.0.0",
"react-error-boundary": "4.0.3"
},
"main": "/index.js"
}
在此示例中,"Add 10" 模拟了一个返回验证错误的 API,updateCartAction 将其存储在 state 中并内联显示。"Add NaN" 导致计数无效,因此 updateCartAction 抛出错误,该错误通过 useActionState 传播到 ErrorBoundary 并显示重置 UI。
疑难解答 {/troubleshooting/}
我的 isPending 标志没有更新 {/ispending-not-updating/}
如果你手动调用 dispatchAction(而不是通过 Action prop),请确保将调用包装在 startTransition 中:
js
import { useActionState, startTransition } from 'react';
function MyComponent() {
const [state, dispatchAction, isPending] = useActionState(myAction, null);
function handleClick() {
// ✅ 正确:包装在 startTransition 中
startTransition(() => {
dispatchAction();
});
}
// ...
}
当 dispatchAction 传递给 Action prop 时,React 会自动将其包装在 Transition 中。
我的 Action 无法读取表单数据 {/action-cannot-read-form-data/}
当你使用 useActionState 时,reducerAction 会接收一个额外的参数作为其第一个参数:之前的或初始的 state。因此,提交的表单数据是它的第二个参数,而不是第一个。
js {2,7}
// 没有使用 useActionState
function action(formData) {
const name = formData.get('name');
}
// 使用了 useActionState
function action(prevState, formData) {
const name = formData.get('name');
}
我的 Actions 被跳过了 {/actions-skipped/}
如果你多次调用 dispatchAction 但其中一些没有运行,可能是因为更早的 dispatchAction 调用抛出了错误。
当 reducerAction 抛出错误时,React 会跳过所有随后排队的 dispatchAction 调用。
要处理此问题,请在 reducerAction 中捕获错误并返回错误状态,而不是抛出错误:
js
async function myReducerAction(prevState, data) {
try {
const result = await submitData(data);
return { success: true, data: result };
} catch (error) {
// ✅ 返回错误状态而不是抛出错误
return { success: false, error: error.message };
}
}
我的 state 没有重置 {/reset-state/}
useActionState 没有提供内置的重置函数。要重置 state,你可以设计你的 reducerAction 来处理重置信号:
js
const initialState = { name: '', error: null };
async function formAction(prevState, payload) {
// 处理重置
if (payload === null) {
return initialState;
}
// 正常的 action 逻辑
const result = await submitData(payload);
return result;
}
function MyComponent() {
const [state, dispatchAction, isPending] = useActionState(formAction, initialState);
function handleReset() {
startTransition(() => {
dispatchAction(null); // 传递 null 以触发重置
});
}
// ...
}
或者,你可以为使用 useActionState 的组件添加一个 key prop,以强制其使用新的 state 重新挂载,或者使用 <form> 的 action prop,它会在提交后自动重置。
我收到了一个错误:“An async function with useActionState was called outside of a transition。” {/async-function-outside-transition/}
一个常见的错误是忘记从 Transition 内部调用 dispatchAction:
<控制台块层级="error">
An async function with useActionState was called outside of a transition. This is likely not what you intended (for example, isPending will not update correctly). Either call the returned function inside startTransition, or pass it to an action or formAction prop.
</控制台块>
Ac这个错误的发生是因为 dispatchAction 必须在 Transition 内部运行:
js
function MyComponent() {
const [state, dispatchAction, isPending] = useActionState(myAsyncAction, null);
function handleClick() {
// ❌ 错误:在 Transition 外部调用 dispatchAction
dispatchAction();
}
// ...
}
要修复此问题,请将调用包装在 startTransition 中:
js
import { useActionState, startTransition } from 'react';
function MyComponent() {
const [state, dispatchAction, isPending] = useActionState(myAsyncAction, null);
function handleClick() {
// ✅ 正确:包装在 startTransition 中
startTransition(() => {
dispatchAction();
});
}
// ...
}
或者将 dispatchAction 传递给 Action prop,它会在 Transition 中调用:
js
function MyComponent() {
const [state, dispatchAction, isPending] = useActionState(myAsyncAction, null);
// ✅ 正确:action prop 会为你包装在 Transition 中
return <Button action={dispatchAction}>...</Button>;
}
我收到了一个错误: “Cannot update action state while rendering” {/cannot-update-during-render/}
你不能在渲染期间调用 dispatchAction:
<控制台块层级="error">
Cannot update action state while rendering.
</控制台块>
这会导致无限循环,因为调用 dispatchAction 会安排一个 state 更新,从而触发重新渲染,而重新渲染又会再次调用 dispatchAction。
js
function MyComponent() {
const [state, dispatchAction, isPending] = useActionState(myAction, null);
// ❌ 错误:在渲染期间调用 dispatchAction
dispatchAction();
// ...
}
要修复此问题,只在响应用户事件(如表单提交或按钮点击)时调用 dispatchAction。
帮助我们改进文档
发现翻译问题或内容错误?请告诉我们。
