useActionStateとuseOptimisticでフォーム送信を書き直す方法

​​React​TypeScript,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 で持ちます。

tsx
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 と違って非同期の関数にでき、サーバーへの送信のような副作用も書けます。

tsx
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 に渡すだけです。

tsx
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文字の本文を送り、サーバーの返事の前とあとで画面を記録しました。出力からは、画面を記録した行だけを抜き出しています。

text
$ 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 を足した③では、返事が来る前に、送った内容を「送信中」として一覧に出せます。

tsx
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 で包まずに呼べます。

text
$ node run.mjs ./out/BoardOptimistic.mjs
  1回目 送信直後: 一覧=[こんにちは(送信中)] 入力欄="こんにちは" ボタン="送信(処理中)" エラー=なし
  1回目 返事のあと: 一覧=[こんにちは] 入力欄="" ボタン="送信" エラー=なし
  2回目 送信直後: 一覧=[こんにちは, 二十文字を超えるメッセージは保存されません(送信中)] 入力欄="二十文字を超えるメッセージは保存されません" ボタン="送信(処理中)" エラー=なし
  2回目 返事のあと: 一覧=[こんにちは] 入力欄="" ボタン="送信" エラー=20文字以内で入力してください

送った内容は、送信した直後から「(送信中)」付きで一覧に出ました。成功すると目印が外れ、エラーのときは仮の項目が一覧から消えています。エラーのときに仮の項目を片付けるコードは書いていません。useOptimistic のリファレンスの、楽観的な表示のしくみの説明によると、送信が終わると、表示は1つ目の引数の値に戻ります。エラーのときは state.messages が変わっていないので、送信前の一覧に戻ったわけです。

formAction の中では dispatch の完了を待っていません(型の上でも、dispatch の戻り値は void です)。それでも、仮の項目はサーバーの返事が来るまで表示され続けました。

④ エラーのときに入力を残す

エラーのときに入力欄が空になる問題は、本文を state に残し、それを入力欄の defaultValue に渡すと避けられました。③から変えたのは2か所です。

tsx
// 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回目の部分です。

text
$ node run.mjs ./out/BoardKeepDraft.mjs
  2回目 送信直後: 一覧=[こんにちは, 二十文字を超えるメッセージは保存されません(送信中)] 入力欄="二十文字を超えるメッセージは保存されません" ボタン="送信(処理中)" エラー=なし
  2回目 返事のあと: 一覧=[こんにちは] 入力欄="二十文字を超えるメッセージは保存されません" ボタン="送信" エラー=20文字以内で入力してください

エラーのあとも本文が残りました(1回目は成功なので、これまでどおり空になります)。リセットされた入力欄に、新しい defaultValue の値が入ったためと考えられます。この動きは、jsdom と Chromium で確かめました。ほかのブラウザでは試していません。

次の GIF は、①〜④のフォームを Chromium で縦に並べ、いちばん上の「4つとも送信する」ボタンで2回送信した様子です。1回目は成功し、2回目は21文字なのでエラーになります。

①〜④のフォームを縦に並べ、「4つとも送信する」ボタンで2回送信したときの、一覧・入力欄・エラーの変化
GIFアニメーション:4つのフォームで2回送信したときの動き。③と④は、返事の前から送った内容が一覧に出る。2回目のエラーのあと、②と③は入力欄が空になり、①と④は本文が残る

画面の上部には「クリック 1回目/2回」のように何回目の送信かが表示され、その下の帯にはサーバーの状態が出ます。値が変わった欄は、変わった瞬間だけピンクの枠や文字になります。返事を待っているあいだに一覧が変わるのは、③と④だけです。2回目の返事のあと、②と③の入力欄はピンクの枠になって空になり、①と④には本文が残ります。

続けて送ると、1件ずつ順番に処理される

③の「送信」ボタンは、送信中も押せるようにしてあります。そこで、返事を待たずに2回続けて送信すると、次のようになりました。

text
$ 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 の表示は送信が終われば本当の値に戻るので、失敗したときの片付けを書かずに済みます。

​Posted by 86