useActionStateは、Actionの結果とpendingを管理します。入力欄の値まで自動的に保存するHookではありません。今回のフォームでは、検証エラーを戻り値で返しただけでもuncontrolled inputが空になりました。
普通のonSubmitとuseStateで処理する版と比較し、入力を残す修正も試しました。Actionの結果を持つstateと、失敗時に残したい入力は、それぞれ設計が必要です。
同じ処理をActionとonSubmitから呼ぶ
検証用の保存処理は次のコードです。ネットワークへは送信せず、Promiseのresolveをブラウザ操作から進めます。traceは呼び出し順と保留中の処理を記録する実験用オブジェクトです。
async function save(previous, formData) {
const name = formData.get('name');
trace.calls.push({ previous: { ...previous }, name });
await new Promise(resolve => trace.gates.push(resolve));
if (name === 'crash') throw new Error('simulated failure');
if (name === 'bad') return { ...previous, message: 'invalid' };
return { count: previous.count + 1, message: `saved:${name}` };
}
goodは成功結果、badは検証エラーを表す戻り値、crashはthrowです。previousを第1引数、FormDataを第2引数として受け取ります。
Action版ではuseActionState(save, initialState)の戻り値をstate、formAction、pendingとして受け取り、formActionをformのactionへ渡しました。inputはdefaultValue=""のuncontrolledです。useActionStateの公式説明には、前回の結果を次の呼び出しへ渡す引数と、保留中を表す戻り値が定義されています。
手動版は、同じsaveをonSubmitから呼びます。
async function onSubmit(event) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setPending(true);
try { setState(await save(state, data)); }
catch (error) { setState(previous => ({ ...previous, message: error.message })); }
finally { setPending(false); }
}
手動版も入力はuncontrolledで、成功時にform.resetを呼ぶ処理は入れていません。pendingと結果をsetStateし、例外はcatchでメッセージへ変えます。これをuseState全般の限界と扱う比較ではなく、どの処理を自分で書いているかを見るための最小実装です。
戻り値のエラーとthrowは違った
最初に6条件を比較しました。すべての送信で、処理を保留している間はpendingがtrueになり、入力した文字列も残っていました。
| 方式と入力 | 完了時の結果 | 完了後のinput |
|---|---|---|
| Action、good | count 1、saved:good | 空 |
| 手動、good | count 1、saved:good | good |
| Action、bad | count 0、invalid | 空 |
| 手動、bad | count 0、invalid | bad |
| Action、crash | Error Boundaryへ移る | formが外れる |
| 手動、crash | count 0、catchしたメッセージ | crash |
badのActionはinvalidを返しても、Promiseとしては正常に解決しています。Reactのformリファレンスにある、Action成功後のuncontrolled inputのresetが、この例でも行われました。アプリが戻り値を検証エラーとして扱うことと、Actionがthrowすることは別です。
throwしたActionにはError Boundaryを用意し、フォームの代わりにsimulated failureを表示することを確認しました。手動版はcatchしてstateへ入れる実装なので、同じ表示遷移にはなりません。
残したい入力を結果へ含める
検証エラーで入力を失わないよう、次の版を追加しました。
async function saveWithInput(previous, formData) {
const next = await save(previous, formData);
return { ...next, input: next.message === 'invalid' ? String(formData.get('name')) : '' };
}
export function PreservingExperiment() {
const [state, formAction, pending] = useActionState(saveWithInput, { ...initialState, input: '' });
return <section>
<output id="action-state">{JSON.stringify(state)}</output>
<output id="action-pending">{String(pending)}</output>
<form id="action-form" action={formAction}>
<input id="action-input" name="name" defaultValue={state.input} />
<button id="action-submit" disabled={pending}>Save</button>
</form>
</section>;
}
検証エラーなら入力値を返し、inputのdefaultValueへ渡します。成功時は空文字列を返します。追加の2条件では、badの後はbadが残り、goodの後は空になりました。
この修正でもinputをvalueで制御してはいません。Action後に使う初期値をstateへ含めることで、今回のreset後に必要な値を残しています。既存アプリへ組み込み済みのフォームではなく、この実験内で動作を確かめた修正です。
複数回呼ぶと前回結果を待つ
もう1つ、フォーム送信とは別のボタンから2回dispatchする条件を確認しました。
function queueTwo() {
startTransition(() => {
for (const name of ['first', 'second']) {
const data = new FormData(); data.set('name', name); formAction(data);
}
});
}
最初のPromiseを解決するまでは、saveの呼び出し記録は1件でした。解決すると2件目が始まり、そのprevious.countは1でした。最終的なstateはcount 2、message saved:secondです。
| 観測時点 | 開始済みのsave | 画面のcount | pending |
|---|---|---|---|
| 1件目を保留中 | 1 | 0 | true |
| 1件目解決後、2件目を保留中 | 2 | 0 | true |
| 両方解決後 | 2 | 2 | false |
この実験では、次のActionに渡る前回結果と、画面へ反映された中間stateを同じものとして読めませんでした。呼び出し履歴とDOMを両方記録しています。
formのactionから呼ぶ場合はReactがTransitionとして扱います。今回のようにボタンから直接dispatchする比較ではstartTransition内で呼びました。Transition外での呼び出しや、並列処理をこのHookへ任せる設計は検証していません。
今回はクライアント関数だけを検証した
検証日は2026年9月11日、React / React DOM 19.3.0、Node.js 24.15.0、esbuild 0.28.2、macOS arm64、Headless Chrome 152です。production build、Strict ModeとReact Compilerなしで、計9条件を確認しました。待機は手動で解決し、通信時間は測っていません。
Server FunctionやJavaScript読み込み前の送信、permalink、認証・保存処理は含めていません。公式APIはServer Functionとの組み合わせも扱っていますが、この記事の結果はブラウザ内の関数を使ったものです。サーバー側の検証や認可は、この実験では実装・検証していません。
比較コードと9条件の結果を残しました。pendingや戻り値に加えて、入力を消すか残すか、例外時にどこへ表示を移すかまで確認できます。