2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【個人開発】ゲームBGM投票Webアプリ #1 フォームバリデーション

2
Last updated at Posted at 2026-05-27

表現の改善にAIを活用しています
あらかじめご了承ください。

はじめに

フォームのバリデーションは複数の画面で共通して発生しているため、各コンポーネント内に都度実装すると、同じようなロジックが散在してしまいます。
その結果、仕様変更時に修正箇所が増え、バグが発生しやすくなります。

そのため、このプロジェクトではバリデーションロジックをカスタムHookとして切り出し、再利用性と保守性を高める設計にしました。

フォームバリデーションHookの設計

引数設計

useFormValidation.js
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

事前定義済みスキーマ

メールなどよく出る項目のスキーマを定数として定義しておくと、重複コードを削減できます。

dataSchema.js
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
    },
    ...
};

使用例

Form.jsx
import { SCHEMA } from './dataSchema';

const formSchema = {
    id: SCHEMA.ID
    email: SCHEMA.EMAIL,
};

バリデーションタイミング

すべての項目を初期状態からバリデーションすると、以下の問題が発生します。

  • 画面を開いた瞬間にエラーが大量表示される
  • ユーザー体験が悪い(何も入力していない段階でエラーになる)
  • 不要な再レンダリングや検証が発生する
そのため、本実装では touched ベースのバリデーション を採用します。

touched ベースのバリデーション

touched は「ユーザーがそのフィールドを一度操作したかどうか」を表す状態です。

一般的には以下のような制御を行います。

  • touched になるまではエラーを表示しない
  • touched になった後のみバリデーションを実行する
具体的な流れは以下の通りです。
  1. 初期状態ではエラーを表示しない
  2. onBlur 発生時に touched 状態へ変更する
  3. touched 状態のフィールドのみバリデーション対象にする
  4. エラーが存在する場合のみメッセージを表示する
この方式により、ユーザーがまだ入力していない項目に対して不要なエラー表示を避けられます。

フォーム送信時のバリデーション

ただし、touched ベースのバリデーションだけでは不十分です。
例えば、ユーザーが一度も入力欄を触らずにフォーム送信した場合、未検証のまま送信できてしまう可能性があります。
そのため、フォーム送信時には touched 状態に関係なく、すべてのフィールドに対してバリデーションを実行する必要があります。
つまり、本実装では以下の2段階で検証を行います。

  • 通常入力時:touched 状態の項目のみ検証
  • フォーム送信時:全項目を検証

State設計

useFormValidation.js
export function useFormValidation(formSchema) {
    const [touched, setTouched] = useState({});
    const [serverErrors, setServerErrors] = useState({});
}

touched

touched は、各フィールドが「ユーザーによって操作済みかどうか」を管理する state です。
例えば以下のような構造になります。

{
  name: true,
  email: true
}

serverErrors

serverErrors は、サーバーサイドバリデーションによって返されたエラーを保持する state です。
例えば、

  • メールアドレスの重複
  • 既に存在するユーザー名
  • 権限エラー
など、クライアント側では判定できないエラーを保持する用途で使用します。
{
  email: "既に使用されているメールアドレスです"
}

クライアントバリデーションとサーバーバリデーションを分離して管理することで、責務を明確にできます。

関数設計

バリデーション関数

useFormValidation.js
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 に依存しない純粋関数として設計したい
  • テストしやすくしたい
  • バリデーションルールを一元管理したい
つまり、Hookは「フォーム状態管理」を担当し、実際の検証ロジックは別レイヤーに分離しています。

validate の役割

validators.js
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 時の処理

useFormValidation.js
 const handleBlur = useCallback((e) => {
        const { name } = e.target;
        setTouched(prev => ({ ...prev, [name]: true }));
    }, []);

入力要素で onBlur が発生した際、そのフィールドを touched 状態に変更します。

エラー取得関数

useFormValidation.js
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 を通して行います。
この関数では、以下の優先順位でエラーを判定します。

  1. サーバーサイドエラー
  2. touched 状態判定
  3. フロントエンドバリデーション

サーバーサイドエラーのクリア

このままでは、一度サーバーエラーが発生すると、その後ユーザーが入力内容を修正しても、古いエラーが残り続けてしまいます。

そのため、入力値変更時に該当フィールドのサーバーエラーをクリアする必要があります。

// 入力内容が変わるとサーバーサイドエラーをクリアします
 const onChange = (e) => {
    const { name, value } = e.target;
    ...
    clearServerError(name);
}

フォーム送信時の処理

useFormValidation.js
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 状態に変更し、エラーメッセージを表示します。

サーバーサイドエラーをクリアする関数

useFormValidation.js
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 に渡して利用します。

AddBgmModal.jsx
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

middleware/validators.js
// フロントエンドの 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 を共有することで、バリデーションルールの重複定義を避けています。

使用例

routes/bgm.js
// 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 開発を学習中の段階で設計したものなので、不十分な点や改善できる点も多いと思います。
「この実装のほうが良い」「こういう設計もある」といったご意見やフィードバックがあれば、ぜひ教えていただけると嬉しいです。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?