😺

React 19の新機能を活用したシンプルで直感的なフォーム送信

2024/12/13に公開

はじめに

少しばかり遅いですがReact 19 の新機能として追加された useActionState と、フォームの action 属性の新しい使い方を試してみました。フォーム送信の簡略化と、非同期処理の状態管理がどのように変わるのかを検証し、これまでのフォーム処理と比べてどのようにコードが簡潔になるのか、また、非同期処理をシンプルに管理するための useActionState の具体的な使い方を紹介します。
さらに、従来の useReactForm との比較を通じて、それぞれの利点と活用シーンについても触れています。フォーム送信の実装や状態管理に悩んでいる方や、React 19 の新しい可能性を試してみたい方に向けた記事になっています

今回紹介するコードはGitHubでも公開しています。
実際に試してみたい方は、ぜひこちらもご確認ください

https://github.com/Mojakun/try-react19

action 属性とは?

<form> の action 属性
React 19 では、フォームの action 属性に直接関数を渡せるようになりました。この関数には FormData オブジェクトが引数として渡され、簡単にフォームのデータを扱うことができます。

React 18以前とReact 19の比較

  1. React 18以前のフォーム送信方法
    React 18以前では、フォームデータを送信する際、onSubmitとuseStateを使用してデータを管理していました。
export default function Form() {
  const [formData, setFormData] = useState({ name: "", email: "" });

  function handleChange(e) {
    setFormData({ ...formData, [e.target.name]: e.target.value });
  }

  function handleSubmit(e) {
    e.preventDefault();
    console.log("Submitted:", formData);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" onChange={handleChange} />
      <input name="email" onChange={handleChange} />
      <button type="submit">Submit</button>
    </form>
  );
}

React 19でのフォーム送信方法

React 19では、action属性を活用することで、フォームデータの送信がよりシンプルに記述できます。

export default function Form() {
  async function handleSubmit(formData) {
    console.log("Submitted:", Object.fromEntries(formData.entries()));
  }

  return (
    <form action={handleSubmit}>
      <input name="name" />
      <input name="email" />
      <button type="submit">Submit</button>
    </form>
  );
}

action属性により、従来の状態管理や冗長なコードから解放され、シンプルで効率的なフォーム処理が実現できます。

useActionStateの説明の前にuseTransitionについて少し触れる

useActionStateを効果的に理解するには、React 18で導入されたuseTransitionの基本的な概念を知っておくと役立ちます。

useTransitionの役割

Reactの非同期UI更新を管理するためのフック。
ユーザー体験を向上させるため、画面全体の状態がブロックされることを防ぎながら、バックグラウンドで非同期処理を進めます。

useTransitionの使い方
以下は、useTransitionの基本的な使い方を示した例です。

import { useState, useTransition } from "react";

export default function Example() {
  const [count, setCount] = useState(0);
  const [isPending, startTransition] = useTransition();

  function handleClick() {
    startTransition(() => {
      setCount((prev) => prev + 1);
    });
  }

  return (
    <div>
      <button onClick={handleClick} disabled={isPending}>
        {isPending ? "Loading..." : "Increment"}
      </button>
      <p>Count: {count}</p>
    </div>
  );
}

このコードでは、startTransitionを利用して状態更新をバックグラウンドで実行し、メインのUIをスムーズに保っています。

useStateとの違い
useStateは状態を即時的に更新するのに対し、useTransitionは非同期処理中の状態管理に特化しています。以下の表でその違いを比較します。

特徴 useState useTransition
目的 状態の即時更新と再レンダリング 非同期処理中の状態管理
再レンダリングのタイミング 状態更新後、即時 再レンダリングの優先順位を調整可能
用途 シンプルな状態管理 非同期処理中のUIスムーズさを保つ
UIへの影響 状態更新が頻繁に発生する場合、UIがフリーズする可能性 メインUIをブロックせずスムーズに処理する

さてuseActionStateとは

useActionStateは、useTransitionをさらに発展させたものとして、React 19で登場しました。特にフォーム送信に特化しており、以下の点でuseTransitionを補完しています。

  • フォーム送信の状態管理
    isPendingを用いて、送信中かどうかを簡単に判断。
  • 成功・失敗の結果の一元化:
    フォーム送信結果(成功・失敗)をstateとして一括管理。
  • UI更新の簡素化
    非同期処理とUI更新を1つのフックで実現。

基本的な使い方(シンプルなFormState)

"use client";

import { useActionState } from "react";

type FormState = {
  success: boolean;
};

export default function SimpleForm() {
  async function handleSubmit(
    state: FormState | null,
    formData: FormData
  ): Promise<FormState> {
    const data = Object.fromEntries(formData.entries());
    if (!data.name || !data.email) {
      return { success: false }; // バリデーション失敗時
    }
    return new Promise((resolve) => {
      setTimeout(() => resolve({ success: true }), 2000); // バリデーション成功時
    });
  }

  const [state, formAction, isPending] = useActionState<FormState, FormData>(
    handleSubmit,
    { success: false }
  );

  return (
    <form action={formAction}>
      <input name="name" placeholder="Name" />
      <input name="email" placeholder="Email" />
      <button type="submit" disabled={isPending}>
        {isPending ? "Submitting..." : "Submit"}
      </button>
      {state.success && <p>Form submitted successfully!</p>}
    </form>
  );
}

useActionStateの特徴

FormDataが自動的に渡される

通常のReactフォームでは、onSubmitイベントから手動でフォームデータを取得する必要があります。例えば、以下のようにevent.preventDefault()を使用し、new FormData()を明示的に作成していました。

function handleSubmit(event: React.FormEvent) {
  event.preventDefault();
  const formData = new FormData(event.target as HTMLFormElement);
  console.log(Object.fromEntries(formData.entries()));
}

しかし、useActionStateを利用すると、フォームのaction属性に指定した関数に、自動的にFormDataが渡されます。これにより、フォームデータの取得が不要になり、記述が大幅に簡略化されます。

フォームの状態を一括管理

useActionStateでは、フォームの送信状態や結果をstateとして一括で管理できます。これにより、次のようなメリットがあります。

成功状態の簡単な追跡: フォーム送信が成功したかどうかをstate.successで判別可能。
例えば、このコードでは、成功時と失敗時の結果を管理しています。

async function handleSubmit(
  _state: { success: boolean } | null,
  formData: FormData
): Promise<{ success: boolean }> {
  const data = Object.fromEntries(formData.entries());
  if (!data.name || !data.email) {
    return { success: false }; // バリデーション失敗時
  }
  return new Promise((resolve) => {
    setTimeout(() => resolve({ success: true }), 2000); // バリデーション成功時
  });
}

UI更新の一貫性

isPendingを利用して、送信中かどうかを判定できます。例えば、以下のように処理中のボタンを「Submitting...」に変えたり、無効化するのも簡単です。

<button type="submit" disabled={isPending}>
  {isPending ? "Submitting..." : "Submit"}
</button>
{state?.success && (
 <p className="text-green-500">Form submitted successfully!</p>
)}

バリデーション処理もstateで一元管理

通常のバリデーションと比較
従来のReactでは、フォームバリデーションを実装する際に、エラーメッセージを個別にuseStateで管理する必要がありました。以下のようなコードになりがちです。

const [errors, setErrors] = useState<Record<string, string>>({});

function handleValidation(formData: FormData) {
  const data = Object.fromEntries(formData.entries());
  const newErrors: Record<string, string> = {};

  if (!data.name) newErrors.name = "Name is required.";
  if (!data.email) newErrors.email = "Email is required.";

  setErrors(newErrors);
}

これに対して、useActionStateを使うと、stateの中にerrorsを含めることで、エラー状態を簡単に管理できます。

"use client";

import { useActionState } from "react";

type FormState = {
  success: boolean;
  errors?: Record<string, string>;
};

export default function SimpleForm() {
  async function handleSubmit(
    _state: FormState | null,
    formData: FormData
  ): Promise<FormState> {
    const errors = validateFormData(formData);
    if (Object.keys(errors).length > 0) {
      return { success: false, errors };
    }
    return { success: true };
  }

  const [state, formAction, isPending] = useActionState<FormState, FormData>(
    handleSubmit,
    { success: false }
  );

  const errors = state?.errors || {};

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <label>
          Name
          <input
            name="name"
            className={errors.name ? "border-red-500" : ""}
          />
        </label>
        {errors.name && <p className="text-red-500">{errors.name}</p>}
      </div>
      <button type="submit" disabled={isPending}>
        {isPending ? "Submitting..." : "Submit"}
      </button>
      {state.success && <p className="text-green-500">Success!</p>}
    </form>
  );
}

useActionState は useReactForm の代わりになるか?

この記事を読んでいる方の中には、日頃から useReactForm を使ってフォームを実装されている方も多いのではないでしょうか。かくいう私も、これまで useReactForm を使って多くのフォームを作ってきました。しかしながら、テストが辛い...

触ってみた感じの感想としてはケースバイケースだと思います(そりゃそうだ)

useActionState は、小規模でシンプルなフォーム管理には非常に有効ですが、複雑なフォームや高度なバリデーションが求められる場面では、useReactForm の柔軟性が必要になるでしょう。

useActionState が有効な場面

  • 小規模・単純なフォーム
  • 軽量なプロジェクト
  • 複雑な依存関係が不要な場面で、よりシンプルな選択肢を求める場合。

useReactForm が有効な場面

  • フィールド数が多い、動的にフィールドが増減するような複雑なフォーム
  • 高度なバリデーション

テストのしやすさの違い

useActionState の特徴として、テストの簡便さが挙げられます。状態を管理する state が返されるため、以下のように状態のモックだけでテストが可能です。

test("フォーム送信時にエラーメッセージが表示される", () => {
  const mockState = { success: false, errors: { name: "Name is required." } };
  const { getByText } = render(
    <SimpleForm useActionState={() => [mockState, jest.fn(), false]} />
  );
  expect(getByText("Name is required.")).toBeInTheDocument();
});

一方、useReactForm では個々のフィールドの状態を細かくモックする必要があるため、テストの負担が増す場合があります。

どちらも適材適所で選択し、プロジェクトの規模や要件に応じて使い分けるのがベストです。
シンプルな非同期フォームを構築したい場合、useActionState を試してみる価値があると思います。

まとめ

React 19で登場したuseActionStateとフォームのaction属性は、従来の状態管理の煩雑さから開放され、シンプルで直感的なフォーム処理を提供します。特に以下の点で、その魅力を実感しました。

フォーム送信処理の簡略化action属性により、手動でFormDataを取得する必要がなくなり、送信処理の記述が大幅に簡潔化されました。

非同期処理とUI更新の一貫性useActionStateを使うことで、送信中の状態(isPending)や成功状態(state.success)を簡単に管理できるようになり、UIの一貫性が向上しました。

バリデーション処理の簡便化バリデーションエラーをstate内に含めることで、エラー表示の管理が統一され、記述量が減りました。

課題と使い分け

useActionStateは、シンプルなフォームには最適ですが、リアルタイムバリデーションや動的なフィールドが必要な複雑なフォームではuseReactFormの方が適しています。それぞれの特徴を理解し、プロジェクトの要件に応じて使い分けることが重要です。

これからの展望

useActionStateは軽量なフォーム構築には非常に有効で、特に非同期処理を伴うフォームには適しています。一方で、フォームの柔軟性や高度なバリデーションが求められる場面では、useReactFormなどの専用ライブラリがまだ有利と言えるでしょう。

今後は、この2つを組み合わせたハイブリッドなアプローチや、さらなる拡張性を模索しながら、プロジェクトに最適なソリューションを探っていきたいと思います。

Discussion