【React】ライブラリを使わずにuseReducerでインタラクティブな複数フォームを作成してみた
リテールアプリ共創部のるおんです。
Reactでフォームを作成する際に皆さんはどのように実装しているでしょうか。一般的には React Hook Form や Formik などの便利なライブラリを使用することが多いと思います。これらを利用するとライブラリが提供するカスタムフックのおかげで簡単にフォームが作成できて便利ですよね。
一年ほど前にライブラリに頼らずReactが標準で用意している useReducer というフックを用いてフォームを作成した経験があります。その時はフォームデータやエラーの状態管理、バリデーションなどを自前で実装するのが結構大変だった記憶があるので、今回その経験を共有しようと思います。
多くの開発者には普通にライブラリを使用することをお勧めしますが、どうしてもReactのエコシステム内で完結したいという要望を持つ方や、useReducer
の使い方を学びたいという方の参考になれば幸いです。
今回のゴール
今回は以下のようなフォームを作成してみたいと思います。
それぞれの入力項目は必須で空欄は認められません。
また、姓(カナ)/名(カナ)
ではカタカナでないといけなかったり、メールアドレス
や電話番号
は適切なフォーマットでないとエラーメッセージを表示するようにします。
これらのエラーメッセージの表示は一文字打つ毎にインタラクティブに判定されます。
まずは、React Hook Form を使ってサクッと作成してみました。
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
/* 全体のスタイリング */
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
は、以下のように使用します。
const [state, dispatch] = useReducer(reducer, initialState);
- state: 現在の状態を表す。これはReducerによって管理される。
- dispatch: アクションをReducerに送信するための関数。stateを更新するトリガーとなる。
- reducer:
(state, action) => newState
の形式の関数。現在の状態とアクションを受け取り、新しい状態を返す。 - initialState: useReducerの初回レンダリング時に使用される初期状態。
例えば、今回のようにフォームの状態管理にuseReducer
を使用すると、各フィールドの更新やバリデーションエラーの管理を効率的に行うことができます。
やってみる
早速ですが先に全体のコードです。
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
の挙動を理解しやすいと思います。
おわりに
どうでしたでしょうか。今回はReactでライブラリを使わずにインタラクティブな複数のフォームの実装をやってみました。
私が以前実装したプロジェクトでは、もっとたくさんフォームの項目があり、input
タグのみでなくラジオボックスを使った選択肢や、都道府県などを選択するSelect
コンポーネント、日付を選択するコンポーネントなどもあり、状態管理やバリデーションの実装にとても手を焼いた記憶があります。
それ以降は、基本的にはライブラリを使用するようにしていますが、React内のエコシステムに閉じることができたり、柔軟なカスタマイズ性があるのはいいですよね。
以上。どなたかの参考になれば幸いです。
参考