useActionStateとuseOptimisticでフォーム送信を書き直す方法
React 19 の useActionState と <form action> を使うと、フォーム送信の「送信中」「エラー」の state を自分で持たずに済みます。useOptimistic を足せば、サーバーの返事を待たずに、送った内容をすぐ画面に出せます。
筆者は検証環境で、一言メッセージを投稿するフォームを、これまでの書き方から順に書き直しました。書き直す前の版と書き直した版のそれぞれで、送信した直後と、サーバーの返事が来たあとの画面を記録しています。書き直すと、エラーのときに入力欄が空になるという違いが出ました。そこで、その避け方まで確かめました。
この記事では、4つの版を、あとで見せる GIF の画面のラベルに合わせて次のように呼びます。
- ① onSubmit と useState(書き直す前)
- ② useActionState(
useActionStateと<form action>に書き直した版) - ③ ②に useOptimistic を足す
- ④ ③にエラー時の入力を残す工夫
検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、jsdom 30.1.3 です。.tsx を tsc で型チェックしてから esbuild で変換し、jsdom の上で動かしました。GIF は、同じコンポーネントを Playwright 1.56.1 の Chromium で動かして撮ったものです。サーバーは検証用に作った偽物です。こちらが合図するまで返事をせず、20文字を超える本文にはエラーを返します。
① 書き直す前:onSubmit と4つの useState
まず、これまでの書き方(①)です。一覧、入力中の文字、エラー、送信中かどうかを、それぞれ useState で持ちます。
export function Board() {
const [messages, setMessages] = useState<Message[]>([]);
const [text, setText] = useState('');
const [error, setError] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsPending(true);
const result = await postMessage(text);
setIsPending(false);
if (!result.ok) {
setError(result.error);
return;
}
setError(null);
setMessages((prev) => [...prev, result.message]);
setText('');
}
return (
<form onSubmit={handleSubmit}>
<ul>{messages.map((m) => <li key={m.id}>{m.text}</li>)}</ul>
<input name="text" value={text} onChange={(e) => setText(e.target.value)} />
<button disabled={isPending}>{isPending ? '送信中…' : '送信'}</button>
{error && <p role="alert">{error}</p>}
</form>
);
}
React 19 の発表記事の、Actions についての説明でも、同じように送信中とエラーを useState で持つ例が、Actions より前の書き方として挙げられています。発表記事によると、これまでは送信中の状態、エラー、楽観的な更新、続けて送ったときの順番を、自分で扱う必要がありました。
② useActionState と form の action に書き直す
送信の処理を、前の state と送られた FormData を受け取り、次の state を返す関数にします。useActionState のリファレンスでは、この関数を reducerAction と呼んでいます。useReducer の reducer と違って非同期の関数にでき、サーバーへの送信のような副作用も書けます。
export type State = { messages: Message[]; error: string | null };
export const initialState: State = { messages: [], error: null };
export async function sendAction(prev: State, formData: FormData): Promise<State> {
const text = String(formData.get('text') ?? '');
const result = await postMessage(text);
if (!result.ok) return { ...prev, error: result.error };
return { messages: [...prev.messages, result.message], error: null };
}
コンポーネントは、useActionState が返す関数を <form> の action に渡すだけです。
export function Board() {
const [state, formAction, isPending] = useActionState(sendAction, initialState);
return (
<form action={formAction}>
<ul>{state.messages.map((m) => <li key={m.id}>{m.text}</li>)}</ul>
<input name="text" />
<button disabled={isPending}>{isPending ? '送信中…' : '送信'}</button>
{state.error && <p role="alert">{state.error}</p>}
</form>
);
}
useState は1つも残っていません。<form> のリファレンスの、action で送信を扱う説明によると、<form> の action に関数を渡すと、送信は Transition の中で動きます。e.preventDefault() もいりません。FormData は reducerAction の2つ目の引数に渡され、1つ目の引数は前の state です。このことは、useActionState のリファレンスの、フォームのデータを読めないときの対処に書かれています。
TypeScript では、sendAction に型を付けておけば、useActionState の型引数を書かなくても tsc のチェックが通りました。
①と②で、1回目に「こんにちは」、2回目に21文字の本文を送り、サーバーの返事の前とあとで画面を記録しました。出力からは、画面を記録した行だけを抜き出しています。
$ node run.mjs ./out/BoardOnSubmit.mjs
1回目 送信直後: 一覧=[] 入力欄="こんにちは" ボタン="送信中…"(押せない) エラー=なし
1回目 返事のあと: 一覧=[こんにちは] 入力欄="" ボタン="送信" エラー=なし
2回目 送信直後: 一覧=[こんにちは] 入力欄="二十文字を超えるメッセージは保存されません" ボタン="送信中…"(押せない) エラー=なし
2回目 返事のあと: 一覧=[こんにちは] 入力欄="二十文字を超えるメッセージは保存されません" ボタン="送信" エラー=20文字以内で入力してください
$ node run.mjs ./out/BoardActionState.mjs
1回目 送信直後: 一覧=[] 入力欄="こんにちは" ボタン="送信中…"(押せない) エラー=なし
1回目 返事のあと: 一覧=[こんにちは] 入力欄="" ボタン="送信" エラー=なし
2回目 送信直後: 一覧=[こんにちは] 入力欄="二十文字を超えるメッセージは保存されません" ボタン="送信中…"(押せない) エラー=なし
2回目 返事のあと: 一覧=[こんにちは] 入力欄="" ボタン="送信" エラー=20文字以内で入力してください
送信中の表示も、成功したあとに入力欄が空になることも、①と②で同じです。isPending は、送信してから返事が来るまで true でした。
書き直すと、エラーのときも入力欄が空になる
違いは最後の1行です。①では、エラーのあとも入力した本文が残っていました。②では、エラーが出ているのに入力欄が空になりました。
<form> のリファレンスの、action で送信を扱う説明には、action の関数が成功で終わると、フォームの中の制御されていない入力欄(value を渡していない入力欄)がリセットされる、とあります。sendAction は、エラーを投げるのではなく、state の一部として返しています。React から見ればこの送信は成功で終わっているため、入力欄がリセットされたと考えられます。
<form action> と value を渡さない入力欄の組み合わせ(useActionState の基本の書き方)に変えると、サーバーがエラーを返したときにも、ユーザーが打った本文が消えます。長い文章を入力するフォームでは、ユーザーが打ち直すことになります。
エラーを state として返さずに、投げる手もあります。ただし、useActionState のリファレンスの、エラーの扱いについての説明によると、投げたエラーは一番近い Error Boundary に表示され、待っている送信も取り消されます。投げた場合に入力欄がどうなるかは、筆者は試していません。同じ説明では、「20文字を超えている」のような想定済みのエラーについて、reducerAction が返す state に含める方法が挙げられています。そこで、state を使って入力を残す方法を、あとの節(④)で試します。
③ useOptimistic で、送った内容をすぐ一覧に出す
①と②では、サーバーの返事が来るまで一覧は変わりません。②に useOptimistic を足した③では、返事が来る前に、送った内容を「送信中」として一覧に出せます。
export function Board() {
const [state, dispatch, isPending] = useActionState(sendAction, initialState);
const [optimisticMessages, addOptimistic] = useOptimistic(
state.messages,
(current: Message[], text: string) => [...current, { id: -current.length - 1, text, sending: true }],
);
function formAction(formData: FormData) {
addOptimistic(String(formData.get('text') ?? ''));
dispatch(formData);
}
return (
<form action={formAction}>
<ul>
{optimisticMessages.map((m) => (
<li key={m.id}>{m.text}{m.sending && <small>(送信中)</small>}</li>
))}
</ul>
<input name="text" />
<button>{isPending ? '送信(処理中)' : '送信'}</button>
{state.error && <p role="alert">{state.error}</p>}
</form>
);
}
useOptimistic のリファレンスの、引数の説明によると、1つ目の引数は、送信中でないときに表示する本当の値です。2つ目の関数では、送信中だけ表示する一覧を作ります。仮の項目には、sending: true という目印を付けました。同じリファレンスにある、一覧に足した項目を返事の前に表示する例では、同じ役目の目印に pending: true という名前を使っています。
useActionState のリファレンスの、<form> と組み合わせる使い方にあるとおり、action に渡した関数は Transition の中で動きます。そのため、addOptimistic も dispatch も、startTransition で包まずに呼べます。
$ node run.mjs ./out/BoardOptimistic.mjs
1回目 送信直後: 一覧=[こんにちは(送信中)] 入力欄="こんにちは" ボタン="送信(処理中)" エラー=なし
1回目 返事のあと: 一覧=[こんにちは] 入力欄="" ボタン="送信" エラー=なし
2回目 送信直後: 一覧=[こんにちは, 二十文字を超えるメッセージは保存されません(送信中)] 入力欄="二十文字を超えるメッセージは保存されません" ボタン="送信(処理中)" エラー=なし
2回目 返事のあと: 一覧=[こんにちは] 入力欄="" ボタン="送信" エラー=20文字以内で入力してください
送った内容は、送信した直後から「(送信中)」付きで一覧に出ました。成功すると目印が外れ、エラーのときは仮の項目が一覧から消えています。エラーのときに仮の項目を片付けるコードは書いていません。useOptimistic のリファレンスの、楽観的な表示のしくみの説明によると、送信が終わると、表示は1つ目の引数の値に戻ります。エラーのときは state.messages が変わっていないので、送信前の一覧に戻ったわけです。
formAction の中では dispatch の完了を待っていません(型の上でも、dispatch の戻り値は void です)。それでも、仮の項目はサーバーの返事が来るまで表示され続けました。
④ エラーのときに入力を残す
エラーのときに入力欄が空になる問題は、本文を state に残し、それを入力欄の defaultValue に渡すと避けられました。③から変えたのは2か所です。
// sendAction:state に draft を足し、エラーのときは送られた本文を入れる
if (!result.ok) return { ...prev, error: result.error, draft: text };
return { messages: [...prev.messages, result.message], error: null, draft: '' };
// Board:入力欄の初期値に draft を使う
<input name="text" defaultValue={state.draft} />
④を同じ手順で動かした結果のうち、2回目の部分です。
$ node run.mjs ./out/BoardKeepDraft.mjs
2回目 送信直後: 一覧=[こんにちは, 二十文字を超えるメッセージは保存されません(送信中)] 入力欄="二十文字を超えるメッセージは保存されません" ボタン="送信(処理中)" エラー=なし
2回目 返事のあと: 一覧=[こんにちは] 入力欄="二十文字を超えるメッセージは保存されません" ボタン="送信" エラー=20文字以内で入力してください
エラーのあとも本文が残りました(1回目は成功なので、これまでどおり空になります)。リセットされた入力欄に、新しい defaultValue の値が入ったためと考えられます。この動きは、jsdom と Chromium で確かめました。ほかのブラウザでは試していません。
次の GIF は、①〜④のフォームを Chromium で縦に並べ、いちばん上の「4つとも送信する」ボタンで2回送信した様子です。1回目は成功し、2回目は21文字なのでエラーになります。

画面の上部には「クリック 1回目/2回」のように何回目の送信かが表示され、その下の帯にはサーバーの状態が出ます。値が変わった欄は、変わった瞬間だけピンクの枠や文字になります。返事を待っているあいだに一覧が変わるのは、③と④だけです。2回目の返事のあと、②と③の入力欄はピンクの枠になって空になり、①と④には本文が残ります。
続けて送ると、1件ずつ順番に処理される
③の「送信」ボタンは、送信中も押せるようにしてあります。そこで、返事を待たずに2回続けて送信すると、次のようになりました。
$ node queue.mjs ./out/BoardOptimistic.mjs
2回送信した直後: 一覧=[1つ目(送信中), 2つ目(送信中)] 返事待ち=1件 サーバーの記録=[受付:1つ目]
1回目の返事のあと: 一覧=[1つ目(送信中), 2つ目(送信中)] 返事待ち=1件 サーバーの記録=[受付:1つ目 → 保存:1つ目 → 受付:2つ目]
2回目の返事のあと: 一覧=[1つ目, 2つ目] 返事待ち=0件 サーバーの記録=[受付:1つ目 → 保存:1つ目 → 受付:2つ目 → 保存:2つ目]
一覧には2件ともすぐ出ましたが、サーバーが2つ目を受け付けたのは、1つ目の返事のあとでした。useActionState のリファレンスの、呼び出しが順番に処理されるしくみの説明によると、reducerAction は前の呼び出しの結果を受け取るため、React は呼び出しを1つずつ順番に処理します。並行して送りたいときは、useActionState ではなく useState と useTransition を使うように、と同じリファレンスの、useReducer との違いの説明に書かれています。
また、1つ目の返事が来たあとも、一覧は「1つ目(送信中)」のままでした。useActionState のリファレンスの注意事項には、進行中の Action が複数あると、React はそれらをまとめて扱う、とあります。そのため、2つとも終わったところで一覧がまとめて確定したと考えられます。注意事項には、これは将来のリリースでなくなるかもしれない制限だとも書かれています。
useActionState に置き換えると、送信中とエラーの state を自分で持たずに済みます。<form action> では、エラーを state として返した場合も、value を渡さない入力欄は空になります。入力を残したいときは、本文を state に入れて defaultValue に渡します。useOptimistic の表示は送信が終われば本当の値に戻るので、失敗したときの片付けを書かずに済みます。





