React 19でuseActionStateで入力フォーム【Typescript】

React 19でuseActionStateで入力フォーム【Typescript】

ども!年末の追い込みが激しくどかどか働いている龍ちゃんです。寒すぎて暖房で過ごしていますが、電気代におびえています。

皆さん、React 19のドキュメントは確認しましたか?12/5についにReact 19がstableになりました。僕はお恥ずかしながら、Next.jsの検証環境を作っていた時にNext15がStableになっていることで気づきました。Stableになったので、そろそろ学ぼうということで新しいHookを学んでいこうと思います。

今回の内容は、「新しいHookである、useActionStateを使ってTypescriptで入力フォームを作る」という内容になります。極力Typescriptで型を付けた状態で紹介していきます。

useActionStateについて確認

まずは、基本の使い方についてまとめていきます。いろいろと利用シーンがあると思います。ざっくりとuseActionStateのうれしいところは以下の点です。

  • useActionState内で非同期処理の状態を取得することができる
  • ライブラリを使用せずにReactのみでフォームの管理が楽になった

ここでは二つの例を紹介していきます。二つの例の違いとしては、Formの入力値の使用の有無になります。

どちらの使い方でも共通しているのは、useStateと同じようにStateを保存することができます。イメージとしては、asyncの処理を連携したState更新処理をForm(入力)とセットで管理することができるHookとなります。

Formの値を使用しないuseActionState

例として、カウントアップを作成します。こちらの内容は、Reactの公式ドキュメントにも記載されています。

useActionStateで設定がマストな引数は2つになります。第一引数にはaction関数、第二引数には初期値を渡します。action関数では、初回の実行時には初期値が渡り、それ以降は前回のaction関数で返答された値が返答されます。型付け行った場合は、action関数の戻り値は型と一致する必要があります。

import { useActionState } from "react";

export const FormTest1 = () => {
  const [count, countAction, isCountPending] = useActionState<number>(
    async (prevCount: number) => {
      await new Promise((res) => setTimeout(res, 1000));
      console.log(prevCount);
      return prevCount + 1;
    },
    0
  );

  return (
    <>
      <form action={countAction}>
        <button type="submit" disabled={isCountPending}>
          カウントアップ
        </button>
        <p>{count}</p>
      </form>
    </>
  );
};

今回のaction関数はuseActionStateの機能を試すために、処理を2秒止めています。useActionStateからの返り値は3つです。

  • count:Stateの値 初回では初期値が、実行後はaction関数によって更新された値が挿入
  • countAction:action関数の実行トリガー
  • isCountPending:action実行状態を取得

useActionStateの素晴らしい点としては、ソース中のisCountPendingにあります。これまで、useStateやuseRefを組み合わせて作成していた。loading表示などもこちらを使用することで一つにまとめることができます。

Formの値を使用するuseActionState

例としては、簡易的なバリデーションがついたフォームとなります。実用性はありませんが、useActionStateの挙動を理解する手助けと、Typescriptでの型検証には有用だと思います。

フォームの入力情報を受け取る場合は、action関数の第二引数に情報が飛んできます。Stateとしては、Error | null の状態を持つことで、バリデーションの有無を表現しています。

import { useActionState } from "react";

export const FormTest1 = () => {
  const [error, action, isPending] = useActionState<Error | null, FormData>(
    async (prevError: Error | null, formData: FormData) => {
      console.log(prevError);

      //   値の取得方法法
      const data = Object.fromEntries(formData.entries());
      console.log(data);
      
      // APIの処理などを行ってResultによって処理を分岐させる

      //   returnを返せばエラー発生とする
      const error = new Error("Failed to submit data");
      if (error) {
        return error;
      }
      return null;
    },
    null
  );

  return (
    <>
      <form action={action}>
        <input type="text" name="name" />
        <button type="submit" disabled={isPending}>
          送信
        </button>
        {error && <p>{error.message}</p>}
      </form>
    </>
  );
};

この例で確認できる内容としては以下になります。

  • useActionStateで送信されるForm情報の構造化
  • State設定の自由度がそれなりにある

useActionStateで入力フォームを作成する

2つの例でuseActionStateの挙動については理解できたと仮定して、実際利用するフォームの作成を進めていきます。

作成するフォームの情報をまとめます。

  • 名前:String・年齢:number
  • バリデーション
    • 名前:空文字禁止・10文字以内
    • 年齢:0以上
  • バリデーション通過後、API通信をするイメージ(今回は2秒後エラー)

ソースコードの全体を先に置いておきます。

import { useActionState } from "react";

type FormType = {
  name: string;
  age: number;
};
type PrevFormDataType = {
  value: FormType;
  validationError: { name: Error | null; age: Error | null };
  apiError: Error | null;
};

const validationName = (name: string) => {
  if (name === "") {
    return new Error("名前を入力してください");
  } else if (name.length > 10) {
    return new Error("名前は10文字以内で入力してください");
  }
  return null;
};
const validationAge = (age: number) => {
  if (age <= 0) {
    return new Error("年齢は0以上で入力してください");
  }
  return null;
};

export const FormTest3 = () => {
  const initialFormData: PrevFormDataType = {
    value: { name: "", age: 0 },
    validationError: { name: null, age: null },
    apiError: null,
  };

  const [formData, action, isPending] = useActionState<
    PrevFormDataType,
    FormData
  >(async (_: PrevFormDataType, formData: FormData) => {
    // FormDataをobjectに変換
    const _formData = Object.fromEntries(formData.entries());
    const data: FormType = {
      name: _formData.name as string,
      age: Number(_formData.age),
    };

    // validationを掛ける いい感じのライブラリがあれば参考にする
    const nameError = validationName(data.name);
    const ageError = validationAge(data.age);
    if (nameError || ageError) {
      return {
        value: { name: data.name, age: data.age },
        validationError: {
          name: nameError,
          age: ageError,
        },
        apiError: null,
      };
    }

    // ここでAPI処理を実装・今回は2秒待ってエラーを返す
    await new Promise((res) => setTimeout(res, 2000));
    const apiError = new Error("Failed to submit data");

    return {
      value: { name: data.name, age: data.age },
      validationError: {
        name: nameError,
        age: ageError,
      },
      apiError: apiError,
    };
  }, initialFormData);

  return (
    <>
      <form
        action={action}
        className="flex w-full max-w-xl flex-col gap-2 rounded-md p-4 shadow"
      >
        <label className="flex flex-col">
          <div className="flex flex-row text-xl">
            <span className="w-1/3">名前:</span>
            <input
              className="w-full border p-1 text-right"
              type="text"
              name="name"
              defaultValue={formData.value.name}
            />
          </div>
          <span className="h-4 text-xs text-red-500">
            {formData.validationError.name && (
              <>{formData.validationError.name.message}</>
            )}
          </span>
        </label>
        <label className="flex flex-col">
          <div className="flex flex-row text-xl">
            <span className="w-1/3">年齢:</span>
            <input
              className="w-full border p-1 text-right"
              type="number"
              name="age"
              defaultValue={formData.value.age}
            />
          </div>
          <span className="h-4 text-xs text-red-500">
            {formData.validationError.age && (
              <>{formData.validationError.age.message}</>
            )}
          </span>
        </label>
        <button
          className={
            "w-full rounded-md py-4 text-lg text-white" +
            (isPending ? " bg-gray-400" : " bg-blue-500")
          }
          type="submit"
          formAction={action}
          disabled={isPending}
        >
          送信{isPending && "中"}
        </button>
        <span className="h-4 text-xs text-red-500">
          {formData.apiError && <p>{formData.apiError.message}</p>}
        </span>
      </form>
    </>
  );
};

Stateの型定義

useActionStateでaction関数実行後は、Formの入力値はリセットが掛かってしまいます。フォームのバリデーション評価の間は値を継続させたいので、Stateの定義としてはFormの入力値・バリデーションエラー・apiエラーの三つを取得できるオブジェクトとして定義しておきます。

// Formのタイプ
type FormType = {
  name: string;
  age: number;
};

// Stateの型定義
type PrevFormDataType = {
  value: FormType;
  validationError: { name: Error | null; age: Error | null };
  apiError: Error | null;
};

バリデーション

将来的にはライブラリを使って運用を進めていきたいのですが、ここでは簡易的に自作したバリデーションを使用します。

名前のバリデーション

const validationName = (name: string) => {
  if (name === "") {
    return new Error("名前を入力してください");
  } else if (name.length > 10) {
    return new Error("名前は10文字以内で入力してください");
  }
  return null;
};

年齢のバリデーション

const validationAge = (age: number) => {
  if (age <= 0) {
    return new Error("年齢は0以上で入力してください");
  }
  return null;
};

バリデーション関数としては、型定義と合わせてError | nullを戻り値として設定しています。

useActionStateの実装

初期化の値を別途定義しています。初期状態では、各種エラーはnullを入れておきます。フォームの値も初期値を設定します。

const initialFormData: PrevFormDataType = {
  value: { name: "", age: 0 },
  validationError: { name: null, age: null },
  apiError: null,
};

const [formData, action, isPending] = useActionState<
  PrevFormDataType,
  FormData
>(async (_: PrevFormDataType, formData: FormData) => {
  // FormDataをobjectに変換
  const _formData = Object.fromEntries(formData.entries());
  const data: FormType = {
    name: _formData.name as string,
    age: Number(_formData.age),
  };

  // validationを掛ける いい感じのライブラリがあれば参考にする
  const nameError = validationName(data.name);
  const ageError = validationAge(data.age);
  if (nameError || ageError) {
    return {
      value: { name: data.name, age: data.age },
      validationError: {
        name: nameError,
        age: ageError,
      },
      apiError: null,
    };
  }

  // ここでAPI処理を実装・今回は2秒待ってエラーを返す
  await new Promise((res) => setTimeout(res, 2000));
  const apiError = new Error("Failed to submit data");

  return {
    value: { name: data.name, age: data.age },
    validationError: {
      name: nameError,
      age: ageError,
    },
    apiError: apiError,
  };
}, initialFormData);

action関数の中身としては、以下のような流れになっています。

  • formDataの積み替え → FormTypeの情報へ変換
  • バリデーションチェック・早期リターンでバリデーションエラー表示
  • API通信

今回は、検証の意味を込めてAPIエラーも用意しています。ここは使用用途によって、ErrorBoundaryでキャッチする仕様でも問題ないかと思います。

終わり

今回は、useActionStateをTypescriptで型付けしながら入力フォームの実装をしてみました。新しい機能が出ても、Stableまで手を出さないというのは、良いことなのか悪いことなのかわかりませんね。きっと、技術選定でリジェクトされた思い出が強く残っているのだと思います。

useActionState以外にも便利そうなHooksが追加されていたので、React 19とNext 15で色々作ってみるのも楽しそうですね。ふんわりと年末に入りますが、一旦はメリークリスマス!

ご覧いただきありがとうございます! この投稿はお役に立ちましたか?

役に立った 役に立たなかった

0人がこの投稿は役に立ったと言っています。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です