React 19 で登場した useActionState は、フォームアクションの結果に基づいて state を更新するためのフックです。従来の useFormState(React DOM の一部)から名前が変更され、React 本体に統合されました。
💡 補足
React Canary の以前のバージョンでは、この API は React DOM の一部であり useFormState という名前でした。
1. なぜ useActionState が必要か
1.1 フォーム処理の課題
React でフォームを扱う場合、従来は以下のような課題がありました:
- 状態管理の複雑さ: フォームの送信状態、エラー状態、成功状態を別々に管理する必要がある
-
非同期処理の扱い:
async/awaitを使った送信処理とUIの同期が煩雑 - サーバーアクションとの連携: Server Components との統合が難しい
- プログレッシブエンハンスメント: JavaScript が読み込まれる前のフォーム送信への対応
// ❌ 従来のフォーム処理(状態が分散)
function OldForm() {
const [result, setResult] = useState(null);
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
setIsPending(true);
setError(null);
try {
const formData = new FormData(e.target);
const result = await submitForm(formData);
setResult(result);
} catch (err) {
setError(err.message);
} finally {
setIsPending(false);
}
};
return <form onSubmit={handleSubmit}>...</form>;
}
// ✅ useActionState を使用(シンプルで統一的)
function NewForm() {
const [state, formAction, isPending] = useActionState(submitAction, null);
return (
<form action={formAction}>
{state?.error && <p>{state.error}</p>}
<button disabled={isPending}>送信</button>
</form>
);
}
1.2 useActionState の3つの特徴
| 特徴 | 説明 |
|---|---|
| フォーム state の統一管理 | アクションの結果に基づいて state を自動更新 |
| pending 状態の自動追跡 |
isPending でアクションの進行状態を取得可能 |
| SSR 対応 | Server Components と連携し、JavaScript 読み込み前でも動作 |
1.3 useActionState の API
const [state, formAction, isPending] = useActionState(fn, initialState, permalink?);
| 引数 | 説明 |
|---|---|
fn |
フォーム送信時に呼び出されるアクション関数。第1引数に前回の state、第2引数にフォームデータを受け取る |
initialState |
state の初期値。シリアライズ可能な任意の値 |
permalink |
省略可能。プログレッシブエンハンスメント用のURL |
| 返り値 | 説明 |
|---|---|
state |
現在の state。初回は initialState、以降はアクションの返り値 |
formAction |
<form> の action プロパティに渡す新しいアクション |
isPending |
アクションが処理中かどうかを表すフラグ |
💡 ポイント
アクション関数のシグネチャは通常のフォームアクションと異なります。第1引数が「前回の state」になるため、フォームデータは第2引数で受け取ります。
2. useActionState の内部構造を徹底解剖
useActionState を使うたびに、React の内部では複数のモジュールが連携して動いています。この章では、facebook/react リポジトリの実際のコードを追いながら、その動作原理を解説します。
2.0 全体像: useActionState が動く仕組み
🎣 useActionState(フック呼び出し)
↓
📝 3つの Hook を内部で作成
├─ stateHook: アクション結果の state
├─ pendingStateHook: pending 状態
└─ actionQueueHook: アクションキュー
↓
⚡ dispatchActionState でアクション実行
↓
🔄 順次実行キューで非同期処理を管理
重要なポイント:useActionState は内部で複数の Hook を組み合わせ、アクションを順次実行するキューを管理しています!
2.1 エントリポイント: packages/react/src/ReactHooks.js
// packages/react/src/ReactHooks.js
export function useActionState<S, P>(
action: (Awaited<S>, P) => S,
initialState: Awaited<S>,
permalink?: string,
): [Awaited<S>, (P) => void, boolean] {
const dispatcher = resolveDispatcher();
return dispatcher.useActionState(action, initialState, permalink);
}
2.2 コア実装: mountActionState
初回レンダー時の処理
// packages/react-reconciler/src/ReactFiberHooks.js
function actionStateReducer<S>(oldState: S, newState: S): S {
return newState;
}
function mountActionState<S, P>(
action: (Awaited<S>, P) => S,
initialStateProp: Awaited<S>,
permalink?: string,
): [Awaited<S>, (P) => void, boolean] {
let initialState: Awaited<S> = initialStateProp;
if (getIsHydrating()) {
const root: FiberRoot = (getWorkInProgressRoot(): any);
const ssrFormState = root.formState;
// If a formState option was passed to the root, there are form state
// markers that we need to hydrate. These indicate whether the form state
// matches this hook instance.
if (ssrFormState !== null) {
const isMatching = tryToClaimNextHydratableFormMarkerInstance(
currentlyRenderingFiber,
);
if (isMatching) {
initialState = ssrFormState[0];
}
}
}
// State hook. The state is stored in a thenable which is then unwrapped by
// the `use` algorithm during render.
const stateHook = mountWorkInProgressHook();
stateHook.memoizedState = stateHook.baseState = initialState;
const stateQueue = {
pending: null,
lanes: NoLanes,
dispatch: (null: any),
lastRenderedReducer: actionStateReducer,
lastRenderedState: initialState,
};
stateHook.queue = stateQueue;
const setState: Dispatch<S | Awaited<S>> = (dispatchSetState.bind(
null,
currentlyRenderingFiber,
((stateQueue: any): UpdateQueue<S | Awaited<S>, S | Awaited<S>>),
): any);
stateQueue.dispatch = setState;
// Pending state. This is used to store the pending state of the action.
// Tracked optimistically, like a transition pending state.
const pendingStateHook = mountStateImpl((false: Thenable<boolean> | boolean));
const setPendingState: boolean => void = (dispatchOptimisticSetState.bind(
null,
currentlyRenderingFiber,
false,
((pendingStateHook.queue: any): UpdateQueue<
S | Awaited<S>,
S | Awaited<S>,
>),
): any);
// Action queue hook. This is used to queue pending actions. The queue is
// shared between all instances of the hook. Similar to a regular state queue,
// but different because the actions are run sequentially, and they run in
// an event instead of during render.
const actionQueueHook = mountWorkInProgressHook();
const actionQueue: ActionStateQueue<S, P> = {
state: initialState,
dispatch: (null: any), // circular
action,
pending: null,
};
actionQueueHook.queue = actionQueue;
const dispatch = (dispatchActionState: any).bind(
null,
currentlyRenderingFiber,
actionQueue,
setPendingState,
setState,
);
actionQueue.dispatch = dispatch;
// Stash the action function on the memoized state of the hook. We'll use this
// to detect when the action function changes so we can update it in
// an effect.
actionQueueHook.memoizedState = action;
return [initialState, dispatch, false];
}
💡 3つの内部 Hook
useActionState は内部で3つの Hook を作成します:
- stateHook: アクションの結果(state)を管理
- pendingStateHook: pending 状態を楽観的に更新
- actionQueueHook: アクションを順次実行するためのキュー
2.3 アクションキューの構造
// packages/react-reconciler/src/ReactFiberHooks.js
// useActionState actions run sequentially, because each action receives the
// previous state as an argument. We store pending actions on a queue.
type ActionStateQueue<S, P> = {
// This is the most recent state returned from an action. It's updated as
// soon as the action finishes running.
state: Awaited<S>,
// A stable dispatch method, passed to the user.
dispatch: Dispatch<P>,
// This is the most recent action function that was rendered. It's updated
// during the commit phase.
// If it's null, it means the action queue errored and subsequent actions
// should not run.
action: ((Awaited<S>, P) => S) | null,
// This is a circular linked list of pending action payloads. It incudes the
// action that is currently running.
pending: ActionStateQueueNode<S, P> | null,
};
type ActionStateQueueNode<S, P> = {
payload: P,
// This is the action implementation at the time it was dispatched.
action: (Awaited<S>, P) => S,
// This is never null because it's part of a circular linked list.
next: ActionStateQueueNode<S, P>,
// Whether or not the action was dispatched as part of a transition.
isTransition: boolean,
// Implements the Thenable interface. We use it to suspend until the action
// finishes.
then: (listener: () => void) => void,
status: 'pending' | 'rejected' | 'fulfilled',
value: any,
reason: any,
listeners: Array<() => void>,
};
アクションキューの循環リスト構造
actionQueue.pending
↓
Node1 → Node2 → Node3 → Node1(循環)
↑ ↓
└────────────────────────┘
2.4 アクションのディスパッチ: dispatchActionState
// packages/react-reconciler/src/ReactFiberHooks.js
function dispatchActionState<S, P>(
fiber: Fiber,
actionQueue: ActionStateQueue<S, P>,
setPendingState: boolean => void,
setState: Dispatch<ActionStateQueueNode<S, P>>,
payload: P,
): void {
if (isRenderPhaseUpdate(fiber)) {
throw new Error('Cannot update form state while rendering.');
}
const currentAction = actionQueue.action;
if (currentAction === null) {
// An earlier action errored. Subsequent actions should not run.
return;
}
const actionNode: ActionStateQueueNode<S, P> = {
payload,
action: currentAction,
next: (null: any), // circular
isTransition: true,
status: 'pending',
value: null,
reason: null,
listeners: [],
then(listener) {
actionNode.listeners.push(listener);
},
};
// Check if we're inside a transition. If so, we'll need to restore the
// transition context when the action is run.
const prevTransition = ReactSharedInternals.T;
if (prevTransition !== null) {
// Optimistically update the pending state, similar to useTransition.
setPendingState(true);
setState(actionNode);
} else {
// This is not a transition.
actionNode.isTransition = false;
setState(actionNode);
}
const last = actionQueue.pending;
if (last === null) {
// There are no pending actions; this is the first one. We can run
// it immediately.
actionNode.next = actionQueue.pending = actionNode;
runActionStateAction(actionQueue, actionNode);
} else {
// There's already an action running. Add to the queue.
const first = last.next;
actionNode.next = first;
actionQueue.pending = last.next = actionNode;
}
}
💡 レンダー中の更新は禁止
isRenderPhaseUpdate(fiber) でレンダー中かどうかをチェックし、レンダー中にアクションをディスパッチしようとするとエラーになります。これは useState と異なり、useActionState はレンダーフェーズの更新をサポートしていないためです。
2.5 アクションの実行: runActionStateAction
// packages/react-reconciler/src/ReactFiberHooks.js
function runActionStateAction<S, P>(
actionQueue: ActionStateQueue<S, P>,
node: ActionStateQueueNode<S, P>,
) {
const action = node.action;
const payload = node.payload;
const prevState = actionQueue.state;
if (node.isTransition) {
// The original dispatch was part of a transition. We restore its
// transition context here.
const prevTransition = ReactSharedInternals.T;
const currentTransition: Transition = ({}: any);
ReactSharedInternals.T = currentTransition;
try {
const returnValue = action(prevState, payload);
const onStartTransitionFinish = ReactSharedInternals.S;
if (onStartTransitionFinish !== null) {
onStartTransitionFinish(currentTransition, returnValue);
}
handleActionReturnValue(actionQueue, node, returnValue);
} catch (error) {
onActionError(actionQueue, node, error);
} finally {
ReactSharedInternals.T = prevTransition;
}
} else {
// The original dispatch was not part of a transition.
try {
const returnValue = action(prevState, payload);
handleActionReturnValue(actionQueue, node, returnValue);
} catch (error) {
onActionError(actionQueue, node, error);
}
}
}
2.6 非同期アクションの処理: handleActionReturnValue
// packages/react-reconciler/src/ReactFiberHooks.js
function handleActionReturnValue<S, P>(
actionQueue: ActionStateQueue<S, P>,
node: ActionStateQueueNode<S, P>,
returnValue: mixed,
) {
if (
returnValue !== null &&
typeof returnValue === 'object' &&
typeof returnValue.then === 'function'
) {
const thenable = ((returnValue: any): Thenable<Awaited<S>>);
// 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>) => {
onActionSuccess(actionQueue, node, nextState);
},
(error: mixed) => onActionError(actionQueue, node, error),
);
} else {
const nextState = ((returnValue: any): Awaited<S>);
onActionSuccess(actionQueue, node, nextState);
}
}
💡 Promise の処理
アクションが Promise を返す場合、then を使って解決を待ちます。解決後に onActionSuccess が呼ばれ、次のアクションがキューから取り出されて実行されます。
2.7 アクション成功時の処理: onActionSuccess
// packages/react-reconciler/src/ReactFiberHooks.js
function onActionSuccess<S, P>(
actionQueue: ActionStateQueue<S, P>,
actionNode: ActionStateQueueNode<S, P>,
nextState: Awaited<S>,
) {
// The action finished running.
actionNode.status = 'fulfilled';
actionNode.value = nextState;
notifyActionListeners(actionNode);
actionQueue.state = nextState;
// Pop the action from the queue and run the next pending action, if there
// are any.
const last = actionQueue.pending;
if (last !== null) {
const first = last.next;
if (first === last) {
// This was the last action in the queue.
actionQueue.pending = null;
} else {
// Remove the first node from the circular queue.
const next = first.next;
last.next = next;
// Run the next action.
runActionStateAction(actionQueue, next);
}
}
}
2.8 更新時の処理: updateActionState
// packages/react-reconciler/src/ReactFiberHooks.js
function updateActionState<S, P>(
action: (Awaited<S>, P) => S,
initialState: Awaited<S>,
permalink?: string,
): [Awaited<S>, (P) => void, boolean] {
const stateHook = updateWorkInProgressHook();
const currentStateHook = ((currentHook: any): Hook);
return updateActionStateImpl(
stateHook,
currentStateHook,
action,
initialState,
permalink,
);
}
function updateActionStateImpl<S, P>(
stateHook: Hook,
currentStateHook: Hook,
action: (Awaited<S>, P) => S,
initialState: Awaited<S>,
permalink?: string,
): [Awaited<S>, (P) => void, boolean] {
const [actionResult] = updateReducerImpl<S | Thenable<S>, S | Thenable<S>>(
stateHook,
currentStateHook,
actionStateReducer,
);
const [isPending] = updateState(false);
// This will suspend until the action finishes.
let state: Awaited<S>;
if (
typeof actionResult === 'object' &&
actionResult !== null &&
typeof actionResult.then === 'function'
) {
try {
state = useThenable(((actionResult: any): Thenable<Awaited<S>>));
} catch (x) {
if (x === SuspenseException) {
throw SuspenseActionException;
} else {
throw x;
}
}
} else {
state = (actionResult: any);
}
const actionQueueHook = updateWorkInProgressHook();
const actionQueue = actionQueueHook.queue;
const dispatch = actionQueue.dispatch;
// Check if a new action was passed. If so, update it in an effect.
const prevAction = actionQueueHook.memoizedState;
if (action !== prevAction) {
currentlyRenderingFiber.flags |= PassiveEffect;
pushSimpleEffect(
HookHasEffect | HookPassive,
createEffectInstance(),
actionStateActionEffect.bind(null, actionQueue, action),
null,
);
}
return [state, dispatch, isPending];
}
2.9 内部の流れ図
2.10 まとめ: useActionState の内部構造
useActionState が動く仕組みの5ステージ
- mountActionState: 3つの内部 Hook(state, pending, actionQueue)を作成
- dispatchActionState: アクションノードを作成しキューに追加
- runActionStateAction: トランジションコンテキストを復元してアクション実行
- handleActionReturnValue: Promise の解決を待ち、結果を処理
- onActionSuccess: state を更新し、次のキューを実行
useState との違い
| 項目 | useState | useActionState |
|---|---|---|
| 内部 Hook 数 | 1 | 3(state + pending + queue) |
| レンダーフェーズ更新 | ✅ サポート | ❌ 禁止 |
| 非同期対応 | ❌ 手動で管理 | ✅ 自動で Promise 処理 |
| 順次実行 | N/A | ✅ キューで管理 |
| pending 状態 | ❌ 手動で管理 | ✅ 自動で追跡 |
3. 代表的ユースケース
3.1 基本的なフォーム送信
import { useActionState } from 'react';
async function submitForm(prevState, formData) {
const name = formData.get('name');
// サーバーにデータを送信
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify({ name }),
});
if (!response.ok) {
return { error: '送信に失敗しました' };
}
return { success: true, message: `${name}さん、登録完了!` };
}
function RegistrationForm() {
const [state, formAction, isPending] = useActionState(submitForm, null);
return (
<form action={formAction}>
<input name="name" placeholder="名前" required />
<button type="submit" disabled={isPending}>
{isPending ? '送信中...' : '登録'}
</button>
{state?.error && <p className="error">{state.error}</p>}
{state?.success && <p className="success">{state.message}</p>}
</form>
);
}
3.2 カウンター(シンプルな例)
import { useActionState } from 'react';
async function increment(prevState, formData) {
return prevState + 1;
}
function Counter() {
const [count, formAction, isPending] = useActionState(increment, 0);
return (
<form>
<p>カウント: {count}</p>
<button formAction={formAction} disabled={isPending}>
+1
</button>
</form>
);
}
3.3 フォームエラーの表示
import { useActionState } from 'react';
async function addToCart(prevState, formData) {
const itemId = formData.get('itemId');
try {
await fetch(`/api/cart/add/${itemId}`, { method: 'POST' });
return { message: 'カートに追加しました!' };
} catch (error) {
return { error: 'カートへの追加に失敗しました' };
}
}
function AddToCartButton({ itemId, itemTitle }) {
const [state, formAction, isPending] = useActionState(addToCart, null);
return (
<form action={formAction}>
<h3>{itemTitle}</h3>
<input type="hidden" name="itemId" value={itemId} />
<button type="submit" disabled={isPending}>
{isPending ? '追加中...' : 'カートに追加'}
</button>
{state?.error && <p className="error">{state.error}</p>}
{state?.message && <p className="success">{state.message}</p>}
</form>
);
}
3.4 サーバーアクションとの連携
// actions.js (Server Actions)
'use server';
export async function createUser(prevState, formData) {
const email = formData.get('email');
const password = formData.get('password');
// バリデーション
if (!email || !password) {
return { error: '全ての項目を入力してください' };
}
// データベースに保存
try {
await db.user.create({ email, password });
return { success: true };
} catch (error) {
return { error: 'ユーザー作成に失敗しました' };
}
}
// SignupForm.jsx (Client Component)
'use client';
import { useActionState } from 'react';
import { createUser } from './actions';
function SignupForm() {
const [state, formAction, isPending] = useActionState(createUser, null);
return (
<form action={formAction}>
<input name="email" type="email" placeholder="メールアドレス" />
<input name="password" type="password" placeholder="パスワード" />
<button disabled={isPending}>
{isPending ? '登録中...' : 'アカウント作成'}
</button>
{state?.error && <p className="error">{state.error}</p>}
{state?.success && <p>登録完了!</p>}
</form>
);
}
3.5 バリデーション付きフォーム
import { useActionState } from 'react';
async function validateAndSubmit(prevState, formData) {
const errors = {};
const name = formData.get('name');
const email = formData.get('email');
const age = formData.get('age');
// バリデーション
if (!name || name.length < 2) {
errors.name = '名前は2文字以上で入力してください';
}
if (!email || !email.includes('@')) {
errors.email = '有効なメールアドレスを入力してください';
}
if (!age || isNaN(age) || age < 0) {
errors.age = '有効な年齢を入力してください';
}
if (Object.keys(errors).length > 0) {
return { errors, values: { name, email, age } };
}
// 送信処理
await submitToServer({ name, email, age });
return { success: true };
}
function ValidatedForm() {
const [state, formAction, isPending] = useActionState(validateAndSubmit, {
errors: {},
values: {},
});
return (
<form action={formAction}>
<div>
<input
name="name"
placeholder="名前"
defaultValue={state.values?.name}
/>
{state.errors?.name && <span className="error">{state.errors.name}</span>}
</div>
<div>
<input
name="email"
type="email"
placeholder="メールアドレス"
defaultValue={state.values?.email}
/>
{state.errors?.email && <span className="error">{state.errors.email}</span>}
</div>
<div>
<input
name="age"
type="number"
placeholder="年齢"
defaultValue={state.values?.age}
/>
{state.errors?.age && <span className="error">{state.errors.age}</span>}
</div>
<button disabled={isPending}>送信</button>
{state.success && <p className="success">送信完了!</p>}
</form>
);
}
4. パフォーマンスと注意点
4.1 アクション関数のシグネチャに注意
// ❌ 通常のフォームアクションと同じシグネチャを使用
function wrongAction(formData) {
// formData が undefined になる!
const name = formData.get('name');
}
// ✅ 第1引数は prevState
function correctAction(prevState, formData) {
const name = formData.get('name');
return { ...prevState, name };
}
4.2 レンダー中の更新は禁止
// ❌ レンダー中にアクションを呼び出す
function BadComponent() {
const [state, formAction] = useActionState(action, null);
// これはエラーになる
formAction(new FormData()); // Error: Cannot update form state while rendering.
return <div>{state}</div>;
}
// ✅ イベントハンドラ内で呼び出す
function GoodComponent() {
const [state, formAction] = useActionState(action, null);
return (
<form action={formAction}>
<button type="submit">送信</button>
</form>
);
}
4.3 非同期アクションはトランジション内で
// ⚠️ 警告が出る可能性
async function asyncAction(prevState, formData) {
await fetch('/api/submit');
return { success: true };
}
// formAction を直接呼び出すと警告
button.addEventListener('click', () => {
formAction(formData); // 警告: アクションがトランジション外で呼び出された
});
// ✅ form の action として使用するか startTransition で囲む
<form action={formAction}>
<button type="submit">送信</button>
</form>
4.4 アクションは順次実行される
// 複数のアクションが短時間にディスパッチされた場合
formAction(data1);
formAction(data2);
formAction(data3);
// 実行順序は保証される: data1 → data2 → data3
// 各アクションは前のアクションが完了してから実行される
5. トラブルシューティング
5.1 アクションが送信されたフォームデータを読み取れない
原因: useActionState でラップすると、引数の順序が変わる
// ❌ 第1引数が formData だと思っている
function action(formData) {
const name = formData.get('name'); // エラー!
}
// ✅ 第1引数は prevState、第2引数が formData
function action(prevState, formData) {
const name = formData.get('name'); // OK
return { name };
}
5.2 isPending が更新されない
原因: 同期アクションを使用している、またはトランジション外で呼び出している
// ❌ 同期アクション(isPending がすぐ false に戻る)
function syncAction(prevState, formData) {
return prevState + 1;
}
// ✅ 非同期アクション
async function asyncAction(prevState, formData) {
await new Promise(r => setTimeout(r, 1000)); // 待機
return prevState + 1;
}
5.3 state が更新されない
原因: アクションから値を返していない
// ❌ return がない
async function badAction(prevState, formData) {
await submitData(formData);
// return がない → state は undefined になる
}
// ✅ 必ず値を返す
async function goodAction(prevState, formData) {
await submitData(formData);
return { success: true };
}
5.4 エラー後にアクションが実行されない
原因: エラーが発生すると actionQueue.action が null になり、後続のアクションは実行されない
// エラー発生後は action が null になる
// 新しいアクションをディスパッチしても無視される
// 解決策: 再マウントするか、エラーハンドリングを適切に行う
async function safeAction(prevState, formData) {
try {
const result = await submitData(formData);
return { success: true, data: result };
} catch (error) {
// エラーをスローせず、state で返す
return { error: error.message };
}
}
6. まとめ
この記事で解説した内容は、公式ドキュメントと facebook/react リポジトリに基づいています:
-
useActionStateはフォームアクションの結果に基づいて state を更新するフック - 内部的には3つの Hook(state, pending, actionQueue)を組み合わせて実装
- アクションは循環リンクリストのキューで順次実行される
- 非同期アクションは Promise として処理され、完了を待ってから次のアクションを実行
-
isPendingでアクションの進行状態を自動追跡
使い分けの指針: フォーム送信やアクションの結果に基づいて state を更新したい場合は useActionState、単純な状態管理には useState を使用。