Reactのライブラリを駆使して、それっぽいアンケートフォームを作ろう | 技術ブログ
はじめにReactには開発を便利にするための様々なライブラリがあります。今回はフォーム作成に欠かせないFormikと、キレイめなUIを作ってくれるMaterial-UIを使って実用的なフォームを...
https://www.wantedly.com/companies/jointcrew/post_articles/914441
はじめに
ライブラリを使わないフォーム
今回実装する画面
実際のコード
フォーム本体
バリデーション
定数、型、スタイル
フォームの構成要素
全体の把握
画面UI
フォームの状態管理
バリデーション
ライブラリの使用状況
Formik vs React Hook Form
Yup vs Zod
最新のトレンド
どの組み合わせを選ぶべきか
まとめ
Reactを使って何らかの入力フォームを作成するとします。
その際、どのライブラリを使って実装をするべきなのでしょうか。
また、大抵は複数のライブラリを組み合わせて使うことになりますが、最適な組み合わせは何なのでしょうか。
そもそも、なぜ複数使う必要があるのでしょうか。
これらを明らかにし、実務でも通用する知識を付けるため、全4回のシリーズでReactでフォームを作るのに使えるライブラリとその使い方、組み合わせ方について解説します。
初回はライブラリを使わずにフォームを実装するとどうなるのかを確認したうえで、フォームを構成する要素を明らかにします。
そしてどのようなライブラリがあるのか、その特性は何かを紹介していきます。
次回以降、それぞれのライブラリの詳しい使い方を通して、何を選定すべきなのかを考えます。
ちなみに、Reactのフォーム作成については過去に以下のような記事を書いています。
この執筆から1年半くらい経ち、実際に現場で使用して得た知見等も溜まってきたので、より詳しく、よりわかりやすく解説していきます。
実務で使うには違和感がありますが、様々な値を扱えるフォームを作ります。
文字列、数値、ラジオボタン、セレクトボックス、複数選択のチェックボックス、単一選択のチェックボックスを用意しました。
このフォームの構成は全4回を通して同じものを扱います。
それぞれのライブラリの特性によって同じフォームでも何が異なるのかを確認します。
まずはライブラリを使わない場合のコードがどのようになるのかみていきましょう。
本記事のコードは React 19 / TypeScript 5.9 / Vite 7 で動作確認しています。
少し長いですが、フォームの構成は以下のようになっています。
コードの解説は後ほど行います。
/* SampleForm.tsx */
import { useState } from "react";
import type { ChangeEvent, FormEvent } from "react";
import type { FormValues, FormErrors } from "./types";
import {
GENDER_OPTIONS,
OCCUPATION_OPTIONS,
INTEREST_OPTIONS,
} from "./constants";
import { validate } from "./validation";
import { styles } from "./styles";
export const SampleForm = () => {
// フォームの値全体の状態管理
const [values, setValues] = useState<FormValues>({
username: "",
age: "",
occupation: "",
gender: "",
interests: [],
agreement: false,
});
// エラーの状態管理
const [errors, setErrors] = useState<FormErrors>({});
// フォームの編集状態管理
const [touched, setTouched] = useState<Partial<Record<keyof FormValues, boolean>>>({});
// ユーザー名
const handleUsernameChange = (e: ChangeEvent<HTMLInputElement>) => {
setValues((prev) => ({ ...prev, username: e.target.value }));
};
const handleUsernameBlur = () => {
setTouched((prev) => ({ ...prev, username: true }));
setErrors((prev) => ({ ...prev, username: validate(values).username }));
};
// 年齢
const handleAgeChange = (e: ChangeEvent<HTMLInputElement>) => {
setValues((prev) => ({ ...prev, age: e.target.value }));
};
const handleAgeBlur = () => {
setTouched((prev) => ({ ...prev, age: true }));
setErrors((prev) => ({ ...prev, age: validate(values).age }));
};
// 性別
const handleGenderChange = (e: ChangeEvent<HTMLInputElement>) => {
setValues((prev) => ({ ...prev, gender: e.target.value }));
};
const handleGenderBlur = () => {
setTouched((prev) => ({ ...prev, gender: true }));
setErrors((prev) => ({ ...prev, gender: validate(values).gender }));
};
// 職業
const handleOccupationChange = (e: ChangeEvent<HTMLSelectElement>) => {
setValues((prev) => ({ ...prev, occupation: e.target.value }));
};
const handleOccupationBlur = () => {
setTouched((prev) => ({ ...prev, occupation: true }));
setErrors((prev) => ({ ...prev, occupation: validate(values).occupation }));
};
// 興味・関心
const handleInterestsChange = (e: ChangeEvent<HTMLInputElement>) => {
const { value, checked } = e.target;
setValues((prev) => {
const newInterests = checked
? [...prev.interests, value]
: prev.interests.filter((interest) => interest !== value);
return { ...prev, interests: newInterests };
});
};
const handleInterestsBlur = () => {
setTouched((prev) => ({ ...prev, interests: true }));
setErrors((prev) => ({ ...prev, interests: validate(values).interests }));
};
// 利用規約への同意
const handleAgreementChange = (e: ChangeEvent<HTMLInputElement>) => {
setValues((prev) => ({ ...prev, agreement: e.target.checked }));
};
const handleAgreementBlur = () => {
setTouched((prev) => ({ ...prev, agreement: true }));
setErrors((prev) => ({ ...prev, agreement: validate(values).agreement }));
};
// 送信処理
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const validationErrors = validate(values);
setErrors(validationErrors);
setTouched({
username: true,
age: true,
gender: true,
occupation: true,
interests: true,
agreement: true,
});
if (Object.keys(validationErrors).length === 0) {
console.log("フォームが正常に送信されました:", values);
}
};
return (
<form onSubmit={handleSubmit} style={styles.form}>
<h2>ユーザー登録</h2>
{/* ユーザー名 */}
<div style={styles.field}>
<label>ユーザー名:</label>
<input
id="username"
name="username"
type="text"
value={values.username}
onChange={handleUsernameChange}
onBlur={handleUsernameBlur}
/>
{touched.username && errors.username && (
<span style={styles.error}>{errors.username}</span>
)}
</div>
{/* 年齢 */}
<div style={styles.field}>
<label>年齢:</label>
<input
id="age"
name="age"
type="number"
value={values.age}
onChange={handleAgeChange}
onBlur={handleAgeBlur}
/>
{touched.age && errors.age && (
<span style={styles.error}>{errors.age}</span>
)}
</div>
{/* 性別 */}
<div style={styles.field}>
<label>性別:</label>
{GENDER_OPTIONS.map((option) => (
<label key={option} style={styles.radioLabel}>
<input
type="radio"
name="gender"
value={option}
checked={values.gender === option}
onChange={handleGenderChange}
onBlur={handleGenderBlur}
/>
{option}
</label>
))}
{touched.gender && errors.gender && (
<span style={styles.error}>{errors.gender}</span>
)}
</div>
{/* 職業 */}
<div style={styles.field}>
<label htmlFor="occupation">職業:</label>
<select
id="occupation"
name="occupation"
value={values.occupation}
onChange={handleOccupationChange}
onBlur={handleOccupationBlur}
>
<option value="">選択してください</option>
{OCCUPATION_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
{touched.occupation && errors.occupation && (
<span style={styles.error}>{errors.occupation}</span>
)}
</div>
{/* 興味・関心 */}
<div style={styles.field}>
<label>興味・関心:</label>
{INTEREST_OPTIONS.map((option) => (
<label key={option} style={styles.checkboxLabel}>
<input
type="checkbox"
name="interests"
value={option}
checked={values.interests.includes(option)}
onChange={handleInterestsChange}
onBlur={handleInterestsBlur}
/>
{option}
</label>
))}
{touched.interests && errors.interests && (
<span style={styles.error}>{errors.interests}</span>
)}
</div>
{/* 利用規約への同意 */}
<div style={styles.field}>
<label style={styles.checkboxLabel}>
<input
type="checkbox"
name="agreement"
checked={values.agreement}
onChange={handleAgreementChange}
onBlur={handleAgreementBlur}
/>
利用規約に同意する
</label>
{touched.agreement && errors.agreement && (
<span style={styles.error}>{errors.agreement}</span>
)}
</div>
<button type="submit" style={styles.button}>
登録
</button>
</form>
);
};
入力された値が想定通りかをチェックする処理も自前で実装します。
フォームの値全体のオブジェクトを引数として受け取り、各値がルールに合致しているかをチェック。
ルールから外れた値がある場合にはエラーメッセージを設定し、エラーオブジェクトに詰めています。
そして、最終的にエラーオブジェクトを返却します。
/* validation.ts */
import type { FormValues, FormErrors } from "./types";
export const validate = (values: FormValues): FormErrors => {
const errors: FormErrors = {};
if (!values.username) {
errors.username = "ユーザー名は必須です。";
} else if (values.username.length < 3) {
errors.username = "ユーザー名は3文字以上で入力してください。";
}
if (!values.age) {
errors.age = "年齢は必須です。";
} else if (isNaN(Number(values.age)) || Number(values.age) <= 0) {
errors.age = "有効な年齢を入力してください。";
} else if (Number(values.age) < 18) {
errors.age = "18歳以上である必要があります。";
}
if (!values.gender) {
errors.gender = "性別を選択してください。";
}
if (!values.occupation) {
errors.occupation = "職業を選択してください。";
}
if (values.interests.length === 0) {
errors.interests = "少なくとも1つの興味・関心を選択してください。";
}
if (!values.agreement) {
errors.agreement = "利用規約に同意する必要があります。";
}
return errors;
};
以下のコードはフォームの構成に大きく寄与しないため、特に解説はしません。
定数
/* constants.ts */
// ラジオボタンで使用する値を定義
export const GENDER_OPTIONS = ["男性", "女性", "その他"] as const;
// セレクトボックスで使用する値を定義
export const OCCUPATION_OPTIONS = ["学生", "会社員", "自営業", "その他"] as const;
// チェックボックスで使用する値を定義
export const INTEREST_OPTIONS = [ "テクノロジー", "デザイン", "ビジネス", "マーケティング"] as const;型
/* types.ts */
// フォームの値の型定義
export type FormValues = {
username: string;
age: string; // inputからは文字列として受け取る
gender: string;
occupation: string;
interests: string[];
agreement: boolean;
};
// フォームのエラーの型定義
export type FormErrors = Partial<Record<keyof FormValues, string>>;スタイル
/* styles.ts */
export const styles: Record<string, React.CSSProperties> = {
form: {
maxWidth: 400,
margin: "0 auto",
padding: 20,
},
field: {
marginBottom: 16,
display: "flex",
flexDirection: "column",
gap: 4,
border: "none",
padding: 0,
},
error: {
color: "#dc2626",
fontSize: 14,
},
radioLabel: {
display: "inline-flex",
alignItems: "center",
gap: 4,
marginRight: 12,
},
checkboxLabel: {
display: "inline-flex",
alignItems: "center",
gap: 4,
marginRight: 12,
},
button: {
padding: "8px 16px",
cursor: "pointer",
},
};
フォーム本体のコードを眺めていると、似たような要素が繰り返し記述されていることに気づくかと思います。
また、上記において共通して使用されている処理もあります。
handle系の処理はいずれかの状態に対して変更を加えています。
これらのことから、フォームは大別すると3つの要素から構成されていると言えます。
これらの要素について詳しくみていきます。
inputやselectなどのHTML標準要素は見た目が質素です。
今回の例のように個別にスタイルを当てることもできますが、実際の業務で使用する膨大な構成要素に対して一つずつスタイルを当てるのは現実的ではありません。
UI用のライブラリを使えば、簡単に見た目がよいフォームを作成できます。
代表的なものにはMUI(Material-UI)、Chakra UI、shadcn/uiなどがあります。
私の現場ではMUIを使っています。
なお、UIライブラリの比較は本シリーズでは扱いません。
次回以降の記事ではMUIを使用してサンプルコードを記述します。
今回のサンプルコードでは状態管理の処理を扱うために、全部で12のhandleXXX関数を用意しました。
入力要素が増えるたびに似たような処理を書いていくのはかなり大変です。
change系とblur系をそれぞれ一つの関数にまとめることも出来ますが、それでもそれぞれの関数内で多くの分岐処理を書く必要があります。
これらの煩雑な管理をしてくれるライブラリとして、Formik、React Hook Formなどがあります。
私の現場ではFormikを使っています。
バリデーション入力要素ごとにルールを記述していました。
必須チェックや文字数チェックなど、よく使うルールを毎度手書きするのは手間です。
また、TypeScriptの型定義との連携も課題になります。
バリデーション用のライブラリとしてはYup、Zodなどがあり、私の現場ではYupが使われています。
先に上げた通り、フォームのライブラリとしてはFormik、React Hook Form、バリデーションのライブラリとしてはYup、Zodがあることがわかりました。
それぞれがどれくらい使われているのかを調べます。
npm trendsで確認した2つのライブラリの比較は以下のとおりです。(2026年1月時点)
React Hook Formが圧倒的であり、約5倍の差をつけていることがわかります。
2022年ころに逆転してから、爆発的な伸びを見せています。
こちらもnpm trendsで確認した比較は以下のようになりました。(2026年1月時点)
こちらは更に差がついており、ZodとYupの間には約8倍の開きがあります。
これらの結果から、React Hook Form + Zodの組み合わせが主流になっていることがわかります。
私の現場では先に述べたとおりFormik + Yupを使っています。
少しトレンドとは外れますがどちらも10年ほど前からあり、npm trendsを見ても分かる通り、増えてはいませんが減ってもいません。根強い支持があることがわかります。
ここ2、3年の流行りから考えると新しいプロジェクトの場合には、React Hook Form + Zodの組み合わせが良さそうです。
ただし、Formik + Yupがダメかというとそういうわけではありません。
また、技術の流行り廃りは常に起きうるため、React Hook Form + Zodの組み合わせが3年後も安泰かというとわからない部分があります。
大切なのはそれぞれのライブラリの特性を理解し、自身のプロジェクトにとって何が適しているのかを判断することです。
今回はサンプルフォームの実装からフォームの構成要素を分解し、それぞれの要素を作るためのライブラリには何があるのかをざっと確認しました。
トレンドだけで選ぶならReact Hook Form + Zodが良さそうということがわかりましたが、なぜ良いとされているのかまではわかっていません。
React Hook Form、Zodが広く受け入れられているのには何か理由があるはずです。
次回以降の記事ではReact Hook FormとFormikの違い、ZodとYupの違いを詳細に確認し、その良さを理解します。
最後にライブラリの適切な組み合わせ方を解説して、フォーム作成の基本がしっかり身につくようにしていく予定です。