1. ホーム
  2. React

【React】useActionState の使い方|フォームのアクションと状態を管理する

Share

フォームを扱うとき、送信中かどうかの状態管理や、送信結果・エラーメッセージの表示を自分で useState を組み合わせて作ると、コードがどうしても煩雑になりがちです。React 19 で追加された useActionState フックを使うと、フォームのアクション(送信処理)と、その結果として持ちたい state、さらに送信中かどうか(pending)を1つのフックにまとめて扱えます。この記事では、useActionState のシグネチャの読み方から、バリデーション結果を表示する実践的なフォーム、送信中のボタン無効化、useState との違い、そしてつまずきやすいポイントまでを、動くコードとあわせて解説します。

useActionState がフォーム管理を1つにまとめる

useActionState は、フォームの送信処理と、その処理が返す state をひとまとめに管理するためのフックです。従来はフォームを作るとき、入力値を保持する state、送信中かどうかを表す state、送信結果やエラーを表す state をそれぞれ useState で用意し、onSubmit ハンドラの中で preventDefault したりローディングフラグを立て下げしたりと、細かな配線を自分で書く必要がありました。

useActionState は、この「送信処理の結果を state として持ちたい」「送信中かどうかを知りたい」という2つの要求を1つのフックで満たします。フォームの action に渡せる関数を返してくれるので、<form action={formAction}> のように書くだけで送信と state 更新がつながります。さらに、後述する Server Actions と組み合わせれば、サーバー側の処理結果をそのままクライアントの state に反映することもできます。

シグネチャと3つの戻り値を読み解く

useActionState は、第1引数にアクション関数、第2引数に state の初期値を受け取り、配列で3つの値を返します。まずは全体の形を確認します。

シグネチャ
const [state, formAction, isPending] = useActionState(fn, initialState);

第1引数の fn は、(previousState, formData) => newState という形のアクション関数です。フォームが送信されると React がこの関数を呼び出し、第1引数に前回の state、第2引数にフォームの入力内容をまとめた FormData オブジェクトを渡します。関数が返した値が、次の state になります。この関数は async にでき、非同期処理の完了を待ってから新しい state を返せます。

第2引数の initialState は、state の初期値です。まだ一度も送信されていない初期表示のとき、state はこの値になります。戻り値の3つは、それぞれ次の意味を持ちます。

戻り値意味
state現在の state。初回は initialState、送信後は fn が返した値になる
formAction<form action={formAction}> に渡すためのアクション。送信時に fn を実行する
isPendingアクションの実行中(送信中)かどうかを表す真偽値

formAction は、<form>action 属性にそのまま渡すのが基本です。ボタンの formAction 属性に渡したり、後述するように startTransition の中から呼び出したりもできますが、まずはフォームに直接渡す使い方を押さえれば十分です。

最小のコードで動きを確認する

まずは、送信するたびにカウントを増やすだけの最小の例で、3つの戻り値がどう連動するかを見てみます。アクション関数が受け取る第1引数 previousState が「前回の state」であることが、この例からよく分かります。

Counter.tsx
"use client";

import { useActionState } from "react";

// previousState に前回の値が渡ってくる
async function increment(previousState: number, formData: FormData) {
  return previousState + 1;
}

export default function Counter() {
  const [count, formAction, isPending] = useActionState(increment, 0);

  return (
    <form action={formAction}>
      <p>現在のカウント: {count}</p>
      <button type="submit" disabled={isPending}>
        {isPending ? "処理中..." : "+1"}
      </button>
    </form>
  );
}

ボタンを押すとフォームが送信され、increment が呼ばれます。第1引数の previousState にはそれまでの count が入っているので、previousState + 1 を返すことでカウントが1つ増えます。返した値は新しい count となり、画面に反映されます。処理中は isPendingtrue になるため、ボタンを disabled にして二重送信を防いでいます。

バリデーション結果を state で表示する

実践的な使い方として、入力フォームのバリデーション結果やエラーメッセージを state として表示する例を作ります。ここでは、メールアドレスの入力欄を持つ問い合わせフォームを想定し、入力が空だったり形式が正しくなかったりしたときにエラーメッセージを返し、成功したら完了メッセージを返します。

まず、state として持たせる形を型で定義しておくと扱いやすくなります。ここでは、表示するメッセージと、それが成功かエラーかを表すフラグを持たせます。

ContactForm.tsx
"use client";

import { useActionState } from "react";

// state として持たせる形を定義
type FormState = {
  message: string;
  success: boolean;
};

const initialState: FormState = { message: "", success: false };

async function submitContact(
  previousState: FormState,
  formData: FormData
): Promise<FormState> {
  const email = String(formData.get("email") ?? "");

  // バリデーション: 結果を state として返す
  if (email === "") {
    return { message: "メールアドレスを入力してください。", success: false };
  }
  if (!email.includes("@")) {
    return { message: "メールアドレスの形式が正しくありません。", success: false };
  }

  // ここで実際の送信処理(API 呼び出しなど)を行う
  await new Promise((resolve) => setTimeout(resolve, 1000));

  return { message: "送信が完了しました。", success: true };
}

export default function ContactForm() {
  const [state, formAction, isPending] = useActionState(
    submitContact,
    initialState
  );

  return (
    <form action={formAction}>
      <input type="email" name="email" placeholder="you@example.com" />
      <button type="submit" disabled={isPending}>
        {isPending ? "送信中..." : "送信"}
      </button>

      {/* state に入ったメッセージを成功・エラーで色分けして表示 */}
      {state.message && (
        <p style={{ color: state.success ? "green" : "red" }}>
          {state.message}
        </p>
      )}
    </form>
  );
}

ポイントは、バリデーションの結果もサーバー処理の結果も、すべて submitContact の戻り値、つまり state として表現していることです。エラーがあれば success: false のメッセージを返し、成功したら success: true のメッセージを返します。コンポーネント側は返ってきた state を見て表示を切り替えるだけなので、エラー用の state とローディング用の state を別々に管理する必要がありません。送信中は isPendingtrue になり、ボタンが無効化されるので、連打による多重送信も防げます。

useState との違いを整理する

useActionState は state を扱うという意味では useState と似ていますが、想定している用途が異なります。useState は「値を保持し、setState で自由に更新する」汎用のフックです。一方 useActionState は「フォームのアクションの結果を state にする」ことに特化しており、更新は setState ではなくアクション関数の戻り値によって行われます。主な違いを表にまとめます。

観点useStateuseActionState
更新の方法setState(value) を任意に呼ぶアクション関数が返した値が次の state になる
前回の state更新関数に前回値を渡す形(setState(prev => ...))で参照アクション関数の第1引数 previousState で受け取れる
pending の取得付属しない(自分で管理する)戻り値の isPending で取得できる
フォームとの連携onSubmit ハンドラを自分で書くformAction<form action> に渡すだけ

大きな違いは2点です。1つは、アクション関数が前回の state を第1引数で受け取れること。もう1つは、送信中かどうかを表す isPending が最初から付属していることです。フォーム送信のように「前回の結果を踏まえて次の state を決めたい」「送信中の表示を出したい」という場面では、useActionState のほうが素直に書けます。逆に、フォームと無関係な単なる値の保持には従来どおり useState が向いています。

Server Actions と組み合わせる

useActionState の第1引数には、クライアントの関数だけでなく Server Action(サーバー側で実行される関数)も渡せます。Next.js の App Router などで "use server" を付けた関数を渡すと、フォーム送信時にサーバー側で処理が実行され、その戻り値がクライアントの state に反映されます。これにより、サーバーでのバリデーションやデータベース更新の結果を、そのまま画面のメッセージ表示に使えます。

actions.ts
"use server";

type FormState = { message: string; success: boolean };

// サーバー側で実行されるアクション
export async function subscribe(
  previousState: FormState,
  formData: FormData
): Promise<FormState> {
  const email = String(formData.get("email") ?? "");
  if (!email.includes("@")) {
    return { message: "メールアドレスが正しくありません。", success: false };
  }

  // サーバーでの保存処理などを行う
  // await db.subscribers.create({ email });

  return { message: "登録しました。", success: true };
}

クライアント側では、この subscribeuseActionState にそのまま渡します。アクション関数の形(previousStateformData を受け取り、新しい state を返す)が同じなので、クライアント関数のときと書き方は変わりません。処理の場所がサーバーに移るだけで、コンポーネント側のコードはほとんど共通のまま使えるのが利点です。

state が更新されないと感じたときに見直す点

useActionState を使い始めたときに戸惑いやすい点を、原因ごとに整理しておきます。多くは「アクション関数の引数の順番」と「state の返し方」に関するものです。

第1引数を FormData だと思ってしまう

もっとも多い勘違いが、アクション関数の第1引数を formData だと思ってしまうことです。実際には、第1引数は前回の state(previousState)で、FormData は第2引数です。function action(formData) { ... } のように書くと、formData の中身が前回の state になってしまい、formData.get() が期待どおり動きません。フォームの入力値を取り出すときは、必ず第2引数から formData.get("name") のように取得してください。

新しい state を return し忘れる

アクション関数は、返した値がそのまま次の state になります。処理だけ書いて return を忘れると、戻り値が undefined になり、state が意図せず消えてしまいます。エラーのときも成功のときも、必ず「次に表示したい state」を返すようにします。特定の条件では前回の値を保ちたいなら、return previousState のように前回の state をそのまま返します。

初期表示で state が空になる

初期表示のとき、state は第2引数に渡した initialState になります。まだ一度も送信していない段階でメッセージを表示しようとして「何も出ない」と感じることがありますが、これは正常な動作です。初期状態では空文字などを入れておき、state.message が空でないときだけ表示するようにすると、初回に不要なメッセージが出るのを防げます。先ほどの問い合わせフォームで state.message && (...) と条件を付けていたのは、この初期表示対策でもあります。

まとめ

useActionState は、フォームのアクションの結果を state として保持し、送信中かどうかも取得できる React 19 のフックです。const [state, formAction, isPending] = useActionState(fn, initialState) という形で使い、アクション関数 fn は前回の state(第1引数)と FormData(第2引数)を受け取って新しい state を返します。返した値がそのまま state になるため、バリデーション結果やエラーメッセージを1つの流れで扱えます。formAction<form action> に渡すだけでフォームと連携でき、isPending で送信中のボタン無効化も簡単に実装できます。useState と違って前回の state を引数で受け取れる点と、pending が付属する点が特徴で、Server Actions と組み合わせればサーバー処理の結果もそのまま画面に反映できます。第1引数が previousState である点だけ取り違えないように注意すれば、フォーム周りのコードをすっきりまとめられるフックです。

参考ページ