useActionStateのServer Actions実行制御の仕組み
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を追加してすぐに実行している
// 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をキューの最後に追加している
// 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でカウンターを実装してみます。
"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を連続でクリックした際のキューイングの流れはざっくり以下になります。
-
useActionState
管理のキューにタスクが追加される -
useActionState
管理のキューのタスクが1つ解決されると、App Router管理のキューにタスク積まれる - App Router管理のキューでServer Actionが直列実行される
-
useActionState
管理のキューで3のServer Actionの実行結果を元にState を更新してから次の タスクをApp Router管理のキューに積む - 以降はタスクがなくなるまで3〜4を繰り返す
上図からわかるように最終的には Next.js の App Router キューへタスク追加されますが、先んじてuseActionState
内部でキューで管理される形になります。
実際にuseActionState
管理のキューへの追加が行われている実装は以下部分になります。
↓キューが空の場合はactionを追加してすぐに実行している
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をキューの最後に追加している
// 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
内でで次のアクションを実行しています。
// 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),
),
);
参考
Discussion