フォームを扱うとき、送信中かどうかの状態管理や、送信結果・エラーメッセージの表示を自分で 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」であることが、この例からよく分かります。
"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 となり、画面に反映されます。処理中は isPending が true になるため、ボタンを disabled にして二重送信を防いでいます。
バリデーション結果を state で表示する
実践的な使い方として、入力フォームのバリデーション結果やエラーメッセージを state として表示する例を作ります。ここでは、メールアドレスの入力欄を持つ問い合わせフォームを想定し、入力が空だったり形式が正しくなかったりしたときにエラーメッセージを返し、成功したら完了メッセージを返します。
まず、state として持たせる形を型で定義しておくと扱いやすくなります。ここでは、表示するメッセージと、それが成功かエラーかを表すフラグを持たせます。
"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 を別々に管理する必要がありません。送信中は isPending が true になり、ボタンが無効化されるので、連打による多重送信も防げます。
useState との違いを整理する
useActionState は state を扱うという意味では useState と似ていますが、想定している用途が異なります。useState は「値を保持し、setState で自由に更新する」汎用のフックです。一方 useActionState は「フォームのアクションの結果を state にする」ことに特化しており、更新は setState ではなくアクション関数の戻り値によって行われます。主な違いを表にまとめます。
| 観点 | useState | useActionState |
|---|---|---|
| 更新の方法 | 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 に反映されます。これにより、サーバーでのバリデーションやデータベース更新の結果を、そのまま画面のメッセージ表示に使えます。
"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 };
}
クライアント側では、この subscribe を useActionState にそのまま渡します。アクション関数の形(previousState と formData を受け取り、新しい 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 である点だけ取り違えないように注意すれば、フォーム周りのコードをすっきりまとめられるフックです。