表現の改善にAIを活用しています
あらかじめご了承ください。
はじめに
フォームのバリデーションは複数の画面で共通して発生しているため、各コンポーネント内に都度実装すると、同じようなロジックが散在してしまいます。
その結果、仕様変更時に修正箇所が増え、バグが発生しやすくなります。
そのため、このプロジェクトではバリデーションロジックをカスタムHookとして切り出し、再利用性と保守性を高める設計にしました。
フォームバリデーションHookの設計
引数設計
export function useFormValidation(formSchema) { ... }
本Hookは formSchema を引数として受け取り、各フォーム項目のバリデーションルールを一元管理します。
formSchema は以下のようなオブジェクトです。
{
id: {
type: 'number',
required: true,
nonNullable: true,
integerOnly: true
},
name: {
type: 'string',
minLength: 1,
maxLength: 100
}
}
formSchemaの構造
formSchema は 「フィールド名 → バリデーション定義」 のマッピングです。
- key:フォームのフィールド名
- value:そのフィールドのバリデーションルール(スキーマ)
スキーマの構成要素
スキーマは大きく以下の2種類のプロパティで構成されます。
1.共通プロパティ(全タイプ共通)
すべてのフィールドで利用できる基本ルールです。
- type データ型(string, number, boolean, array, object など)
- required undefined を許可するかどうか
- nonNullable null を許可するかどうか
- validateFn カスタムバリデーション関数、プロパティでは対応できない時の最終手段
2. 型固有プロパティ
type に応じて追加できる制約です。
string型の場合
- minLength
- maxLength
- regex
number型の場合
- allowString(文字列数値の許可)
- integerOnly(整数のみ許可)
- minValue
- maxValue
事前定義済みスキーマ
メールなどよく出る項目のスキーマを定数として定義しておくと、重複コードを削減できます。
export const SCHEMA = {
EMAIL: {
type: 'string', required: true, nonNullable: true,
minLength: 6, maxLength: 254,
specificType: 'email', regex: EMAIL_REGEX
},
ID: {
type: 'number', required: true, nonNullable: true,
integerOnly: true
},
...
};
使用例
import { SCHEMA } from './dataSchema';
const formSchema = {
id: SCHEMA.ID
email: SCHEMA.EMAIL,
};
バリデーションタイミング
すべての項目を初期状態からバリデーションすると、以下の問題が発生します。
- 画面を開いた瞬間にエラーが大量表示される
- ユーザー体験が悪い(何も入力していない段階でエラーになる)
- 不要な再レンダリングや検証が発生する
touched ベースのバリデーション
touched は「ユーザーがそのフィールドを一度操作したかどうか」を表す状態です。
一般的には以下のような制御を行います。
- touched になるまではエラーを表示しない
- touched になった後のみバリデーションを実行する
- 初期状態ではエラーを表示しない
- onBlur 発生時に touched 状態へ変更する
- touched 状態のフィールドのみバリデーション対象にする
- エラーが存在する場合のみメッセージを表示する
フォーム送信時のバリデーション
ただし、touched ベースのバリデーションだけでは不十分です。
例えば、ユーザーが一度も入力欄を触らずにフォーム送信した場合、未検証のまま送信できてしまう可能性があります。
そのため、フォーム送信時には touched 状態に関係なく、すべてのフィールドに対してバリデーションを実行する必要があります。
つまり、本実装では以下の2段階で検証を行います。
- 通常入力時:touched 状態の項目のみ検証
- フォーム送信時:全項目を検証
State設計
export function useFormValidation(formSchema) {
const [touched, setTouched] = useState({});
const [serverErrors, setServerErrors] = useState({});
}
touched
touched は、各フィールドが「ユーザーによって操作済みかどうか」を管理する state です。
例えば以下のような構造になります。
{
name: true,
email: true
}
serverErrors
serverErrors は、サーバーサイドバリデーションによって返されたエラーを保持する state です。
例えば、
- メールアドレスの重複
- 既に存在するユーザー名
- 権限エラー
{
email: "既に使用されているメールアドレスです"
}
クライアントバリデーションとサーバーバリデーションを分離して管理することで、責務を明確にできます。
関数設計
バリデーション関数
import { validate } from '@shared/validators';
const validateField = useCallback((field, formData) =>
validate(formData[field], formSchema[field], formData), [formSchema]);
validateField 自体は薄いラッパ関数で、実際の検証処理は共通の validate 関数へ委譲しています。
実際の検証ロジックを持たせないことで、Hook側をシンプルに保っています。
validate を分離した理由
バリデーションの本体である validate 関数は、Hook内部ではなく共有モジュールとして切り出しています。
理由は以下の通りです。
- frontend / backend の両方で利用したい
- React に依存しない純粋関数として設計したい
- テストしやすくしたい
- バリデーションルールを一元管理したい
validate の役割
export function validate(data, schema, formData = null){
if (schema === null || schema === undefined) {
return { isValid: true };
}
const { type, required, nonNullable, validateFn } = schema;
if (type === 'string') { ... } else ...
if (typeof validateFn === 'function') {
return validateFn(normalizedData, formData);
}
return { isValid: true };
}
validate 関数は、
- データ型検証
- 必須チェック
- 型別制約(文字数・数値範囲など)
- カスタムバリデーション
また、validateFn を定義できるようにすることで、スキーマだけでは表現しづらい独自ルールにも対応できます。
onBlur 時の処理
const handleBlur = useCallback((e) => {
const { name } = e.target;
setTouched(prev => ({ ...prev, [name]: true }));
}, []);
入力要素で onBlur が発生した際、そのフィールドを touched 状態に変更します。
エラー取得関数
const getFieldError = useCallback((field, formData) => {
if (serverErrors[field]) return serverErrors[field];
if (!touched[field]) return null;
const result = validateField(field, formData);
return result.isValid ? null : result.error;
}, [touched, serverErrors, validateField]);
エラー表示は getFieldError を通して行います。
この関数では、以下の優先順位でエラーを判定します。
- サーバーサイドエラー
- touched 状態判定
- フロントエンドバリデーション
サーバーサイドエラーのクリア
このままでは、一度サーバーエラーが発生すると、その後ユーザーが入力内容を修正しても、古いエラーが残り続けてしまいます。
そのため、入力値変更時に該当フィールドのサーバーエラーをクリアする必要があります。
// 入力内容が変わるとサーバーサイドエラーをクリアします
const onChange = (e) => {
const { name, value } = e.target;
...
clearServerError(name);
}
フォーム送信時の処理
const validateAllFields = useCallback((fields, formData) => {
const hasError = fields.some(field => {
// サーバーサイドエラーが残っている場合はエラー扱い
if (serverErrors[field]) return true;
// クライアント側バリデーションを実行
const result = validateField(field, formData);
return !result.isValid;
});
if (hasError) {
// 全項目を touched 状態にしてエラーメッセージを表示
const allTouched = fields.reduce((acc, field) => {
acc[field] = true;
return acc;
}, {});
setTouched((prev) => ({ ...prev, ...allTouched }));
return false;
}
return true;
}, [serverErrors, validateField]);
フォーム送信時は、全入力項目に対してバリデーションを実行します。
エラーが存在する場合は、すべての項目を touched 状態に変更し、エラーメッセージを表示します。
サーバーサイドエラーをクリアする関数
const clearServerError = useCallback((fieldName) => {
setServerErrors(prev => {
// 対象のエラーが存在しない場合はそのまま返す
if (!prev[fieldName]) return prev;
// 指定した項目のエラーのみ削除
const { [fieldName]: _, ...rest } = prev;
return rest;
});
}, []);
入力値が変更されたタイミングで呼び出し、対象フィールドのサーバーサイドエラーをクリアします。
バリデーション状態のリセット
const resetValidation = useCallback(() => {
setTouched({});
setServerErrors({});
}, []);
touched 状態とサーバーサイドエラーをすべて初期化します。
Hook の使用例
Hook から返された値や関数を、各入力要素の props に渡して利用します。
import { SCHEMA } from '@shared/dataSchema';
import { useEntitySuggestions } from '@/hooks/useEntitySuggestions.js';
const formSchema = {
language: SCHEMA.SUPPORTED_LANGUAGE,
name: { ...SCHEMA.NAME, required: true, nonNullable: true },
userComment: SCHEMA.TEXT
};
export default function AddBgmModal() {
const [formData, setFormData] = useState({
language: '',
name: '',
userComment: ''
});
const {
handleBlur,
getFieldError,
validateAllFields,
setServerErrors,
clearServerError,
resetValidation
} = useFormValidation(singleFormSchema);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
// 入力変更時にサーバーサイドエラーをクリア
clearServerError(name);
}
const handleSubmit = (e) => {
// 全項目のバリデーションを実行
const isValid = validateAllFields(...);
if (!isValid) return;
// バリデーション成功時のみ API を実行
const res = await fetch(...);
// サーバー側エラーをフォームに反映
setServerErrors(prev => ({
...prev,
...res.xxx
}));
}
<FormInput
name='name'
label='bgmName'
value={formData.name}
error={getFieldError('name', formData)}
required={true}
onChange={handleChange}
onBlur={handleBlur}
/>
...
}
backend側のバリデーション
backend 側でもバリデーションを行う必要性
開発初期の私は、「フォームのバリデーションはフロントエンドだけで十分」と考えていました。
しかし実際には、バックエンド側でも必ずバリデーションを行う必要があります。
フロントエンドは、ユーザーが入力した値を整理し、HTTP リクエストとして送信しているだけです。
最終的にバックエンドへ届くのは単なる HTTP リクエストであり、UI を経由しなくても直接送信できます。
//ターミナルから直接リクエストを投げる
curl -X POST https://api.example.com/login \
-d "username=tarou" \
-d "password=secure_password"
そのため、フロントエンド側でどれだけ厳密な入力制限(文字数制限、必須チェック、選択肢の固定など)を実装していても、悪意のあるユーザーはそれらを簡単に回避できます。
たとえば、JavaScript のバリデーションを無視して不正な値を送信したり、本来想定していないデータを直接 API に投げたりすることが可能です。
つまり、システムを安全に保つためには、「受け取ったデータを最終的に信用しない」という前提で、バックエンド側でも必ずバリデーションを実行する必要があります。
実装
このプロジェクトでは、backend に Express.js を採用しています。
リクエストの検証は middleware として実装しました。
バリデーションmiddleware
// フロントエンドの useFormValidation と同じ schema 構造を受け取る
export function validateRequestParam(formSchema) {
return async (req, res, next) => {
try {
let params;
if (req.method === 'GET') {
params = { ...req.query };
} else {
params = { ...req.body, ...req.query };
}
for (const field of Object.keys(formSchema)) {
// shared module の validate 関数を使用
const { isValid, error } = validate(params[field], formSchema[field], params);
// バリデーション失敗時は 400 Bad Request を返す
if (isValid === false) {
return res.status(400).json(...);
}
}
// すべての検証を通過した場合のみ次の middleware へ進む
return next();
} catch (err) {
...
}
}
}
フロントエンドとバックエンドで同じ schema を共有することで、バリデーションルールの重複定義を避けています。
使用例
// BGM データ取得用 router
const schema__get = {
id: SCHEMA.ID,
language: SCHEMA.SUPPORTED_LANGUAGE
};
router.get('/',
verifySession,
validateRequestParam(schema__get),
async (req, res) => { ... }
)
route ごとに schema を定義し、middleware に渡すだけで入力値を検証できます。
これにより、各 API に個別のバリデーション処理を書く必要がなくなりました。
最後に
今回は、プロジェクトで実装したフォームバリデーションの設計について紹介しました。
まだ Web 開発を学習中の段階で設計したものなので、不十分な点や改善できる点も多いと思います。
「この実装のほうが良い」「こういう設計もある」といったご意見やフィードバックがあれば、ぜひ教えていただけると嬉しいです。