【React】ライブラリを使わずにuseReducerでインタラクティブな複数フォームを作成してみた

【React】ライブラリを使わずにuseReducerでインタラクティブな複数フォームを作成してみた

Clock Icon2024.09.28

リテールアプリ共創部のるおんです。
Reactでフォームを作成する際に皆さんはどのように実装しているでしょうか。一般的には React Hook FormFormik などの便利なライブラリを使用することが多いと思います。これらを利用するとライブラリが提供するカスタムフックのおかげで簡単にフォームが作成できて便利ですよね。

一年ほど前にライブラリに頼らずReactが標準で用意している useReducer というフックを用いてフォームを作成した経験があります。その時はフォームデータやエラーの状態管理、バリデーションなどを自前で実装するのが結構大変だった記憶があるので、今回その経験を共有しようと思います。

多くの開発者には普通にライブラリを使用することをお勧めしますが、どうしてもReactのエコシステム内で完結したいという要望を持つ方や、useReducerの使い方を学びたいという方の参考になれば幸いです。

今回のゴール

今回は以下のようなフォームを作成してみたいと思います。

https://youtu.be/C35bVysXKYc

それぞれの入力項目は必須で空欄は認められません。
また、姓(カナ)/名(カナ)ではカタカナでないといけなかったり、メールアドレス電話番号は適切なフォーマットでないとエラーメッセージを表示するようにします。
これらのエラーメッセージの表示は一文字打つ毎にインタラクティブに判定されます。

まずは、React Hook Form を使ってサクッと作成してみました。

FormWithReactHookForm.tsx
FormWithReactHookForm.tsx
import "./App.css";
import { useForm } from "react-hook-form";

interface FormData {
  lastName: string;
  firstName: string;
  lastNameKana: string;
  firstNameKana: string;
  email: string;
  phone: string;
  age: number;
  password: string;
}

function FormWithReactHookForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isValid },
  } = useForm<FormData>({
    mode: "onChange",
  });
  const onSubmit = (data: FormData) => {
    console.log("送信!");
    console.log("data", data);
  };

  return (
    <div className="form-container">
      <h1>フォーム</h1>
      <form onSubmit={handleSubmit(onSubmit)}>
        <div className="name-container">
          <div>
            <label htmlFor="last-name"></label>
            <input
              type="text"
              id="last-name"
              {...register("lastName", {
                required: "姓は必須です",
              })}
            />
            {errors["lastName"] && (
              <p>{errors["lastName"]?.message as string}</p>
            )}
          </div>
          <div>
            <label htmlFor="first-name"></label>
            <input
              type="text"
              id="first-name"
              {...register("firstName", {
                required: "名は必須です",
              })}
            />
            {errors["firstName"] && (
              <p>{errors["firstName"]?.message as string}</p>
            )}
          </div>
        </div>
        <div className="name-container">
          <div>
            <label htmlFor="last-name-kana">姓(カナ)</label>
            <input
              type="text"
              id="last-name-kana"
              {...register("lastNameKana", {
                required: "姓(カナ)は必須です",
                pattern: {
                  value: /^[-]+$/,
                  message: "全角カタカナで入力してください",
                },
              })}
            />
            {errors["lastNameKana"] && (
              <p>{errors["lastNameKana"]?.message as string}</p>
            )}
          </div>
          <div>
            <label htmlFor="first-name-kana">名(カナ)</label>
            <input
              type="text"
              id="first-name-kana"
              {...register("firstNameKana", {
                required: "名(カナ)は必須です",
                pattern: {
                  value: /^[-]+$/,
                  message: "全角カタカナで入力してください",
                },
              })}
            />
            {errors["firstNameKana"] && (
              <p>{errors["firstNameKana"]?.message as string}</p>
            )}
          </div>
        </div>
        <label htmlFor="email">メールアドレス</label>
        <input
          type="email"
          id="email"
          {...register("email", {
            required: "メールアドレスは必須です",
            pattern: {
              value: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
              message: "メールアドレスの形式が正しくありません",
            },
          })}
        />
        {errors.email && <p>{errors.email.message as string}</p>}
        <label htmlFor="phone">電話番号</label>
        <input
          type="tel"
          id="phone"
          {...register("phone", {
            required: "電話番号は必須です",
            pattern: {
              value: /^[0-9]{10,11}$/,
              message: "電話番号は10桁か11桁の数字で入力してください",
            },
          })}
        />
        {errors.phone && <p>{errors.phone.message as string}</p>}
        <label htmlFor="age">年齢</label>
        <input
          type="number"
          id="age"
          {...register("age", {
            required: "年齢は必須です",
          })}
        />
        {errors.age && <p>{errors.age.message as string}</p>}
        <label htmlFor="password">パスワード</label>
        <input
          type="password"
          id="password"
          {...register("password", {
            required: "パスワードは必須です",
            minLength: {
              value: 8,
              message: "パスワードは8文字以上で入力してください",
            },
          })}
        />
        {errors.password && <p>{errors.password.message as string}</p>}
        <button type="submit" disabled={!isValid}>
          送信
        </button>
      </form>
    </div>
  );
}

export default FormWithReactHookForm;

html周りがファットになるのは仕方ないとしてロジック周りはかなりスッキリしていますよね。

CSSはこんな感じ

App.css
App.css
/* 全体のスタイリング */
body {
  font-family: 'Roboto', sans-serif;
  background-color: #f0f2f5;
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
  margin: 0;
}

/* フォームのコンテナ */
.form-container {
  background-color: white;
  padding: 40px;
  border-radius: 12px;
  box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
  width: 400px;
  transition: all 0.3s ease;
}

.form-container:hover {
  transform: translateY(-5px);
  box-shadow: 0 12px 20px rgba(0, 0, 0, 0.15);
}

/* タイトル */
h1 {
  margin: 0 0 30px;
  font-size: 28px;
  font-weight: 700;
  text-align: center;
  color: #333;
}

.name-container {
  display: flex;
  justify-content: space-between;
}

/* フォーム要素のスタイリング */
label {
  display: block;
  font-size: 16px;
  margin-bottom: 8px;
  color: #555;
}

input {
  width: 100%;
  padding: 12px;
  font-size: 16px;
  border: 2px solid #e0e0e0;
  border-radius: 8px;
  margin-bottom: 20px;
  box-sizing: border-box;
  transition: border-color 0.3s ease;
}

input:focus {
  outline: none;
  border-color: #3490dc;
}

/* エラーメッセージ */
p {
  color: #e3342f;
  margin: -15px 0 20px;
  font-size: 13px;
}

/* 送信ボタン */
button {
  width: 100%;
  padding: 12px;
  font-size: 18px;
  font-weight: bold;
  background-color: #3490dc;
  color: white;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  transition: background-color 0.3s, transform 0.2s;
  margin-top: 10px;
}

button[type="submit"]:disabled {
  background-color: #cccccc;
  color: #666666;
  cursor: not-allowed;
}

button:hover {
  background-color: #2779bd;
  transform: translateY(-2px);
}

button:active {
  transform: translateY(0);
}

今回のゴールはこのフォームをライブラリを使用せずに useReducer を用いてフォームの状態管理を一括で行って実装することです。

useReducerとは

useReducerは、Reactのフックの一つで、複雑な状態管理を行う際に有用です。特に状態が複数のサブ値に分かれていたり、状態変更が複雑なロジックを伴う場合に適しています。useStateと比較して、useReducerは以下のような特徴があります。

  • 状態の一元管理: 複数の関連する状態を一つのオブジェクトとして管理できるため、状態の更新が一貫性を持ちやすい
  • 明確な状態遷移: reducer関数を使用して、状態の遷移ロジックを明確に定義可能

簡単言ってしまうと、フォームのようにたくさんの項目の状態を管理しないといけない時に、それぞれをuseStateで管理するより、useReducerを使うと状態の一元管理ができますよという感じです。

useReducerは、以下のように使用します。

React
const [state, dispatch] = useReducer(reducer, initialState);
  • state: 現在の状態を表す。これはReducerによって管理される。
  • dispatch: アクションをReducerに送信するための関数。stateを更新するトリガーとなる。
  • reducer: (state, action) => newState の形式の関数。現在の状態とアクションを受け取り、新しい状態を返す。
  • initialState: useReducerの初回レンダリング時に使用される初期状態。

例えば、今回のようにフォームの状態管理にuseReducerを使用すると、各フィールドの更新やバリデーションエラーの管理を効率的に行うことができます。

やってみる

早速ですが先に全体のコードです。

FormWithUseReducer.tsx
FormWithUseReducer.tsx
import { useReducer } from "react";
import "./App.css";

type TFormData = {
  lastName: string;
  firstName: string;
  lastNameKana: string;
  firstNameKana: string;
  email: string;
  phone: string;
  age: string;
  password: string;
};

type TErrorState = Record<keyof TFormData, string>;

type TState = TFormData & {
  errors: TErrorState;
};

type TAction =
  | {
      type: "SET_FIELD";
      field: keyof TState;
      value: string;
    }
  | {
      type: "SET_ERROR";
      field: keyof TState["errors"];
      value: string;
    };

const validators: Record<keyof TFormData, (value: string) => string> = {
  lastName: (value) => {
    if (value.length < 1) {
      return "姓は必須です";
    }
    return "";
  },
  firstName: (value) => {
    if (value.length < 1) {
      return "名は必須です";
    }
    return "";
  },
  lastNameKana: (value) => {
    if (value.length < 1) {
      return "姓(カナ)は必須です";
    }

    if (!/^[-]+$/.test(value)) {
      return "全角カタカナで入力してください";
    }
    return "";
  },
  firstNameKana: (value) => {
    if (value.length < 1) {
      return "名(カナ)は必須です";
    }

    if (!/^[-]+$/.test(value)) {
      return "全角カタカナで入力してください";
    }
    return "";
  },
  email: (value) => {
    if (value.length < 1) {
      return "メールアドレスは必須です";
    }

    if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(value)) {
      return "メールアドレスの形式が正しくありません";
    }
    return "";
  },
  phone: (value) => {
    if (value.length < 1) {
      return "電話番号は必須です";
    }

    if (!/^[0-9]{10,11}$/.test(value)) {
      return "電話番号は10桁か11桁の数字で入力してください";
    }
    return "";
  },
  age: (value) => {
    if (value.length < 1) {
      return "年齢は必須です";
    }

    if (!/^\d+$/.test(value)) {
      return "年齢は半角数字で入力してください";
    }
    return "";
  },
  password: (value) => {
    if (value.length < 1) {
      return "パスワードは必須です";
    }

    if (value.length < 8) {
      return "パスワードは8文字以上で入力してください";
    }
    return "";
  },
};

const initialState: TState = {
  lastName: "",
  firstName: "",
  lastNameKana: "",
  firstNameKana: "",
  email: "",
  phone: "",
  age: "",
  password: "",
  errors: {
    lastName: "",
    firstName: "",
    lastNameKana: "",
    firstNameKana: "",
    email: "",
    phone: "",
    age: "",
    password: "",
  },
};

const formReducer = (state: TState, action: TAction) => {
  switch (action.type) {
    case "SET_FIELD":
      return { ...state, [action.field]: action.value };
    case "SET_ERROR":
      return {
        ...state,
        errors: { ...state.errors, [action.field]: action.value },
      };
    default:
      throw new Error(`予期されないアクションタイプが指定されました。`);
  }
};

function FormWithUseReducer() {
  const [state, dispatch] = useReducer(formReducer, initialState);
  const { errors, ...formData } = state;

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const fieldName = e.target.name as keyof TFormData;
    const fieldValue = e.target.value;
    dispatch({ type: "SET_FIELD", field: fieldName, value: fieldValue });

    if (fieldName in validators) {
      const errorMessage = validators[fieldName](fieldValue);

      dispatch({
        type: "SET_ERROR",
        field: fieldName,
        value: errorMessage,
      });
    }
  };

  const isFormValid: boolean =
    Object.values(errors).every((error) => !error) &&
    Object.values(formData).every((value) => value.length > 0);

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    console.log("送信!");
    console.log("formData", formData);
  };

  return (
    <div className="form-container">
      <h1>Form with useReducer</h1>
      <form onSubmit={handleSubmit}>
        <div className="name-container">
          <div>
            <label htmlFor="last-name"></label>
            <input
              type="text"
              id="last-name"
              name="lastName"
              value={state.lastName}
              onChange={handleChange}
            />
            {errors.lastName && <p>{errors.lastName}</p>}
          </div>
          <div>
            <label htmlFor="first-name"></label>
            <input
              type="text"
              id="first-name"
              name="firstName"
              value={state.firstName}
              onChange={handleChange}
            />
            {errors.firstName && <p>{errors.firstName}</p>}
          </div>
        </div>
        <div className="name-container">
          <div>
            <label htmlFor="last-name-kana">姓(カナ)</label>
            <input
              type="text"
              id="last-name-kana"
              name="lastNameKana"
              value={state.lastNameKana}
              onChange={handleChange}
            />
            {errors.lastNameKana && <p>{errors.lastNameKana}</p>}
          </div>
          <div>
            <label htmlFor="first-name-kana">名(カナ)</label>
            <input
              type="text"
              id="first-name-kana"
              name="firstNameKana"
              value={state.firstNameKana}
              onChange={handleChange}
            />
            {errors.firstNameKana && <p>{errors.firstNameKana}</p>}
          </div>
        </div>
        <label htmlFor="email">メールアドレス</label>
        <input
          type="email"
          id="email"
          name="email"
          value={state.email}
          onChange={handleChange}
        />
        {errors.email && <p>{errors.email}</p>}
        <label htmlFor="phone">電話番号</label>
        <input
          type="tel"
          id="phone"
          name="phone"
          value={state.phone}
          onChange={handleChange}
        />
        {errors.phone && <p>{errors.phone}</p>}
        <label htmlFor="age">年齢</label>
        <input
          type="number"
          id="age"
          name="age"
          value={state.age}
          onChange={handleChange}
        />
        {errors.age && <p>{errors.age}</p>}
        <label htmlFor="password">パスワード</label>
        <input
          type="password"
          id="password"
          name="password"
          value={state.password}
          onChange={handleChange}
        />
        {errors.password && <p>{errors.password}</p>}
        <button type="submit" disabled={!isFormValid}>
          送信
        </button>
      </form>
    </div>
  );
}

export default FormWithUseReducer;

React Hook Formを使う場合に比べてだいぶコードの記述量が多いですね。
まず、何よりライブラリを使用しない場合はフォームの状態を自前で用意する必要があります。

以下は管理するstateの初期値です。

const initialState: TState = {
  lastName: "",
  firstName: "",
  lastNameKana: "",
  firstNameKana: "",
  email: "",
  phone: "",
  age: "",
  password: "",
  errors: {
    lastName: "",
    firstName: "",
    lastNameKana: "",
    firstNameKana: "",
    email: "",
    phone: "",
    age: "",
    password: "",
  },
};

管理するフォームのデータと、ネストされるerrorsオブジェクトによってエラーメッセージを一元管理しています。
これが最初にページを開いたときの状態で、ユーザーによって値がインプットされるたびにこの値が変化する必要があります。

 <label htmlFor="last-name"></label>
  <input
   type="text"
   id="last-name"
   name="lastName"
   value={state.lastName}
+  onChange={handleChange}
  />
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const fieldName = e.target.name as keyof TFormData;
    const fieldValue = e.target.value;
+   dispatch({ type: "SET_FIELD", field: fieldName, value: fieldValue });

    if (fieldName in validators) {
      const errorMessage = validators[fieldName](fieldValue);

+      dispatch({
+       type: "SET_ERROR",
+       field: fieldName,
+       value: errorMessage,
+      });
    }
  };

inputタグのonChange属性に渡すhandleChange関数を作成します。
useReducerから受け取るdispatch関数を使用して適切なアクションを実行します。

ハイライトをしている部分が、フォームをセットする処理と、エラーをセットする処理です。

一つ目のdispatch関数の実行によってフォームの項目(フィールド)の値が現在の状態にセットされます。
二つ目のdispatchではエラーをセットしています。その前に、validatorsオブジェクトが返却するエラーメッセージを作成する関数を使用して、項目にマッチするバリデーションを実行しエラーメッセージを生成しています。

validators

validatorsを別で用意している

const validators: Record<keyof TFormData, (value: string) => string> = {
  lastName: (value) => {
    if (value.length < 1) {
      return "姓は必須です";
    }
    return "";
  },
  firstName: (value) => {
    if (value.length < 1) {
      return "名は必須です";
    }
    return "";
  },
  lastNameKana: (value) => {
    if (value.length < 1) {
      return "姓(カナ)は必須です";
    }

    if (!/^[-]+$/.test(value)) {
      return "全角カタカナで入力してください";
    }
    return "";
  },
  firstNameKana: (value) => {
    if (value.length < 1) {
      return "名(カナ)は必須です";
    }

    if (!/^[-]+$/.test(value)) {
      return "全角カタカナで入力してください";
    }
    return "";
  },
  email: (value) => {
    if (value.length < 1) {
      return "メールアドレスは必須です";
    }

    if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(value)) {
      return "メールアドレスの形式が正しくありません";
    }
    return "";
  },
  phone: (value) => {
    if (value.length < 1) {
      return "電話番号は必須です";
    }

    if (!/^[0-9]{10,11}$/.test(value)) {
      return "電話番号は10桁か11桁の数字で入力してください";
    }
    return "";
  },
  age: (value) => {
    if (value.length < 1) {
      return "年齢は必須です";
    }

    if (!/^\d+$/.test(value)) {
      return "年齢は半角数字で入力してください";
    }
    return "";
  },
  password: (value) => {
    if (value.length < 1) {
      return "パスワードは必須です";
    }

    if (value.length < 8) {
      return "パスワードは8文字以上で入力してください";
    }
    return "";
  },
};

dispatch関数が実行されると、それはReducer関数によって処理されます。

const formReducer = (state: TState, action: TAction) => {
  switch (action.type) {
    case "SET_FIELD":
      return { ...state, [action.field]: action.value };
    case "SET_ERROR":
      return {
        ...state,
        errors: { ...state.errors, [action.field]: action.value },
      };
    default:
      throw new Error(`予期されないアクションタイプが指定されました。`);
  }
};

それぞれのアクションに応じて現在のstateを更新しています。
このようにすることでフォームの状態とそれに対応するエラーメッセージを一つのstateで管理することができますね。

最後に、エラーがないことを確認して送信を実行することができます。

  const isFormValid: boolean =
    Object.values(errors).every((error) => !error) &&
    Object.values(formData).every((value) => value.length > 0);

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    console.log("送信!");
    console.log("formData", formData);
  };
 <form onSubmit={handleSubmit}>
   {/* 省略 */}
    <button type="submit" disabled={!isFormValid}>
     送信
    </button>
 </form>

これによってuseReducerを用いたインタラクティブなフォームを作成することができました!
ちなみに状態の中身をコンソールで見るとこんな感じになってます。一文字打つたびに中身が変化して、よりuseReducerの挙動を理解しやすいと思います。

https://youtu.be/-fwRV-BzTO4

おわりに

どうでしたでしょうか。今回はReactでライブラリを使わずにインタラクティブな複数のフォームの実装をやってみました。
私が以前実装したプロジェクトでは、もっとたくさんフォームの項目があり、inputタグのみでなくラジオボックスを使った選択肢や、都道府県などを選択するSelectコンポーネント、日付を選択するコンポーネントなどもあり、状態管理やバリデーションの実装にとても手を焼いた記憶があります。

それ以降は、基本的にはライブラリを使用するようにしていますが、React内のエコシステムに閉じることができたり、柔軟なカスタマイズ性があるのはいいですよね。

以上。どなたかの参考になれば幸いです。

参考

https://ja.react.dev/reference/react/useReducer

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.