Skip to content

4.2 Actions / use / useOptimistic — フォームと楽観的更新 ​

Part 3.8 のフォームは、useState で入力値を制御コンポーネントとして持ち、onSubmit で fetch を投げる形でした。送信中フラグ・エラー表示・成功後のフォームリセットは自分で書く必要がありましたね。React 19 は、この定型を全部 React 側に吸収する仕組みを用意してくれました。それが Actions です。ここは覚えることが多めなので、少しずつ見ていきましょう。

Action とは何か ​

React 用語での Action は、startTransition(Transition)の中で呼ばれる async 関数 のことです。特別なクラスや型があるわけではなく、単に「フォームや startTransition に渡す async 関数」を指します。ある関数が Action として扱われると、React は以下を自動で管理してくれます。

  • pending 状態(isPending)
  • エラー処理(エラー時に巻き戻し)
  • 楽観的更新(useOptimistic と組み合わせたとき)
  • フォームの自動リセット(<form action={fn}> の場合)

図 4.2.1 onSubmit と form action で「書く量」の差 ​

onSubmit 方式自分で書くもの- isPending 用の state- error 用の state- preventDefault + fetch- 成功後の入力リセット- 送信中のボタン無効化- 楽観更新&巻き戻し定型コードが多い<form action={fn}>React が受け持つもの - isPending の管理- Transition 内での実行- uncontrolled 入力のリセット- useOptimistic との連携- 失敗時のロールバック補助- useFormStatus で子に共有開発者が書くのは async 関数本体

<form action={fn}> の使い方 ​

React 19 では、<form> の action に関数を渡せます。HTML 標準では URL を渡すと POST 先として使われますが、関数を渡した場合は 送信時に React が FormData を引数として関数を呼び、Transition の中で実行 してくれます。

tsx
// react.dev/reference/react-dom/components/form より翻案
function SearchForm() {
  function search(formData: FormData) {
    const query = formData.get("query");
    console.log("searched:", query);
  }

  return (
    <form action={search}>
      <input name="query" />
      <button type="submit">検索</button>
    </form>
  );
}

送信が成功すると 制御されていない(uncontrolled)入力は自動でリセット されます。ここが onSubmit 方式との大きな差です。地味ですが、毎回手で書いていたリセット処理が消えるのは結構ありがたいですね。

useActionState — Actions をまとめて扱う ​

useActionState は、Action 関数と初期状態を受け取り、[state, dispatch, isPending] を返すフックです。

tsx
// react.dev/reference/react/useActionState の例を翻案
import { useActionState } from "react";

type State = { error?: string; name?: string };

async function updateName(_prev: State, formData: FormData): Promise<State> {
  const name = String(formData.get("name"));
  if (name.length < 2) {
    return { error: "名前は 2 文字以上にしてください" };
  }
  await new Promise((r) => setTimeout(r, 500));
  return { name };
}

export function NameForm() {
  const [state, formAction, isPending] = useActionState(updateName, {});
  return (
    <form action={formAction}>
      <input name="name" />
      <button type="submit" disabled={isPending}>
        {isPending ? "送信中..." : "更新"}
      </button>
      {state.error && <p role="alert">{state.error}</p>}
      {state.name && <p>こんにちは、{state.name} さん</p>}
    </form>
  );
}

押さえておきたいポイントはこの 3 つです。

  • Action の第 1 引数は「前回の state」、第 2 引数が dispatch に渡した payload(フォーム経由なら FormData)です。
  • 戻り値が次の state になります。エラーは throw するのではなく戻り値に含める のがセオリーです。throw すると次の送信がスキップされる可能性があります(公式ドキュメントの注意)。
  • useFormState と呼ばれていた API が改名されたものです(react-dom ではなく react からインポートする点に注意)。

useFormStatus — 子コンポーネントから親 form の状態を読む ​

フォームの中で送信ボタンを別コンポーネントに切り出したい、という要望はよく出てきます。useFormStatus は、<form> の中に置かれたコンポーネント から、送信中かどうかを読み取るためのフックです。props のバケツリレーが要らなくなるのが大きな利点です。

tsx
// react.dev/reference/react-dom/hooks/useFormStatus より翻案
import { useFormStatus } from "react-dom";

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "送信中..." : "送信"}
    </button>
  );
}

export function Form() {
  async function handle(formData: FormData) {
    await new Promise((r) => setTimeout(r, 800));
  }
  return (
    <form action={handle}>
      <input name="title" />
      <SubmitButton />
    </form>
  );
}

返される状態オブジェクトは { pending, data, method, action } の 4 プロパティです(data は送信中の FormData)。

制約: useFormStatus は 親フォームの状態しか読めません。同じコンポーネントの中で <form> を return している場合は拾えないので、送信ボタンは必ずフォームの子として置きます。最初は見落としがちな点なので、気に留めておいてください。

useOptimistic — 楽観的更新 ​

送信が完了する前に「成功した前提」で UI を先行更新する手法を、楽観的 UI(optimistic UI) と呼びます。チャットアプリで、メッセージがサーバーに届くより先に自分の吹き出しが一瞬で出る、あの挙動ですね。

useOptimistic は、送信中に限って 暫定値を返し、Action が終わると自動で実値に戻してくれます。

tsx
// react.dev/reference/react/useOptimistic の例を翻案
import { useOptimistic, startTransition } from "react";

type Message = { id: string; text: string; sending?: boolean };

export function Chat({
  messages,
  sendMessage,
}: {
  messages: Message[];
  sendMessage: (text: string) => Promise<void>;
}) {
  const [optimistic, addOptimistic] = useOptimistic(
    messages,
    (current: Message[], pendingText: string) => [
      ...current,
      { id: "temp", text: pendingText, sending: true },
    ]
  );

  async function handleSubmit(formData: FormData) {
    const text = String(formData.get("text"));
    addOptimistic(text);
    await sendMessage(text);
  }

  return (
    <>
      <ul>
        {optimistic.map((m) => (
          <li key={m.id} style={{ opacity: m.sending ? 0.5 : 1 }}>
            {m.text}
          </li>
        ))}
      </ul>
      <form action={handleSubmit}>
        <input name="text" />
        <button type="submit">送信</button>
      </form>
    </>
  );
}
  • useOptimistic(value, reducer?) の第 1 引数が「何もなければ見せる値」、第 2 引数が「楽観更新のときにどう変形するか」です。
  • 呼び出しは Action(startTransition の中)である必要があります。<form action={...}> 経由なら自動で Transition の中になります。
  • 送信が終わると messages(外側の state)が更新されるので、useOptimistic は 自動的にその実値にフォールバック します。エラーで messages が更新されなくても、Transition が終われば元の messages に戻るので、巻き戻しは明示的に書かなくて済みます。

触って確かめる — 楽観的更新のデモ ​

下のデモは、楽観的更新の感覚をつかむための模型です(実際に React の useOptimistic を呼んでいるわけではなく、挙動を真似ています)。「送信」を押すと、サーバー応答を待つ前に吹き出しが追加されます。「次の送信を失敗させる」にチェックを入れて送信すると、暫定で出した吹き出しが失敗状態にロールバックされる様子を見られます。

楽観的更新のデモ(擬似サーバー)
  • こんにちは確定

送信ボタンを押すと、サーバー応答を待つ前に吹き出しが追加されます(useOptimistic の発想)。 「失敗させる」にチェックを入れて送信すると、実装次第で 失敗状態に置き換える (このデモの挙動)か、外側の state を更新しないことでそのまま消える (useOptimistic の自動ロールバックに相当)ことを観察できます。

図 4.2.2 Action の流れ(データフロー) ​

use — フックではない新しい API ​

use は「Promise から値を読む」「Context から値を読む」という 2 つの用途を持つ API です。公式はこれをフックとは呼びません。理由は、他のフックと違って条件分岐やループの中、早期 return の後でも呼べる からです。

tsx
// react.dev/reference/react/use の例を翻案
import { use, Suspense } from "react";

function Message({ messagePromise }: { messagePromise: Promise<string> }) {
  const text = use(messagePromise); // resolve するまで suspend
  return <p>{text}</p>;
}

export function App({ promise }: { promise: Promise<string> }) {
  return (
    <Suspense fallback={<p>読み込み中...</p>}>
      <Message messagePromise={promise} />
    </Suspense>
  );
}
  • Promise を渡す: resolve するまでその場で suspend します。つまり、最も近い親 <Suspense> の fallback に切り替わります(詳しくは 4.3)。
  • Context を渡す: useContext と同じ効果ですが、if や早期 return の後でも呼べます。
tsx
import { use, createContext } from "react";
const ThemeContext = createContext<"light" | "dark">("light");

function HR({ show }: { show: boolean }) {
  if (!show) return null;
  const theme = use(ThemeContext); // 早期 return の後でも OK
  return <hr data-theme={theme} />;
}

ただし、try / catch の中では呼べません。失敗時は Error Boundary で捕まえるのが前提です。

動かして確かめる ​

useActionState の単純な例を、StackBlitz 上で観察できます。

StackBlitz で開く

フレッシュな Vite + React + TypeScript テンプレートが立ち上がったら、src/App.tsx を上記 NameForm の実装に差し替えてみてください。送信ボタンを押したときの isPending の変化を体感するのが目的です。

参照ソース ​