⚙️

useActionStateのServer Actions実行制御の仕組み

2024/06/24に公開

React 19で導入されたuseActionStateは、アクションを単に実行するだけでなく、アクションの結果を取り扱いたいときに便利なAPIです。
加えて、useActionStateでは内部でキューを管理しており、Server Actionの実行タスクをフレームワーク側に依頼するタイミングが制御されています。

この記事ではuseActionState内部で行われているキューイング制御をServer Actionの挙動も合わせて整理しようと思います。

Server Actionsは直列実行される

前提として、Next.jsのServer Actionは「同時に実行可能なServer Actionは常に1つだけ」でという制約があります。(直列に実行され、複数のServer Actionを並列に実行しないことを意味します。)

Next.js の内部を見てみると、/src/shared/lib/router/action-queue.tsで複数のactionを順次処理するキューを管理する機能を提供するモジュールが実装されています。

↓キューが空の場合はactionを追加してすぐに実行している
https://github.com/vercel/next.js/blob/f52715ba5bfc2b0759b237de0e9487f4aa6cd88d/packages/next/src/shared/lib/router/action-queue.ts#L148-L158

// Check if the queue is empty
if (actionQueue.pending === null) {
  // The queue is empty, so add the action and start it immediately
  // Mark this action as the last in the queue
  actionQueue.last = newAction;

  runAction({
    actionQueue,
    action: newAction,
    setState,
  });
}

↓既に実行中のタスクが存在している場合は、actionをキューの最後に追加している

https://github.com/vercel/next.js/blob/v15.0.0-rc.0/packages/next/src/shared/lib/router/action-queue.ts#L181-L186

// The queue is not empty, so add the action to the end of the queue
// It will be started by runRemainingActions after the previous action finishes
if (actionQueue.last !== null) {
  actionQueue.last.next = newAction;
}
actionQueue.last = newAction;

Server Actionが複数実行された際のApp Router管理のキューの状態を図にすると下記のようなイメージになると思います。

useActionStateではServer Actionsの実行制御が行われている

useActionStateではhook内でキューを管理しており、Server Actionの実行タスクをフレームワーク(Next.js)側に依頼するタイミングが制御されています。
これにより、複数のアクションが実行された際の状態のズレを防ぎ、安全に状態の更新が行われます。

例として、useActionState、Server Actionでカウンターを実装してみます。

actions/increment.ts
"use server";

import { setTimeout } from "node:timers/promises";

export async function incrementCount(count: number) {
  await setTimeout(600);
  return count + 1;
}
"use client";

import { useActionState } from "react";
import { incrementCount } from "./actions";

export default function App() {
  const [count, formAction] = useActionState(incrementCount, 0);

  return (
    <form action={formAction}>
      Count: {count}
      <button>Increment</button>
    </form>
  );
}

上図のネットワークタイムラインから、Server Action が直列に実行されており、Server Action 実行回数と useActionState で管理している count 値(画面上のcount)にズレが生じていないこともわかります。

Incrementを連続でクリックした際のキューイングの流れはざっくり以下になります。

  1. useActionState管理のキューにタスクが追加される
  2. useActionState管理のキューのタスクが1つ解決されると、App Router管理のキューにタスク積まれる
  3. App Router管理のキューでServer Actionが直列実行される
  4. useActionState管理のキューで3のServer Actionの実行結果を元にState を更新してから次の タスクをApp Router管理のキューに積む
  5. 以降はタスクがなくなるまで3〜4を繰り返す

上図からわかるように最終的には Next.js の App Router キューへタスク追加されますが、先んじてuseActionState内部でキューで管理される形になります。

実際にuseActionState管理のキューへの追加が行われている実装は以下部分になります。

↓キューが空の場合はactionを追加してすぐに実行している
https://github.com/facebook/react/blob/ee5c19493086fdeb32057e16d1e3414370242307/packages/react-reconciler/src/ReactFiberHooks.js#L1983-L1998

if (last === null) {
  // There are no pending actions; this is the first one. We can run
  // it immediately.
  const newLast: ActionStateQueueNode<P> = {
    payload,
    next: (null: any), // circular
  };
  newLast.next = actionQueue.pending = newLast;

  runActionStateAction(
    actionQueue,
    (setPendingState: any),
    (setState: any),
    payload,
  );
}

↓既に実行中のタスクが存在している場合は、actionをキューの最後に追加している
https://github.com/facebook/react/blob/ee5c19493086fdeb32057e16d1e3414370242307/packages/react-reconciler/src/ReactFiberHooks.js#L1999-L2005

// There's already an action running. Add to the queue.
const first = last.next;
const newLast: ActionStateQueueNode<P> = {
  payload,
  next: first,
};
actionQueue.pending = last.next = newLast;

useActionState 管理のキューは、キュー自体が State を参照する構造となっており、実行中のServer Actionが完了したときに、その結果で State を更新してから次のServer Actionを実行するようになっています。 結果として Server Action 側には都度最新の State が伝わることになります。

少しややこしいですが、state更新時のキューの状態は下図のようなイメージになると思います。

以下の箇所でactionの戻り値を読み取り、Stateの更新とfinishRunningActionStateAction内でで次のアクションを実行しています。

https://github.com/facebook/react/blob/ee5c19493086fdeb32057e16d1e3414370242307/packages/react-reconciler/src/ReactFiberHooks.js#L2044-L2061

// Attach a listener to read the return state of the action. As soon as
// this resolves, we can run the next action in the sequence.
thenable.then(
  (nextState: Awaited<S>) => {
    actionQueue.state = nextState;
    finishRunningActionStateAction(
      actionQueue,
      (setPendingState: any),
      (setState: any),
    );
  },
  () =>
    finishRunningActionStateAction(
      actionQueue,
      (setPendingState: any),
      (setState: any),
    ),
);

参考

https://react.dev/reference/react/useActionState

https://github.com/facebook/react

https://github.com/vercel/next.js/issues/64396

https://quramy.medium.com/server-actions-の同時実行制御と画面の状態更新-35acf5d825ca

https://stackoverflow.com/questions/77484983/why-are-server-actions-not-executing-concurrently

frontend flat

Discussion