知海

useActionState()

ReactAPI 参考:React 核心

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,之后是它上一次的返回值)作为第一个参数,然后是传递给 dispatchActionactionPayload 作为第二个参数。
  • initialState:你希望 state 初始时的值。在 dispatchAction 首次被调用后,React 会忽略此参数。
  • 可选 permalink:一个包含此表单所修改页面的唯一 URL 的字符串。
    • 用于带有React 服务器组件的页面,以支持渐进增强。
    • 如果 reducerAction 是一个服务器函数,并且表单在 JavaScript bundle 加载完成之前被提交,浏览器将导航到指定的 permalink URL,而不是当前页面的 URL。

返回值 {/returns/}

useActionState 返回一个包含三个值的数组:

  1. 当前的 state。在首次渲染期间,该值等于你传入的 initialState 参数。在 dispatchAction 被调用后,该值会变为 reducerAction 的返回值。
  2. 一个 dispatchAction 函数,可以在 Actions 中调用。
  3. 一个 isPending 标识,用于表明当前 Actions 是否处于 pending 状态。

注意 {/caveats/}

  • useActionState 是一个 Hook,所以你只能在组件的顶层或自定义 Hook 中调用它。你不能在循环或条件语句中调用它。如果你需要这样做,请提取一个新组件并将 state 移入其中。
  • React 会按顺序对多次 dispatchAction 调用进行排队并执行。每次 reducerAction 调用都会接收上一次调用的结果。
  • dispatchAction 函数具有稳定的标识,因此你通常会看到它被从 Effect 的依赖项中省略,但包含它不会导致 Effect 触发。如果 linter 允许你省略依赖项而不报错,那么这样做是安全的。了解更多关于移除 Effect 依赖的信息。
  • 使用 permalink 选项时,请确保目标页面渲染了相同的表单组件(包括相同的 reducerActionpermalink),以便 React 知道如何传递 state。一旦页面变为可交互状态,此参数将不再起作用。
  • 使用服务器函数时,initialState 需要是 可序列化的(例如普通对象、数组、字符串和数字等值)。
  • 如果 dispatchAction 抛出错误,React 会取消所有已排队的 Actions,并显示最近的 错误边界
  • 如果有多个正在进行的 Actions,React 会将它们分批处理。这是一个限制,可能会在未来的版本中移除。

<注意>

dispatchAction 必须从 Action 中调用。

你可以将其包装在 startTransition 中,或者将其传递给 Action prop。在该作用域之外调用将不会被视作 Transition 的一部分,并且在开发模式下会记录错误

</注意>


reducerAction 函数 {/reduceraction/}

传递给 useActionStatereducerAction 函数接收上一个 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 返回一个包含以下三个值的数组:

  1. 该表单的 当前 state,初始值是传入的参数。
  2. 该表单的 action 调度函数,用于让你触发 reducerAction
  3. 一个 pending state,用于指示 Action 是否还在执行中。

要调用 addToCartAction,请调用 action 调度函数。React 将使用之前的 count 对 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

<深入探讨>

useActionStateuseReducer 有何不同? {/useactionstate-vs-usereducer/}

你可能会注意到此示例看起来很像 useReducer,但它们的用途不同:

  • 使用 useReducer 来管理 UI 的 state。reducer 必须是纯函数。

  • 使用 useActionState 来管理 Actions 的 state。reducer 可以执行副作用。

你可以将 useActionState 视为用于用户 Action 副作用的 useReducer。由于它基于之前的 Action 来计算下一个要执行的 Action,因此必须按顺序调用。如果你想并行执行 Actions,请直接使用 useStateuseTransition

</深入探讨>


useOptimistic 结合使用 {/using-with-useoptimistic/}

你可以将 useActionStateuseOptimistic 结合使用,以显示即时 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 的组件时,你不需要自己调用 startTransitionuseOptimistic

此示例展示了如何使用 QuantityStepper 组件的 increaseActiondecreaseAction 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 中,因此你无需自己调用 startTransitionreducerAction 会接收之前的 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

帮助我们改进文档

发现翻译问题或内容错误?请告诉我们。