T-CREATOR

Vue.js で作るモダンなフォームバリデーション

Vue.js で作るモダンなフォームバリデーション

フォームバリデーションは、Web アプリケーションにおいてユーザーが入力したデータの品質を保証し、セキュリティリスクを軽減する重要な機能です。特に現代の Web アプリケーションでは、ユーザビリティとセキュリティの両立が求められています。

Vue.js 3 では、Composition API の導入により、従来の Options API と比べてより柔軟で再利用性の高いバリデーション機能を実装できるようになりました。リアクティブな状態管理と組み合わせることで、ユーザーが入力する瞬間からリアルタイムでフィードバックを提供し、優れたユーザー体験を実現できます。

本記事では、Vue.js 3 を使用したモダンなフォームバリデーションの実装方法を、基礎から応用まで段階的に解説します。HTML5 の標準機能から始まり、カスタムバリデーション関数、動的フォーム、UX 配慮、パフォーマンス最適化まで、実際のプロダクション環境で使用可能な実装パターンをご紹介しますね。

基本的なバリデーション実装

まずは、Vue.js 3 でフォームバリデーションを実装する基本的なアプローチから始めましょう。モダンなフォームバリデーションでは、HTML5 の標準機能を活用しつつ、JavaScript による詳細な制御を組み合わせることが重要です。

標準 HTML5 バリデーションの活用

HTML5 には多くの便利なバリデーション機能が組み込まれています。これらの機能を Vue.js と組み合わせることで、効率的なバリデーションを実現できます。

html<template>
  <form @submit.prevent="handleSubmit" novalidate>
    <div class="form-group">
      <label for="email">メールアドレス</label>
      <input
        id="email"
        v-model="formData.email"
        type="email"
        required
        minlength="5"
        maxlength="100"
        @blur="validateField('email')"
        @input="clearFieldError('email')"
        :class="{ 'is-invalid': errors.email }"
      />
      <div v-if="errors.email" class="error-message">
        {{ errors.email }}
      </div>
    </div>

    <div class="form-group">
      <label for="password">パスワード</label>
      <input
        id="password"
        v-model="formData.password"
        type="password"
        required
        minlength="8"
        pattern="^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*#?&]{8,}$"
        @blur="validateField('password')"
        @input="clearFieldError('password')"
        :class="{ 'is-invalid': errors.password }"
      />
      <div v-if="errors.password" class="error-message">
        {{ errors.password }}
      </div>
    </div>

    <button type="submit" :disabled="!isFormValid">
      送信
    </button>
  </form>
</template>

この基本的な実装では、HTML5 のrequiredminlengthmaxlengthpattern属性を使用しています。また、novalidate属性でブラウザデフォルトのバリデーションを無効化し、Vue.js で制御しています。

Composition API でのリアクティブバリデーション

次に、Composition API を使用してリアクティブなバリデーション状態を管理する実装を見てみましょう。

html<script setup lang="ts">
import { ref, reactive, computed } from 'vue'

interface FormData {
  email: string
  password: string
  username: string
}

// フォームデータの管理
const formData = reactive<FormData>({
  email: '',
  password: '',
  username: ''
})

// エラー状態の管理
const errors = reactive<Partial<Record<keyof FormData, string>>>({})

// バリデーション中の状態
const validating = ref<Set<keyof FormData>>(new Set())

// 個別フィールドのバリデーション関数
const validateEmail = (email: string): string | null => {
  if (!email) return 'メールアドレスを入力してください'
  if (email.length < 5) return 'メールアドレスは5文字以上で入力してください'
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
  if (!emailRegex.test(email)) return '有効なメールアドレス形式で入力してください'
  return null
}

const validatePassword = (password: string): string | null => {
  if (!password) return 'パスワードを入力してください'
  if (password.length < 8) return 'パスワードは8文字以上で入力してください'
  const hasLetter = /[A-Za-z]/.test(password)
  const hasNumber = /\d/.test(password)
  if (!hasLetter || !hasNumber) {
    return 'パスワードには英字と数字を含めてください'
  }
  return null
}

この実装では、リアクティブな状態管理を活用して、フォームの状態変化に応じて自動的に UI が更新されるようになっています。

リアルタイムバリデーションの実装

ユーザビリティを向上させるために、入力と同時にバリデーションを実行するリアルタイムバリデーションを実装しましょう。

html<script setup lang="ts">
// ... 前のコードに続いて

// フィールド別バリデーション実行
const validateField = async (
  field: keyof FormData
): Promise<void> => {
  validating.value.add(field);

  try {
    let error: string | null = null;

    switch (field) {
      case 'email':
        error = validateEmail(formData.email);
        break;
      case 'password':
        error = validatePassword(formData.password);
        break;
      case 'username':
        // 非同期バリデーション(後で詳しく説明)
        error = await validateUsername(formData.username);
        break;
    }

    if (error) {
      errors[field] = error;
    } else {
      delete errors[field];
    }
  } catch (err) {
    console.error(`Validation error for ${field}:`, err);
    errors[field] =
      'バリデーション中にエラーが発生しました';
  } finally {
    validating.value.delete(field);
  }
};

// エラーメッセージのクリア
const clearFieldError = (field: keyof FormData): void => {
  if (errors[field]) {
    delete errors[field];
  }
};

// フォーム全体の有効性判定
const isFormValid = computed((): boolean => {
  const hasRequiredFields =
    formData.email &&
    formData.password &&
    formData.username;
  const hasNoErrors = Object.keys(errors).length === 0;
  const isNotValidating = validating.value.size === 0;

  return (
    hasRequiredFields && hasNoErrors && isNotValidating
  );
});

// フォーム送信処理
const handleSubmit = async (): Promise<void> => {
  // 全フィールドを再検証
  await Promise.all([
    validateField('email'),
    validateField('password'),
    validateField('username'),
  ]);

  if (!isFormValid.value) {
    console.error('Form validation failed');
    return;
  }

  try {
    // API送信処理
    console.log('Submitting form:', formData);
  } catch (error) {
    console.error('Form submission error:', error);
  }
};
</script>

このリアルタイムバリデーションの実装では、ユーザーがフィールドから離れた瞬間(@blur)にバリデーションを実行し、入力中(@input)はエラーメッセージをクリアしています。これにより、ユーザーの操作を妨げることなく、適切なタイミングでフィードバックを提供できます。

カスタムバリデーション関数の作成

標準的なバリデーション機能だけでは対応できない、独自のビジネスロジックに基づいたバリデーションを実装していきましょう。カスタムバリデーション関数により、アプリケーション固有の要件に対応できます。

独自バリデーションルールの定義

ビジネス要件に応じた独自のバリデーションルールを作成する方法です。再利用性を考慮した設計を心がけましょう。

typescript// composables/useValidation.ts
import { ref } from 'vue';

export type ValidationRule<T = any> = (
  value: T
) => string | null;
export type AsyncValidationRule<T = any> = (
  value: T
) => Promise<string | null>;

// 日本語の名前バリデーション
export const validateJapaneseName: ValidationRule<
  string
> = (name: string) => {
  if (!name.trim()) return '名前を入力してください';

  // ひらがな、カタカナ、漢字、英字のみ許可
  const japaneseNameRegex =
    /^[ひらがなカタカナ漢字a-zA-Z\s]+$/u;
  if (!japaneseNameRegex.test(name)) {
    return '名前はひらがな、カタカナ、漢字、英字のみ使用可能です';
  }

  if (name.length < 2 || name.length > 50) {
    return '名前は2文字以上50文字以下で入力してください';
  }

  return null;
};

// 電話番号バリデーション(日本の形式)
export const validatePhoneNumber: ValidationRule<string> = (
  phone: string
) => {
  if (!phone) return '電話番号を入力してください';

  // ハイフンを除去して数字のみにする
  const cleanPhone = phone.replace(/-/g, '');

  // 日本の電話番号形式(固定電話・携帯電話)
  const phoneRegex = /^(0[5-9]\d{8}|0[1-4]\d{9})$/;
  if (!phoneRegex.test(cleanPhone)) {
    return '正しい電話番号形式で入力してください(例:090-1234-5678)';
  }

  return null;
};

// パスワード強度チェック
export const validatePasswordStrength: ValidationRule<
  string
> = (password: string) => {
  if (!password) return 'パスワードを入力してください';

  const checks = [
    { regex: /.{8,}/, message: '8文字以上' },
    { regex: /[A-Z]/, message: '大文字を含む' },
    { regex: /[a-z]/, message: '小文字を含む' },
    { regex: /\d/, message: '数字を含む' },
    {
      regex: /[!@#$%^&*(),.?":{}|<>]/,
      message: '特殊文字を含む',
    },
  ];

  const failedChecks = checks.filter(
    (check) => !check.regex.test(password)
  );

  if (failedChecks.length > 0) {
    const missing = failedChecks
      .map((check) => check.message)
      .join('、');
    return `パスワードには以下が必要です: ${missing}`;
  }

  return null;
};

これらのカスタムバリデーション関数は、ビジネス要件に特化した検証ロジックを実装しています。関数型で設計することで、テストしやすく再利用可能な構造になっています。

非同期バリデーション(API 確認)

サーバーサイドでの重複チェックなど、非同期処理が必要なバリデーションを実装しましょう。

typescript// composables/useAsyncValidation.ts
import { ref } from 'vue';

// APIエラーの型定義
interface ApiError {
  message: string;
  code?: string;
}

// ユーザー名の重複チェック(非同期)
export const validateUsernameAvailability: AsyncValidationRule<
  string
> = async (username: string) => {
  if (!username) return 'ユーザー名を入力してください';

  // 基本的なフォーマットチェック
  if (username.length < 3)
    return 'ユーザー名は3文字以上で入力してください';
  if (username.length > 20)
    return 'ユーザー名は20文字以下で入力してください';

  const usernameRegex = /^[a-zA-Z0-9_-]+$/;
  if (!usernameRegex.test(username)) {
    return 'ユーザー名は英数字、アンダースコア、ハイフンのみ使用可能です';
  }

  try {
    // API呼び出しでユーザー名の重複確認
    const response = await fetch(
      `/api/users/check-username`,
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ username }),
      }
    );

    if (!response.ok) {
      if (response.status === 409) {
        return 'このユーザー名は既に使用されています';
      }
      throw new Error(
        `HTTP error! status: ${response.status}`
      );
    }

    const result = await response.json();
    return result.available
      ? null
      : 'このユーザー名は既に使用されています';
  } catch (error) {
    console.error('Username validation error:', error);
    return 'ユーザー名の確認中にエラーが発生しました。しばらく後にお試しください';
  }
};

// メールアドレスの重複チェック
export const validateEmailAvailability: AsyncValidationRule<
  string
> = async (email: string) => {
  // まず基本的なメール形式をチェック
  const basicEmailError = validateEmail(email);
  if (basicEmailError) return basicEmailError;

  try {
    const response = await fetch(`/api/users/check-email`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ email }),
    });

    if (response.status === 409) {
      return 'このメールアドレスは既に登録されています';
    }

    if (!response.ok) {
      throw new Error(
        `HTTP error! status: ${response.status}`
      );
    }

    return null;
  } catch (error) {
    if (
      error instanceof TypeError &&
      error.message.includes('Failed to fetch')
    ) {
      return 'ネットワークエラーが発生しました。接続を確認してください';
    }

    console.error('Email validation error:', error);
    return 'メールアドレスの確認中にエラーが発生しました';
  }
};

非同期バリデーションでは、ネットワークエラーやサーバーエラーなど、様々な例外状況を適切にハンドリングすることが重要です。

複合条件バリデーション

複数のフィールドの値を組み合わせたバリデーションや、条件によって変わるバリデーションルールを実装しましょう。

html<script setup lang="ts">
// パスワード確認フィールドの実装例
const formData = reactive({
  password: '',
  passwordConfirm: '',
  birthDate: '',
  agreementAccepted: false,
  accountType: 'personal' as 'personal' | 'business',
});

// パスワード確認のバリデーション
const validatePasswordConfirmation = (
  password: string,
  confirmation: string
): string | null => {
  if (!confirmation)
    return 'パスワード確認を入力してください';

  if (password !== confirmation) {
    return 'パスワードが一致しません';
  }

  return null;
};

// 年齢制限バリデーション
const validateAge = (birthDate: string): string | null => {
  if (!birthDate) return '生年月日を入力してください';

  const birth = new Date(birthDate);
  const today = new Date();
  const age = today.getFullYear() - birth.getFullYear();

  // 誕生日前なら1歳減らす
  const monthDiff = today.getMonth() - birth.getMonth();
  const dayDiff = today.getDate() - birth.getDate();
  const adjustedAge =
    monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)
      ? age - 1
      : age;

  if (adjustedAge < 18) {
    return '18歳以上の方のみご利用いただけます';
  }

  if (adjustedAge > 120) {
    return '正しい生年月日を入力してください';
  }

  return null;
};

// アカウントタイプに応じた条件付きバリデーション
const validateBusinessInfo = (
  accountType: string,
  companyName?: string
): string | null => {
  if (accountType === 'business') {
    if (!companyName || companyName.trim().length === 0) {
      return '法人アカウントの場合、会社名は必須です';
    }

    if (
      companyName.length < 2 ||
      companyName.length > 100
    ) {
      return '会社名は2文字以上100文字以下で入力してください';
    }
  }

  return null;
};

// 利用規約同意のバリデーション
const validateAgreement = (
  accepted: boolean
): string | null => {
  if (!accepted) {
    return '利用規約に同意してください';
  }
  return null;
};
</script>

このような複合条件バリデーションでは、フィールド間の依存関係を明確にし、ユーザーに分かりやすいエラーメッセージを提供することが重要です。次のセクションでは、これらの基本的な実装をさらに発展させた高度なフォームパターンについて解説します。

高度なフォームパターン

実際の Web アプリケーションでは、静的なフォームだけでなく、ユーザーの操作に応じて動的に変化するフォームや、複雑なデータ構造を扱うフォームが必要になります。ここでは、そういった高度なフォームパターンの実装方法をご紹介します。

動的フィールドのバリデーション

ユーザーがフィールドを追加・削除できる動的フォームでは、配列の要素に対するバリデーションが重要です。

html<template>
  <form @submit.prevent="handleSubmit">
    <h3>スキル登録フォーム</h3>

    <div
      v-for="(skill, index) in formData.skills"
      :key="skill.id"
      class="skill-item"
    >
      <div class="skill-row">
        <div class="field-group">
          <label>スキル名</label>
          <input
            v-model="skill.name"
            type="text"
            placeholder="JavaScript, Python など"
            @blur="validateSkillName(index)"
            @input="clearSkillError(index, 'name')"
            :class="{
              'is-invalid': getSkillError(index, 'name'),
            }"
          />
          <div
            v-if="getSkillError(index, 'name')"
            class="error-message"
          >
            {{ getSkillError(index, 'name') }}
          </div>
        </div>

        <div class="field-group">
          <label>経験年数</label>
          <select
            v-model="skill.experience"
            @change="validateSkillExperience(index)"
            :class="{
              'is-invalid': getSkillError(
                index,
                'experience'
              ),
            }"
          >
            <option value="">選択してください</option>
            <option value="beginner">1年未満</option>
            <option value="intermediate">1-3年</option>
            <option value="advanced">3-5年</option>
            <option value="expert">5年以上</option>
          </select>
          <div
            v-if="getSkillError(index, 'experience')"
            class="error-message"
          >
            {{ getSkillError(index, 'experience') }}
          </div>
        </div>

        <button
          type="button"
          @click="removeSkill(index)"
          :disabled="formData.skills.length <= 1"
          class="remove-button"
        >
          削除
        </button>
      </div>
    </div>

    <button
      type="button"
      @click="addSkill"
      class="add-button"
    >
      スキルを追加
    </button>

    <div class="form-actions">
      <button type="submit" :disabled="!isFormValid">
        保存
      </button>
    </div>
  </form>
</template>

<script setup lang="ts">
import { reactive, computed } from 'vue';

interface Skill {
  id: string;
  name: string;
  experience: string;
}

interface DynamicFormData {
  skills: Skill[];
}

// フォームデータの初期化
const formData = reactive<DynamicFormData>({
  skills: [{ id: generateId(), name: '', experience: '' }],
});

// エラー状態の管理(配列形式)
const skillErrors = reactive<
  Record<string, Record<string, string>>
>({});

// ユニークIDの生成
const generateId = (): string => {
  return (
    Date.now().toString(36) +
    Math.random().toString(36).substr(2)
  );
};

// スキルの追加
const addSkill = (): void => {
  formData.skills.push({
    id: generateId(),
    name: '',
    experience: '',
  });
};

// スキルの削除
const removeSkill = (index: number): void => {
  if (formData.skills.length <= 1) return;

  const skill = formData.skills[index];
  formData.skills.splice(index, 1);

  // エラー状態もクリア
  delete skillErrors[skill.id];
};
</script>

この動的フォームでは、各スキルアイテムにユニークな ID を割り当て、エラー状態を適切に管理しています。

スキルバリデーション関数の実装

動的フィールドのバリデーション関数を実装しましょう。

html<script setup lang="ts">
// ... 前のコードに続いて

// スキル名のバリデーション
const validateSkillName = (index: number): void => {
  const skill = formData.skills[index];
  const name = skill.name.trim();

  if (!skillErrors[skill.id]) {
    skillErrors[skill.id] = {};
  }

  if (!name) {
    skillErrors[skill.id]['name'] =
      'スキル名を入力してください';
    return;
  }

  if (name.length < 2) {
    skillErrors[skill.id]['name'] =
      'スキル名は2文字以上で入力してください';
    return;
  }

  // 重複チェック
  const duplicateIndex = formData.skills.findIndex(
    (s, i) =>
      i !== index &&
      s.name.toLowerCase() === name.toLowerCase()
  );

  if (duplicateIndex !== -1) {
    skillErrors[skill.id]['name'] =
      'このスキルは既に登録されています';
    return;
  }

  delete skillErrors[skill.id]['name'];
};

// 経験年数のバリデーション
const validateSkillExperience = (index: number): void => {
  const skill = formData.skills[index];

  if (!skillErrors[skill.id]) {
    skillErrors[skill.id] = {};
  }

  if (!skill.experience) {
    skillErrors[skill.id]['experience'] =
      '経験年数を選択してください';
  } else {
    delete skillErrors[skill.id]['experience'];
  }
};

// エラー取得ヘルパー関数
const getSkillError = (
  index: number,
  field: string
): string | null => {
  const skill = formData.skills[index];
  return skillErrors[skill.id]?.[field] || null;
};

// エラークリア関数
const clearSkillError = (
  index: number,
  field: string
): void => {
  const skill = formData.skills[index];
  if (skillErrors[skill.id]?.[field]) {
    delete skillErrors[skill.id][field];
  }
};

// フォーム全体の有効性判定
const isFormValid = computed((): boolean => {
  // すべてのスキルに名前と経験年数が入力されているかチェック
  const allFieldsFilled = formData.skills.every(
    (skill) => skill.name.trim() && skill.experience
  );

  // エラーがないかチェック
  const hasNoErrors = Object.values(skillErrors).every(
    (skillError) => Object.keys(skillError).length === 0
  );

  return allFieldsFilled && hasNoErrors;
});
</script>

ネストしたオブジェクトの検証

複雑なデータ構造を持つフォームでのバリデーション実装を見てみましょう。

html<template>
  <form @submit.prevent="handleSubmit">
    <h3>プロフィール編集</h3>

    <!-- 基本情報 -->
    <fieldset>
      <legend>基本情報</legend>
      <div class="field-group">
        <label>氏名</label>
        <input
          v-model="formData.profile.name"
          type="text"
          @blur="validateNestedField('profile', 'name')"
          :class="{
            'is-invalid': getNestedError('profile', 'name'),
          }"
        />
        <div
          v-if="getNestedError('profile', 'name')"
          class="error-message"
        >
          {{ getNestedError('profile', 'name') }}
        </div>
      </div>
    </fieldset>

    <!-- 住所情報 -->
    <fieldset>
      <legend>住所</legend>
      <div class="field-group">
        <label>郵便番号</label>
        <input
          v-model="formData.address.zipCode"
          type="text"
          placeholder="123-4567"
          @blur="validateNestedField('address', 'zipCode')"
          :class="{
            'is-invalid': getNestedError(
              'address',
              'zipCode'
            ),
          }"
        />
        <div
          v-if="getNestedError('address', 'zipCode')"
          class="error-message"
        >
          {{ getNestedError('address', 'zipCode') }}
        </div>
      </div>

      <div class="field-group">
        <label>都道府県</label>
        <select
          v-model="formData.address.prefecture"
          @change="
            validateNestedField('address', 'prefecture')
          "
          :class="{
            'is-invalid': getNestedError(
              'address',
              'prefecture'
            ),
          }"
        >
          <option value="">選択してください</option>
          <option value="tokyo">東京都</option>
          <option value="osaka">大阪府</option>
          <!-- その他の都道府県 -->
        </select>
      </div>

      <div class="field-group">
        <label>市区町村・番地</label>
        <textarea
          v-model="formData.address.street"
          @blur="validateNestedField('address', 'street')"
          :class="{
            'is-invalid': getNestedError(
              'address',
              'street'
            ),
          }"
        ></textarea>
      </div>
    </fieldset>

    <!-- 連絡先情報 -->
    <fieldset>
      <legend>連絡先</legend>
      <div
        v-for="(contact, index) in formData.contacts"
        :key="contact.id"
        class="contact-item"
      >
        <div class="field-group">
          <label>種別</label>
          <select
            v-model="contact.type"
            @change="validateContactField(index, 'type')"
          >
            <option value="email">メール</option>
            <option value="phone">電話</option>
            <option value="fax">FAX</option>
          </select>
        </div>

        <div class="field-group">
          <label></label>
          <input
            v-model="contact.value"
            :type="
              contact.type === 'email' ? 'email' : 'text'
            "
            @blur="validateContactField(index, 'value')"
            :class="{
              'is-invalid': getContactError(index, 'value'),
            }"
          />
          <div
            v-if="getContactError(index, 'value')"
            class="error-message"
          >
            {{ getContactError(index, 'value') }}
          </div>
        </div>
      </div>
    </fieldset>
  </form>
</template>

<script setup lang="ts">
import { reactive } from 'vue';

interface Contact {
  id: string;
  type: 'email' | 'phone' | 'fax';
  value: string;
}

interface NestedFormData {
  profile: {
    name: string;
    email: string;
  };
  address: {
    zipCode: string;
    prefecture: string;
    street: string;
  };
  contacts: Contact[];
}

const formData = reactive<NestedFormData>({
  profile: {
    name: '',
    email: '',
  },
  address: {
    zipCode: '',
    prefecture: '',
    street: '',
  },
  contacts: [
    { id: generateId(), type: 'email', value: '' },
  ],
});

// ネストしたエラー状態の管理
const nestedErrors = reactive<
  Record<string, Record<string, string>>
>({
  profile: {},
  address: {},
  contacts: {},
});

// 連絡先のエラー状態
const contactErrors = reactive<
  Record<string, Record<string, string>>
>({});
</script>

条件付きバリデーション

ユーザーの選択や入力内容に応じて、バリデーションルールが動的に変わる実装です。

html<script setup lang="ts">
// ... 前のコードに続いて

// ネストしたフィールドのバリデーション
const validateNestedField = (
  section: keyof NestedFormData,
  field: string
): void => {
  if (section === 'profile') {
    validateProfileField(field);
  } else if (section === 'address') {
    validateAddressField(field);
  }
};

// プロフィールフィールドのバリデーション
const validateProfileField = (field: string): void => {
  const value =
    formData.profile[
      field as keyof typeof formData.profile
    ];

  switch (field) {
    case 'name':
      if (!value || value.trim().length === 0) {
        nestedErrors.profile[field] =
          '氏名を入力してください';
      } else if (value.length < 2) {
        nestedErrors.profile[field] =
          '氏名は2文字以上で入力してください';
      } else {
        delete nestedErrors.profile[field];
      }
      break;
  }
};

// 住所フィールドのバリデーション
const validateAddressField = (field: string): void => {
  const value =
    formData.address[
      field as keyof typeof formData.address
    ];

  switch (field) {
    case 'zipCode':
      if (!value) {
        nestedErrors.address[field] =
          '郵便番号を入力してください';
      } else if (!/^\d{3}-\d{4}$/.test(value)) {
        nestedErrors.address[field] =
          '郵便番号は123-4567の形式で入力してください';
      } else {
        delete nestedErrors.address[field];
      }
      break;

    case 'prefecture':
      if (!value) {
        nestedErrors.address[field] =
          '都道府県を選択してください';
      } else {
        delete nestedErrors.address[field];
      }
      break;

    case 'street':
      if (!value || value.trim().length === 0) {
        nestedErrors.address[field] =
          '市区町村・番地を入力してください';
      } else {
        delete nestedErrors.address[field];
      }
      break;
  }
};

// 連絡先のバリデーション(条件付き)
const validateContactField = (
  index: number,
  field: string
): void => {
  const contact = formData.contacts[index];

  if (!contactErrors[contact.id]) {
    contactErrors[contact.id] = {};
  }

  if (field === 'value') {
    // 種別に応じて異なるバリデーションを適用
    switch (contact.type) {
      case 'email':
        const emailError = validateEmail(contact.value);
        if (emailError) {
          contactErrors[contact.id][field] = emailError;
        } else {
          delete contactErrors[contact.id][field];
        }
        break;

      case 'phone':
      case 'fax':
        const phoneError = validatePhoneNumber(
          contact.value
        );
        if (phoneError) {
          contactErrors[contact.id][field] = phoneError;
        } else {
          delete contactErrors[contact.id][field];
        }
        break;
    }
  }
};

// ネストしたエラー取得ヘルパー
const getNestedError = (
  section: string,
  field: string
): string | null => {
  return nestedErrors[section]?.[field] || null;
};

// 連絡先エラー取得ヘルパー
const getContactError = (
  index: number,
  field: string
): string | null => {
  const contact = formData.contacts[index];
  return contactErrors[contact.id]?.[field] || null;
};
</script>

このような高度なフォームパターンでは、データ構造の複雑さに応じてバリデーションロジックも複雑になりますが、適切に構造化することで保守性を保つことができます。次のセクションでは、これらの複雑なフォームでもユーザビリティを損なわない UX 設計について解説します。

UX を考慮したバリデーション設計

優れたフォームバリデーションは、技術的な正確性だけでなく、ユーザーの体験を最優先に考えることが重要です。適切なタイミングでのフィードバック、視覚的な分かりやすさ、アクセシビリティの確保により、ユーザーがストレスなくフォームを完了できる設計を実現しましょう。

エラー表示のタイミング制御

エラーメッセージの表示タイミングは、ユーザビリティに大きく影響します。適切なタイミング制御を実装しましょう。

html<template>
  <form @submit.prevent="handleSubmit" class="smart-form">
    <div class="field-group">
      <label for="username">ユーザー名</label>
      <input
        id="username"
        v-model="formData.username"
        type="text"
        @blur="handleFieldBlur('username')"
        @input="handleFieldInput('username')"
        @focus="handleFieldFocus('username')"
        :class="getFieldClass('username')"
        :aria-describedby="getAriaDescribedBy('username')"
        :aria-invalid="hasFieldError('username')"
      />

      <!-- プログレッシブエラー表示 -->
      <div
        v-if="shouldShowError('username')"
        :id="`username-error`"
        class="error-message"
        role="alert"
      >
        {{ errors.username }}
      </div>

      <!-- 成功フィードバック -->
      <div
        v-if="shouldShowSuccess('username')"
        :id="`username-success`"
        class="success-message"
      >
        ✓ このユーザー名は利用可能です
      </div>

      <!-- ローディング状態 -->
      <div
        v-if="isValidating('username')"
        class="validation-loading"
      >
        <span class="spinner"></span>
        確認中...
      </div>
    </div>

    <div class="field-group">
      <label for="email">メールアドレス</label>
      <input
        id="email"
        v-model="formData.email"
        type="email"
        @blur="handleFieldBlur('email')"
        @input="handleFieldInput('email')"
        :class="getFieldClass('email')"
        :aria-describedby="getAriaDescribedBy('email')"
        :aria-invalid="hasFieldError('email')"
      />

      <!-- インラインヒント -->
      <div
        v-if="showHint('email')"
        :id="`email-hint`"
        class="field-hint"
      >
        例: user@example.com
      </div>

      <div
        v-if="shouldShowError('email')"
        :id="`email-error`"
        class="error-message"
        role="alert"
      >
        {{ errors.email }}
      </div>
    </div>
  </form>
</template>

<script setup lang="ts">
import { ref, reactive, computed } from 'vue';

// フィールドの状態管理
interface FieldState {
  touched: boolean;
  focused: boolean;
  blurred: boolean;
  hasInput: boolean;
  validationAttempted: boolean;
}

const formData = reactive({
  username: '',
  email: '',
});

const errors = reactive<Record<string, string>>({});
const validating = ref<Set<string>>(new Set());
const fieldStates = reactive<Record<string, FieldState>>({
  username: {
    touched: false,
    focused: false,
    blurred: false,
    hasInput: false,
    validationAttempted: false,
  },
  email: {
    touched: false,
    focused: false,
    blurred: false,
    hasInput: false,
    validationAttempted: false,
  },
});

// フィールドイベントハンドラー
const handleFieldFocus = (field: string): void => {
  fieldStates[field].focused = true;
  fieldStates[field].touched = true;
};

const handleFieldBlur = (field: string): void => {
  fieldStates[field].focused = false;
  fieldStates[field].blurred = true;

  // ブラー時にバリデーション実行(値が入力されている場合のみ)
  if (fieldStates[field].hasInput) {
    validateField(field);
  }
};

const handleFieldInput = (field: string): void => {
  fieldStates[field].hasInput =
    formData[field as keyof typeof formData].length > 0;

  // エラーがある場合は即座にクリア(リアルタイム修正フィードバック)
  if (
    errors[field] &&
    formData[field as keyof typeof formData]
  ) {
    clearFieldError(field);

    // 修正中のリアルタイムバリデーション(デバウンス付き)
    scheduleValidation(field);
  }
};

// バリデーション実行のスケジューリング
const validationTimeouts = ref<
  Record<string, NodeJS.Timeout>
>({});

const scheduleValidation = (field: string): void => {
  // 既存のタイムアウトをクリア
  if (validationTimeouts.value[field]) {
    clearTimeout(validationTimeouts.value[field]);
  }

  // 500ms後にバリデーション実行
  validationTimeouts.value[field] = setTimeout(() => {
    validateField(field);
  }, 500);
};
</script>

このタイミング制御により、ユーザーが入力中に邪魔されることなく、適切なタイミングでフィードバックを受け取れます。

エラー表示制御ロジック

エラー表示の判断ロジックを実装します。

html<script setup lang="ts">
// ... 前のコードに続いて

// エラー表示の判断
const shouldShowError = (field: string): boolean => {
  const state = fieldStates[field];
  const hasError = !!errors[field];

  if (!hasError) return false;

  // バリデーションが試行されていて、エラーがある場合
  if (state.validationAttempted && hasError) return true;

  // フィールドから離れた後で、入力があった場合
  if (state.blurred && state.hasInput && hasError)
    return true;

  // フォーム送信が試行された場合
  if (formSubmitAttempted.value && hasError) return true;

  return false;
};

// 成功表示の判断
const shouldShowSuccess = (field: string): boolean => {
  const state = fieldStates[field];
  const hasValue =
    formData[field as keyof typeof formData].length > 0;
  const hasNoError = !errors[field];
  const isNotValidating = !validating.value.has(field);

  return (
    state.validationAttempted &&
    hasValue &&
    hasNoError &&
    isNotValidating
  );
};

// ヒント表示の判断
const showHint = (field: string): boolean => {
  const state = fieldStates[field];
  const hasError = !!errors[field];
  const hasValue =
    formData[field as keyof typeof formData].length > 0;

  // フォーカス中で、エラーがなく、値が空の場合にヒントを表示
  return state.focused && !hasError && !hasValue;
};

// バリデーション中かどうか
const isValidating = (field: string): boolean => {
  return validating.value.has(field);
};

// フィールドエラーの有無
const hasFieldError = (field: string): boolean => {
  return shouldShowError(field);
};

// CSSクラスの動的決定
const getFieldClass = (field: string): string => {
  const classes = ['form-input'];

  if (shouldShowError(field)) {
    classes.push('is-invalid');
  } else if (shouldShowSuccess(field)) {
    classes.push('is-valid');
  }

  if (fieldStates[field].focused) {
    classes.push('is-focused');
  }

  if (isValidating(field)) {
    classes.push('is-validating');
  }

  return classes.join(' ');
};

// ARIA属性の動的決定
const getAriaDescribedBy = (field: string): string => {
  const ids = [];

  if (shouldShowError(field)) {
    ids.push(`${field}-error`);
  }

  if (showHint(field)) {
    ids.push(`${field}-hint`);
  }

  if (shouldShowSuccess(field)) {
    ids.push(`${field}-success`);
  }

  return ids.join(' ');
};

// フィールドエラーのクリア
const clearFieldError = (field: string): void => {
  if (errors[field]) {
    delete errors[field];
  }
};

// バリデーション実行
const validateField = async (
  field: string
): Promise<void> => {
  fieldStates[field].validationAttempted = true;
  validating.value.add(field);

  try {
    let error: string | null = null;

    switch (field) {
      case 'username':
        error = await validateUsernameAvailability(
          formData.username
        );
        break;
      case 'email':
        error = await validateEmailAvailability(
          formData.email
        );
        break;
    }

    if (error) {
      errors[field] = error;
    } else {
      delete errors[field];
    }
  } finally {
    validating.value.delete(field);
  }
};

const formSubmitAttempted = ref(false);

const handleSubmit = async (): Promise<void> => {
  formSubmitAttempted.value = true;

  // 全フィールドのバリデーション
  await Promise.all(
    Object.keys(formData).map((field) =>
      validateField(field)
    )
  );

  if (Object.keys(errors).length === 0) {
    console.log('Form is valid, submitting...');
    // フォーム送信処理
  }
};
</script>

アクセシビリティ対応

フォームバリデーションにおけるアクセシビリティ対応は、すべてのユーザーにとって利用しやすいフォームを作るために不可欠です。

html<template>
  <form
    @submit.prevent="handleSubmit"
    class="accessible-form"
    novalidate
    role="form"
    aria-label="ユーザー登録フォーム"
  >
    <!-- フォーム全体のエラーサマリー -->
    <div
      v-if="formErrors.length > 0 && formSubmitAttempted"
      class="error-summary"
      role="alert"
      aria-labelledby="error-summary-title"
      tabindex="-1"
      ref="errorSummaryRef"
    >
      <h3
        id="error-summary-title"
        class="error-summary-title"
      >
        入力に問題があります
      </h3>
      <ul class="error-list">
        <li v-for="error in formErrors" :key="error.field">
          <a
            :href="`#${error.field}`"
            @click.prevent="focusField(error.field)"
          >
            {{ error.message }}
          </a>
        </li>
      </ul>
    </div>

    <!-- 必須フィールドの説明 -->
    <p class="required-note" id="required-fields-note">
      <span class="required-marker">*</span> は必須項目です
    </p>

    <fieldset>
      <legend>アカウント情報</legend>

      <div class="field-group">
        <label for="username" class="field-label">
          ユーザー名
          <span class="required-marker" aria-label="必須"
            >*</span
          >
        </label>

        <input
          id="username"
          v-model="formData.username"
          type="text"
          required
          autocomplete="username"
          :aria-describedby="getUsernameAriaDescribedBy()"
          :aria-invalid="hasFieldError('username')"
          :aria-required="true"
          @blur="handleFieldBlur('username')"
          @input="handleFieldInput('username')"
          @focus="handleFieldFocus('username')"
        />

        <!-- フィールド説明 -->
        <div
          id="username-description"
          class="field-description"
        >
          3文字以上20文字以下で、英数字とアンダースコアが使用できます
        </div>

        <!-- エラーメッセージ -->
        <div
          v-if="shouldShowError('username')"
          id="username-error"
          class="error-message"
          role="alert"
          aria-live="polite"
        >
          {{ errors.username }}
        </div>

        <!-- 成功メッセージ -->
        <div
          v-if="shouldShowSuccess('username')"
          id="username-success"
          class="success-message"
          aria-live="polite"
        >
          このユーザー名は利用可能です
        </div>
      </div>
    </fieldset>

    <!-- パスワード強度インジケーター -->
    <fieldset>
      <legend>セキュリティ</legend>

      <div class="field-group">
        <label for="password" class="field-label">
          パスワード
          <span class="required-marker" aria-label="必須"
            >*</span
          >
        </label>

        <input
          id="password"
          v-model="formData.password"
          type="password"
          required
          autocomplete="new-password"
          :aria-describedby="getPasswordAriaDescribedBy()"
          :aria-invalid="hasFieldError('password')"
          @input="handlePasswordInput"
        />

        <!-- パスワード強度メーター -->
        <div class="password-strength" aria-live="polite">
          <div class="strength-label">パスワード強度:</div>
          <div
            class="strength-meter"
            role="progressbar"
            :aria-valuenow="passwordStrength.score"
            :aria-valuemin="0"
            :aria-valuemax="4"
            :aria-valuetext="passwordStrength.label"
          >
            <div
              class="strength-bar"
              :class="passwordStrength.cssClass"
              :style="{
                width: `${
                  (passwordStrength.score / 4) * 100
                }%`,
              }"
            ></div>
          </div>
          <div class="strength-text">
            {{ passwordStrength.label }}
          </div>
        </div>

        <!-- パスワード要件チェックリスト -->
        <ul
          class="password-requirements"
          id="password-requirements"
        >
          <li
            v-for="requirement in passwordRequirements"
            :key="requirement.id"
            :class="{ 'requirement-met': requirement.met }"
            :aria-describedby="`requirement-${requirement.id}`"
          >
            <span
              class="requirement-icon"
              :aria-label="
                requirement.met
                  ? '満たしています'
                  : '満たしていません'
              "
            >
              {{ requirement.met ? '✓' : '○' }}
            </span>
            {{ requirement.text }}
          </li>
        </ul>
      </div>
    </fieldset>
  </form>
</template>

<script setup lang="ts">
import { reactive } from 'vue';

interface FormItem {
  id: string;
  name: string;
  email: string;
  category: string;
}

const formItems = ref<FormItem[]>([
  {
    id: '1',
    name: 'John Doe',
    email: 'john@example.com',
    category: 'admin',
  },
  // ... 大量のアイテム
]);

const searchFilters = ref({
  name: '',
  category: '',
  emailDomain: '',
});

// メモ化されたフィルタリング処理
const filteredItems = computed(() => {
  const { name, category, emailDomain } =
    searchFilters.value;

  if (!name && !category && !emailDomain) {
    return formItems.value;
  }

  return formItems.value.filter((item) => {
    // 名前フィルター
    if (
      name &&
      !item.name.toLowerCase().includes(name.toLowerCase())
    ) {
      return false;
    }

    // カテゴリフィルター
    if (category && item.category !== category) {
      return false;
    }

    // メールドメインフィルター
    if (emailDomain) {
      const domain = item.email.split('@')[1];
      if (
        !domain ||
        !domain
          .toLowerCase()
          .includes(emailDomain.toLowerCase())
      ) {
        return false;
      }
    }

    return true;
  });
});

// メモ化されたバリデーション結果
const validationResults = computed(() => {
  const results: Record<
    string,
    Record<string, string | null>
  > = {};

  for (const item of filteredItems.value) {
    results[item.id] = {
      name: validateName(item.name),
      email: validateEmail(item.email),
      duplicateEmail: validateEmailUniqueness(
        item.email,
        item.id
      ),
    };
  }

  return results;
});

// メモ化されたサマリー情報
const validationSummary = computed(() => {
  const results = validationResults.value;
  let totalErrors = 0;
  let totalWarnings = 0;
  const errorsByType: Record<string, number> = {};

  for (const itemResults of Object.values(results)) {
    for (const [field, error] of Object.entries(
      itemResults
    )) {
      if (error) {
        totalErrors++;
        errorsByType[field] =
          (errorsByType[field] || 0) + 1;

        if (error.includes('警告')) {
          totalWarnings++;
        }
      }
    }
  }

  return {
    totalItems: filteredItems.value.length,
    totalErrors,
    totalWarnings,
    errorsByType,
    isValid: totalErrors === 0,
  };
});

// 重複チェックの最適化(Map を使用)
const emailMap = computed(() => {
  const map = new Map<string, string[]>();

  for (const item of formItems.value) {
    const email = item.email.toLowerCase();
    if (!map.has(email)) {
      map.set(email, []);
    }
    map.get(email)!.push(item.id);
  }

  return map;
});

const validateEmailUniqueness = (
  email: string,
  currentId: string
): string | null => {
  const duplicateIds =
    emailMap.value.get(email.toLowerCase()) || [];
  const otherDuplicates = duplicateIds.filter(
    (id) => id !== currentId
  );

  return otherDuplicates.length > 0
    ? `このメールアドレスは他の${otherDuplicates.length}件のアイテムと重複しています`
    : null;
};

// パフォーマンス監視
const performanceMetrics = ref({
  lastValidationTime: 0,
  averageValidationTime: 0,
  validationCount: 0,
});

const measureValidationPerformance = <T>(
  fn: () => T
): T => {
  const start = performance.now();
  const result = fn();
  const end = performance.now();

  const duration = end - start;
  performanceMetrics.value.lastValidationTime = duration;
  performanceMetrics.value.validationCount++;

  // 移動平均の計算
  const count = performanceMetrics.value.validationCount;
  const current =
    performanceMetrics.value.averageValidationTime;
  performanceMetrics.value.averageValidationTime =
    (current * (count - 1) + duration) / count;

  return result;
};
</script>

<template>
  <div class="optimized-form">
    <!-- フィルター -->
    <div class="filters">
      <input
        v-model="searchFilters.name"
        placeholder="名前で検索"
      />
      <select v-model="searchFilters.category">
        <option value="">全てのカテゴリ</option>
        <option value="admin">管理者</option>
        <option value="user">ユーザー</option>
      </select>
    </div>

    <!-- サマリー情報 -->
    <div class="validation-summary">
      <p>アイテム数: {{ validationSummary.totalItems }}</p>
      <p>エラー数: {{ validationSummary.totalErrors }}</p>
      <p>
        平均検証時間:
        {{
          performanceMetrics.averageValidationTime.toFixed(
            2
          )
        }}ms
      </p>
    </div>

    <!-- アイテムリスト -->
    <div class="items-list">
      <div
        v-for="item in filteredItems"
        :key="item.id"
        class="item"
        :class="{
          'has-errors': Object.values(
            validationResults[item.id] || {}
          ).some((error) => error),
        }"
      >
        <div class="item-content">
          {{ item.name }} ({{ item.email }})
        </div>

        <div
          v-if="validationResults[item.id]"
          class="validation-errors"
        >
          <div
            v-for="(error, field) in validationResults[
              item.id
            ]"
            :key="field"
            v-if="error"
            class="error"
          >
            {{ field }}: {{ error }}
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

パフォーマンス最適化

大量のフィールドを持つフォームや複雑なバリデーションロジックを扱う場合、パフォーマンスの最適化が重要になります。ユーザーの入力をスムーズに処理し、レスポンシブな体験を提供するための最適化テクニックをご紹介します。

デバウンス処理の実装

ユーザーの入力に対してリアルタイムでバリデーションを実行する場合、過度な API 呼び出しや CPU 使用量を避けるためにデバウンス処理が必要です。

typescript// composables/useDebounce.ts
import { ref, watch } from 'vue';

export interface DebounceOptions {
  delay?: number;
  immediate?: boolean;
}

export function useDebounce<T>(
  value: Ref<T>,
  callback: (value: T) => void | Promise<void>,
  options: DebounceOptions = {}
) {
  const { delay = 300, immediate = false } = options;

  const debounceTimer = ref<NodeJS.Timeout | null>(null);
  const isExecuting = ref(false);

  const execute = async (newValue: T): Promise<void> => {
    isExecuting.value = true;
    try {
      await callback(newValue);
    } finally {
      isExecuting.value = false;
    }
  };

  const debouncedCallback = (newValue: T): void => {
    // 既存のタイマーをクリア
    if (debounceTimer.value) {
      clearTimeout(debounceTimer.value);
    }

    // immediate オプションが true で初回実行の場合
    if (immediate && !debounceTimer.value) {
      execute(newValue);
      return;
    }

    // デバウンス処理
    debounceTimer.value = setTimeout(() => {
      execute(newValue);
      debounceTimer.value = null;
    }, delay);
  };

  // 値の変更を監視
  watch(value, debouncedCallback, { immediate });

  // クリーンアップ関数
  const cleanup = (): void => {
    if (debounceTimer.value) {
      clearTimeout(debounceTimer.value);
      debounceTimer.value = null;
    }
  };

  return {
    isExecuting: readonly(isExecuting),
    cleanup,
  };
}

デバウンスを活用したバリデーション実装の具体例です。

html<script setup lang="ts">
import { ref, onUnmounted } from 'vue';
import { useDebounce } from '@/composables/useDebounce';

const searchQuery = ref('');
const validationResults = ref<Record<string, boolean>>({});

// 検索クエリのバリデーション(API呼び出しを含む)
const validateSearchQuery = async (
  query: string
): Promise<void> => {
  if (!query.trim()) {
    validationResults.value.searchQuery = false;
    return;
  }

  try {
    // API呼び出しでクエリの有効性をチェック
    const response = await fetch(`/api/search/validate`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ query }),
    });

    const result = await response.json();
    validationResults.value.searchQuery = result.isValid;
  } catch (error) {
    console.error('Search validation error:', error);
    validationResults.value.searchQuery = false;
  }
};

// デバウンス処理付きバリデーション
const { isExecuting: isValidatingSearch, cleanup } =
  useDebounce(searchQuery, validateSearchQuery, {
    delay: 500,
  });

// コンポーネントのクリーンアップ
onUnmounted(() => {
  cleanup();
});
</script>

<template>
  <div class="search-form">
    <input
      v-model="searchQuery"
      type="text"
      placeholder="検索キーワードを入力"
      :class="{
        validating: isValidatingSearch,
        valid: validationResults.searchQuery === true,
        invalid: validationResults.searchQuery === false,
      }"
    />

    <div
      v-if="isValidatingSearch"
      class="validation-indicator"
    >
      検索条件を確認中...
    </div>
  </div>
</template>

計算量を抑えるバリデーション

大量のデータや複雑なバリデーションルールを効率的に処理するためのテクニックです。

typescript// composables/useOptimizedValidation.ts
import { computed, ref } from 'vue';

export interface ValidationCache {
  [key: string]: {
    value: any;
    result: string | null;
    timestamp: number;
  };
}

export function useOptimizedValidation() {
  const validationCache = ref<ValidationCache>({});
  const CACHE_DURATION = 5 * 60 * 1000; // 5分間キャッシュ

  // キャッシュからの結果取得
  const getCachedResult = (
    key: string,
    value: any
  ): string | null => {
    const cached = validationCache.value[key];

    if (!cached) return null;

    // キャッシュの有効期限チェック
    const isExpired =
      Date.now() - cached.timestamp > CACHE_DURATION;
    if (isExpired) {
      delete validationCache.value[key];
      return null;
    }

    // 値が同じかチェック
    if (
      JSON.stringify(cached.value) === JSON.stringify(value)
    ) {
      return cached.result;
    }

    return null;
  };

  // キャッシュに結果を保存
  const setCachedResult = (
    key: string,
    value: any,
    result: string | null
  ): void => {
    validationCache.value[key] = {
      value: JSON.parse(JSON.stringify(value)), // ディープコピー
      result,
      timestamp: Date.now(),
    };
  };

  // 重複チェックの最適化(大量データ対応)
  const optimizedDuplicateCheck = (
    items: Array<{ id: string; value: string }>,
    currentIndex: number
  ): string | null => {
    const currentItem = items[currentIndex];
    const cacheKey = `duplicate_${currentItem.value}_${items.length}`;

    // キャッシュから結果を取得
    const cachedResult = getCachedResult(
      cacheKey,
      currentItem.value
    );
    if (cachedResult !== null) {
      return cachedResult;
    }

    // Set を使用した高速重複検索
    const valueSet = new Set<string>();
    const duplicateIndices = new Set<number>();

    for (let i = 0; i < items.length; i++) {
      const value = items[i].value.toLowerCase().trim();

      if (valueSet.has(value)) {
        duplicateIndices.add(i);
        // 既存の要素のインデックスも見つける
        for (let j = 0; j < i; j++) {
          if (
            items[j].value.toLowerCase().trim() === value
          ) {
            duplicateIndices.add(j);
            break;
          }
        }
      } else {
        valueSet.add(value);
      }
    }

    const result = duplicateIndices.has(currentIndex)
      ? 'この値は既に使用されています'
      : null;

    // 結果をキャッシュ
    setCachedResult(cacheKey, currentItem.value, result);

    return result;
  };

  // バッチバリデーション(複数フィールドを同時処理)
  const batchValidate = async (
    validations: Array<{
      field: string;
      value: any;
      validator: (value: any) => Promise<string | null>;
    }>
  ): Promise<Record<string, string | null>> => {
    const promises = validations.map(
      async ({ field, value, validator }) => {
        const cacheKey = `${field}_${JSON.stringify(
          value
        )}`;

        // キャッシュチェック
        const cachedResult = getCachedResult(
          cacheKey,
          value
        );
        if (cachedResult !== null) {
          return { field, result: cachedResult };
        }

        try {
          const result = await validator(value);
          setCachedResult(cacheKey, value, result);
          return { field, result };
        } catch (error) {
          console.error(
            `Validation error for ${field}:`,
            error
          );
          return {
            field,
            result:
              'バリデーション中にエラーが発生しました',
          };
        }
      }
    );

    const results = await Promise.all(promises);

    return results.reduce((acc, { field, result }) => {
      acc[field] = result;
      return acc;
    }, {} as Record<string, string | null>);
  };

  return {
    optimizedDuplicateCheck,
    batchValidate,
    clearCache: () => {
      validationCache.value = {};
    },
  };
}

メモ化による最適化

Vue 3 のリアクティビティシステムを活用した効率的なバリデーション実装です。

html<script setup lang="ts">
import { computed, ref, shallowRef } from 'vue';

interface FormItem {
  id: string;
  name: string;
  email: string;
  category: string;
}

const formItems = ref<FormItem[]>([
  {
    id: '1',
    name: 'John Doe',
    email: 'john@example.com',
    category: 'admin',
  },
  // ... 大量のアイテム
]);

const searchFilters = ref({
  name: '',
  category: '',
  emailDomain: '',
});

// メモ化されたフィルタリング処理
const filteredItems = computed(() => {
  const { name, category, emailDomain } =
    searchFilters.value;

  if (!name && !category && !emailDomain) {
    return formItems.value;
  }

  return formItems.value.filter((item) => {
    // 名前フィルター
    if (
      name &&
      !item.name.toLowerCase().includes(name.toLowerCase())
    ) {
      return false;
    }

    // カテゴリフィルター
    if (category && item.category !== category) {
      return false;
    }

    // メールドメインフィルター
    if (emailDomain) {
      const domain = item.email.split('@')[1];
      if (
        !domain ||
        !domain
          .toLowerCase()
          .includes(emailDomain.toLowerCase())
      ) {
        return false;
      }
    }

    return true;
  });
});

// メモ化されたバリデーション結果
const validationResults = computed(() => {
  const results: Record<
    string,
    Record<string, string | null>
  > = {};

  for (const item of filteredItems.value) {
    results[item.id] = {
      name: validateName(item.name),
      email: validateEmail(item.email),
      duplicateEmail: validateEmailUniqueness(
        item.email,
        item.id
      ),
    };
  }

  return results;
});

// メモ化されたサマリー情報
const validationSummary = computed(() => {
  const results = validationResults.value;
  let totalErrors = 0;
  let totalWarnings = 0;
  const errorsByType: Record<string, number> = {};

  for (const itemResults of Object.values(results)) {
    for (const [field, error] of Object.entries(
      itemResults
    )) {
      if (error) {
        totalErrors++;
        errorsByType[field] =
          (errorsByType[field] || 0) + 1;

        if (error.includes('警告')) {
          totalWarnings++;
        }
      }
    }
  }

  return {
    totalItems: filteredItems.value.length,
    totalErrors,
    totalWarnings,
    errorsByType,
    isValid: totalErrors === 0,
  };
});

// 重複チェックの最適化(Map を使用)
const emailMap = computed(() => {
  const map = new Map<string, string[]>();

  for (const item of formItems.value) {
    const email = item.email.toLowerCase();
    if (!map.has(email)) {
      map.set(email, []);
    }
    map.get(email)!.push(item.id);
  }

  return map;
});

const validateEmailUniqueness = (
  email: string,
  currentId: string
): string | null => {
  const duplicateIds =
    emailMap.value.get(email.toLowerCase()) || [];
  const otherDuplicates = duplicateIds.filter(
    (id) => id !== currentId
  );

  return otherDuplicates.length > 0
    ? `このメールアドレスは他の${otherDuplicates.length}件のアイテムと重複しています`
    : null;
};

// パフォーマンス監視
const performanceMetrics = ref({
  lastValidationTime: 0,
  averageValidationTime: 0,
  validationCount: 0,
});

const measureValidationPerformance = <T>(
  fn: () => T
): T => {
  const start = performance.now();
  const result = fn();
  const end = performance.now();

  const duration = end - start;
  performanceMetrics.value.lastValidationTime = duration;
  performanceMetrics.value.validationCount++;

  // 移動平均の計算
  const count = performanceMetrics.value.validationCount;
  const current =
    performanceMetrics.value.averageValidationTime;
  performanceMetrics.value.averageValidationTime =
    (current * (count - 1) + duration) / count;

  return result;
};
</script>

<template>
  <div class="optimized-form">
    <!-- フィルター -->
    <div class="filters">
      <input
        v-model="searchFilters.name"
        placeholder="名前で検索"
      />
      <select v-model="searchFilters.category">
        <option value="">全てのカテゴリ</option>
        <option value="admin">管理者</option>
        <option value="user">ユーザー</option>
      </select>
    </div>

    <!-- サマリー情報 -->
    <div class="validation-summary">
      <p>アイテム数: {{ validationSummary.totalItems }}</p>
      <p>エラー数: {{ validationSummary.totalErrors }}</p>
      <p>
        平均検証時間:
        {{
          performanceMetrics.averageValidationTime.toFixed(
            2
          )
        }}ms
      </p>
    </div>

    <!-- アイテムリスト -->
    <div class="items-list">
      <div
        v-for="item in filteredItems"
        :key="item.id"
        class="item"
        :class="{
          'has-errors': Object.values(
            validationResults[item.id] || {}
          ).some((error) => error),
        }"
      >
        <div class="item-content">
          {{ item.name }} ({{ item.email }})
        </div>

        <div
          v-if="validationResults[item.id]"
          class="validation-errors"
        >
          <div
            v-for="(error, field) in validationResults[
              item.id
            ]"
            :key="field"
            v-if="error"
            class="error"
          >
            {{ field }}: {{ error }}
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

パフォーマンス最適化

大量のフィールドを持つフォームや複雑なバリデーションロジックを扱う場合、パフォーマンスの最適化が重要になります。ユーザーの入力をスムーズに処理し、レスポンシブな体験を提供するための最適化テクニックをご紹介します。

デバウンス処理の実装

ユーザーの入力に対してリアルタイムでバリデーションを実行する場合、過度な API 呼び出しや CPU 使用量を避けるためにデバウンス処理が必要です。

typescript// composables/useDebounce.ts
import { ref, watch, readonly } from 'vue';

export interface DebounceOptions {
  delay?: number;
  immediate?: boolean;
}

export function useDebounce<T>(
  value: Ref<T>,
  callback: (value: T) => void | Promise<void>,
  options: DebounceOptions = {}
) {
  const { delay = 300, immediate = false } = options;

  const debounceTimer = ref<NodeJS.Timeout | null>(null);
  const isExecuting = ref(false);

  const execute = async (newValue: T): Promise<void> => {
    isExecuting.value = true;
    try {
      await callback(newValue);
    } finally {
      isExecuting.value = false;
    }
  };

  const debouncedCallback = (newValue: T): void => {
    if (debounceTimer.value) {
      clearTimeout(debounceTimer.value);
    }

    debounceTimer.value = setTimeout(() => {
      execute(newValue);
      debounceTimer.value = null;
    }, delay);
  };

  watch(value, debouncedCallback, { immediate });

  const cleanup = (): void => {
    if (debounceTimer.value) {
      clearTimeout(debounceTimer.value);
      debounceTimer.value = null;
    }
  };

  return {
    isExecuting: readonly(isExecuting),
    cleanup,
  };
}

計算量を抑えるバリデーション

大量のデータや複雑なバリデーションルールを効率的に処理するためのテクニックです。

typescript// composables/useOptimizedValidation.ts
import { computed, ref } from 'vue';

export interface ValidationCache {
  [key: string]: {
    value: any;
    result: string | null;
    timestamp: number;
  };
}

export function useOptimizedValidation() {
  const validationCache = ref<ValidationCache>({});
  const CACHE_DURATION = 5 * 60 * 1000; // 5分間キャッシュ

  // 重複チェックの最適化(大量データ対応)
  const optimizedDuplicateCheck = (
    items: Array<{ id: string; value: string }>,
    currentIndex: number
  ): string | null => {
    // Set を使用した高速重複検索
    const valueSet = new Set<string>();
    const duplicateIndices = new Set<number>();

    for (let i = 0; i < items.length; i++) {
      const value = items[i].value.toLowerCase().trim();

      if (valueSet.has(value)) {
        duplicateIndices.add(i);
        for (let j = 0; j < i; j++) {
          if (
            items[j].value.toLowerCase().trim() === value
          ) {
            duplicateIndices.add(j);
            break;
          }
        }
      } else {
        valueSet.add(value);
      }
    }

    return duplicateIndices.has(currentIndex)
      ? 'この値は既に使用されています'
      : null;
  };

  return {
    optimizedDuplicateCheck,
    clearCache: () => {
      validationCache.value = {};
    },
  };
}

メモ化による最適化

Vue 3 のリアクティビティシステムを活用した効率的なバリデーション実装です。

html<script setup lang="ts">
import { computed, ref } from 'vue';

interface FormItem {
  id: string;
  name: string;
  email: string;
}

const formItems = ref<FormItem[]>([]);

// メモ化されたバリデーション結果
const validationResults = computed(() => {
  const results: Record<
    string,
    Record<string, string | null>
  > = {};

  for (const item of formItems.value) {
    results[item.id] = {
      name: validateName(item.name),
      email: validateEmail(item.email),
    };
  }

  return results;
});

// 重複チェックの最適化
const emailMap = computed(() => {
  const map = new Map<string, string[]>();

  for (const item of formItems.value) {
    const email = item.email.toLowerCase();
    if (!map.has(email)) {
      map.set(email, []);
    }
    map.get(email)!.push(item.id);
  }

  return map;
});

// パフォーマンス監視
const performanceMetrics = ref({
  lastValidationTime: 0,
  averageValidationTime: 0,
  validationCount: 0,
});

const measureValidationPerformance = <T>(
  fn: () => T
): T => {
  const start = performance.now();
  const result = fn();
  const end = performance.now();

  const duration = end - start;
  performanceMetrics.value.lastValidationTime = duration;
  performanceMetrics.value.validationCount++;

  // 移動平均の計算
  const count = performanceMetrics.value.validationCount;
  const current =
    performanceMetrics.value.averageValidationTime;
  performanceMetrics.value.averageValidationTime =
    (current * (count - 1) + duration) / count;

  return result;
};
</script>

これらのパフォーマンス最適化技術により、大規模なフォームでもスムーズなユーザー体験を提供できます。

よくあるエラーと解決方法

Vue.js でフォームバリデーションを実装する際によく遭遇するエラーとその解決方法をご紹介します。

エラー 1: Cannot read property 'length' of undefined

bashCannot read property 'length' of undefined
    at validateField (FormComponent.vue:45:32)

原因: リアクティブデータの初期化が不適切で、undefined の値に対してプロパティアクセスを試みている

html<!-- ❌ エラーが発生するコード -->
<script setup lang="ts">
const formData = reactive({
  // email が初期化されていない
});

const validateEmail = (email: string): string | null => {
  if (email.length < 5) {
    // email が undefined の場合エラー
    return 'メールアドレスは5文字以上で入力してください';
  }
  return null;
};
</script>
html<!-- ✅ 正しいコード -->
<script setup lang="ts">
const formData = reactive({
  email: '', // 適切な初期値を設定
});

const validateEmail = (email: string): string | null => {
  // ガード句でnullish値をチェック
  if (!email || email.length < 5) {
    return 'メールアドレスは5文字以上で入力してください';
  }
  return null;
};
</script>

エラー 2: Maximum call stack size exceeded

bashRangeError: Maximum call stack size exceeded
    at Proxy.get (reactivity.esm-bundler.js:1147:25)

原因: リアクティブオブジェクト内での循環参照や、watcher の無限ループ

html<!-- ❌ エラーが発生するコード -->
<script lang="ts">
import { defineEmits } from 'vue'; // インポートは不要

export default {
  setup() {
    const emit = defineEmits(['submit', 'error']);
    return { emit };
  },
};
</script>
html<!-- ✅ 正しいコード -->
<script setup lang="ts">
// defineEmits はインポート不要、setup内で直接使用
const emit = defineEmits<{
  submit: [data: FormData];
  error: [message: string];
}>();
</script>

エラー 3: Failed to resolve component: defineEmits

bashFailed to resolve component: defineEmits

原因: Composition API の設定が正しくないか、古いバージョンの Vue を使用している

html<!-- ❌ エラーが発生するコード -->
<script lang="ts">
import { defineEmits } from 'vue'; // インポートは不要

export default {
  setup() {
    const emit = defineEmits(['submit', 'error']);
    return { emit };
  },
};
</script>
html<!-- ✅ 正しいコード -->
<script setup lang="ts">
// defineEmits はインポート不要、setup内で直接使用
const emit = defineEmits<{
  submit: [data: FormData];
  error: [message: string];
}>();
</script>

エラー 4: Property 'validate' does not exist on type 'ComponentPublicInstance'

bashProperty 'validate' does not exist on type 'ComponentPublicInstance'

原因: 子コンポーネントの型定義が不適切

html<!-- ❌ エラーが発生するコード -->
<script setup lang="ts">
const formRef = ref<InstanceType<typeof SomeComponent>>();

const handleSubmit = () => {
  formRef.value?.validate(); // validate メソッドが型定義されていない
};
</script>
html<!-- ✅ 正しいコード -->
<script setup lang="ts">
// 子コンポーネントの公開インターフェースを定義
interface FormComponentInstance {
  validate: () => Promise<boolean>;
  reset: () => void;
}

const formRef = ref<FormComponentInstance>();

const handleSubmit = async () => {
  const isValid = await formRef.value?.validate();
  if (isValid) {
    // フォーム送信処理
  }
};
</script>
html<!-- 子コンポーネント側 -->
<script setup lang="ts">
// defineExpose で公開するメソッドを明示
const validate = async (): Promise<boolean> => {
  // バリデーション処理
  return true;
};

const reset = (): void => {
  // リセット処理
};

defineExpose({
  validate,
  reset,
});
</script>

まとめ

Vue.js 3 でのモダンなフォームバリデーション実装について、基礎から応用まで幅広く解説いたしました。

重要なポイントのまとめ:

#項目重要なポイント
1基本的なバリデーションHTML5 と Composition API を組み合わせたリアクティブな実装
2カスタムバリデーション再利用可能な関数設計と非同期処理の適切なハンドリング
3高度なフォームパターン動的フィールド、ネストしたデータ、条件付きバリデーション
4UX 設計エラー表示タイミング、アクセシビリティ、ユーザビリティ
5パフォーマンス最適化デバウンス、メモ化、キャッシュによる効率的な処理

フォームバリデーションは単なる技術的な実装以上に、ユーザー体験を大きく左右する重要な要素です。適切なタイミングでのフィードバック、分かりやすいエラーメッセージ、アクセシビリティへの配慮により、すべてのユーザーにとって使いやすいフォームを作成できます。

また、アプリケーションの成長に伴ってフォームが複雑化した場合でも、モジュラーな設計とパフォーマンス最適化により、保守性と効率性を両立できるでしょう。

Vue.js 3 の強力なリアクティビティシステムと Composition API を活用することで、従来のフォームライブラリに依存することなく、プロジェクトの要件に完全に適合したバリデーション機能を実装できます。これにより、より良いユーザー体験と開発者体験の両方を実現していただけると思います。

関連リンク