Vue 3フォームバリデーションComposable:型安全ダイナミックフォームの5つのコアパターン

前端工程

フォームバリデーションのペインポイント:なぜ自作Composableが必要なのか

2026年、Vue 3 Composition APIはフォーム処理の標準パラダイムとなりましたが、バリデーションロジックが各コンポーネントに分散、非同期バリデーションのデバウンス処理が複雑、ダイナミックフォームフィールドの追加削除が管理困難、クロスフィールド連動バリデーションに手が届かない、エラーメッセージの状態管理が混乱 — これらの問題により、フォーム開発はフロントエンドで最も頭を悩ませる作業となっています。自作フォームバリデーションComposableとZod Schemaによる型安全の組み合わせが、これらのペインポイントを解決する最適なアプローチです。

ペインポイント 具体的な表れ 影響
バリデーションロジックの分散 各フォームコンポーネントでバリデーションルールを重複記述 コードの冗長化、ルールの不整合
非同期バリデーションの複雑さ ユーザー名重複確認、電話認証にデバウンスが必要 リクエストストーム、UXの低下
ダイナミックフォームの難しさ フィールドの動的追加削除、条件付き表示/非表示 バリデーション状態の非同期
クロスフィールド連動 パスワード確認、日付範囲の検証 バリデーションロジックの密結合
エラー状態の混乱 touched/dirty/valid状態が分散管理 UIフィードバックの不正確さ

コア概念一覧

概念 説明
Composable Vue 3コンポジション関数、再利用可能なリアクティブロジックをカプセル化
フォームバリデーション ユーザー入力に対するルールベースの検証、データの正当性を確保
Zod Schema TypeScriptファーストのSchema宣言型バリデーションライブラリ、型推論付き
非同期バリデーション バックエンドリクエストが必要なバリデーション(一意性チェック等)、デバウンスが必要
ダイナミックフィールド 実行時に追加/削除されるフォームフィールド、動的バリデーションルール管理が必要
エラー状態 各フィールドのエラーメッセージ、タッチ済みかどうか、ダーティデータかどうか
ダーティチェック フィールド値がユーザーによって変更されたかどうかを判定(dirty)
送信状態 isSubmitting/isSubmitSuccessful等の送信プロセス状態

5つの主要課題の深掘り分析

課題1:バリデーションロジックの再利用

従来のアプローチではバリデーションルールをコンポーネント内に埋め込み、コンポーネント間で共有できません。同じフィールドのバリデーションルール(電話番号、メール)が各フォームで再定義され、修正時の見落としが発生しやすくなります。

課題2:非同期バリデーションのデバウンス

ユーザー名の一意性チェックやメールの利用可能性確認は、デバウンスなしではキーストロークごとにリクエストが発生します。「検証中」と「検証失敗」の状態を区別し、デバウンス中もユーザーの入力をブロックしない必要があります。

課題3:ダイナミックフィールドの追加削除

ダイナミックフォーム(タグリスト、複数住所)では実行時のフィールド管理とフィールドごとの独立したバリデーションが必要です。フィールド削除時はバリデーション状態をクリーンアップし、追加時はバリデーションルールを初期化する必要があります。

課題4:クロスフィールドバリデーション

パスワード確認、日付範囲、金額の上限下限など、複数のフィールド値に同時にアクセスする必要があるバリデーションです。バリデーションのトリガータイミングも調整が必要 — 関連フィールドのいずれかが変更されたら依存フィールドを再検証する必要があります。

課題5:フォーム状態管理

1つのフォームフィールドにはvalue、errors、touched、dirty、isValidatingなど多数の状態が関わります。手動管理はエラーを招きやすく、統一されたリアクティブ状態コンテナと自動派生computedプロパティが不可欠です。


パターン1:useValidation基礎バリデーションComposable

import { ref, reactive, computed, type Ref } from 'vue'

type Validator = (value: any) => string | true

interface FieldState {
  value: Ref<any>
  errors: string[]
  touched: boolean
  dirty: boolean
}

interface ValidationRule {
  validator: Validator
  message: string
}

export function useValidation<T extends Record<string, any>>(
  initialValues: T,
  rules: Record<keyof T, ValidationRule[]>
) {
  const fields = reactive<Record<string, FieldState>>({})
  const isValidating = ref(false)

  for (const key in initialValues) {
    fields[key] = {
      value: ref(initialValues[key]),
      errors: [],
      touched: false,
      dirty: false
    }
  }

  const validateField = (key: keyof T): boolean => {
    const field = fields[key as string]
    if (!field) return true

    const fieldRules = rules[key] || []
    const errors: string[] = []

    for (const rule of fieldRules) {
      const result = rule.validator(field.value.value)
      if (result !== true) {
        errors.push(rule.message)
      }
    }

    field.errors = errors
    return errors.length === 0
  }

  const validateAll = (): boolean => {
    let allValid = true
    for (const key in fields) {
      if (!validateField(key as keyof T)) {
        allValid = false
      }
    }
    return allValid
  }

  const touchField = (key: keyof T) => {
    const field = fields[key as string]
    if (field) {
      field.touched = true
    }
  }

  const markDirty = (key: keyof T) => {
    const field = fields[key as string]
    if (field) {
      field.dirty = field.value.value !== initialValues[key]
    }
  }

  const isValid = computed(() => {
    return Object.values(fields).every(
      (field) => field.errors.length === 0
    )
  })

  const isDirty = computed(() => {
    return Object.values(fields).some((field) => field.dirty)
  })

  const getValues = (): T => {
    const values = {} as T
    for (const key in fields) {
      values[key as keyof T] = fields[key].value.value
    }
    return values
  }

  const resetForm = () => {
    for (const key in initialValues) {
      const field = fields[key]
      field.value.value = initialValues[key]
      field.errors = []
      field.touched = false
      field.dirty = false
    }
  }

  return {
    fields,
    isValidating,
    isValid,
    isDirty,
    validateField,
    validateAll,
    touchField,
    markDirty,
    getValues,
    resetForm
  }
}

パターン2:Zod Schema駆動の型安全バリデーション

import { ref, reactive, computed } from 'vue'
import { z, type ZodType, type ZodError } from 'zod'

interface ZodFieldState {
  value: any
  errors: string[]
  touched: boolean
  dirty: boolean
}

export function useZodForm<T extends ZodType<any>>(
  schema: T,
  initialValues: z.infer<T>
) {
  type FormValues = z.infer<T>

  const fields = reactive<Record<string, ZodFieldState>>({})
  const formErrors = ref<string[]>([])
  const isValidating = ref(false)

  for (const key in initialValues) {
    fields[key] = {
      value: initialValues[key],
      errors: [],
      touched: false,
      dirty: false
    }
  }

  const validateField = async (key: string): Promise<boolean> => {
    const field = fields[key]
    if (!field) return true

    try {
      const fieldSchema = schema.shape[key]
      if (fieldSchema) {
        await fieldSchema.parseAsync(field.value)
      }
      field.errors = []
      return true
    } catch (error) {
      const zodError = error as ZodError
      field.errors = zodError.errors.map((e) => e.message)
      return false
    }
  }

  const validateAll = async (): Promise<boolean> => {
    isValidating.value = true
    formErrors.value = []

    try {
      const values = getValues()
      await schema.parseAsync(values)
      for (const key in fields) {
        fields[key].errors = []
      }
      isValidating.value = false
      return true
    } catch (error) {
      const zodError = error as ZodError
      for (const issue of zodError.errors) {
        const path = issue.path[0]?.toString()
        if (path && fields[path]) {
          if (!fields[path].errors.includes(issue.message)) {
            fields[path].errors.push(issue.message)
          }
        } else {
          formErrors.value.push(issue.message)
        }
      }
      isValidating.value = false
      return false
    }
  }

  const isValid = computed(() => {
    return Object.values(fields).every(
      (field) => field.errors.length === 0
    ) && formErrors.value.length === 0
  })

  const getValues = (): FormValues => {
    const values = {} as FormValues
    for (const key in fields) {
      (values as any)[key] = fields[key].value
    }
    return values
  }

  const resetForm = () => {
    for (const key in initialValues) {
      fields[key].value = initialValues[key]
      fields[key].errors = []
      fields[key].touched = false
      fields[key].dirty = false
    }
    formErrors.value = []
  }

  return {
    fields,
    formErrors,
    isValidating,
    isValid,
    validateField,
    validateAll,
    getValues,
    resetForm
  }
}

const userSchema = z.object({
  username: z.string().min(3, 'ユーザー名は3文字以上必要').max(20, 'ユーザー名は20文字以内'),
  email: z.string().email('有効なメールアドレスを入力してください'),
  password: z.string().min(8, 'パスワードは8文字以上必要').regex(
    /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
    'パスワードには大文字・小文字・数字を含めてください'
  )
})

type UserForm = z.infer<typeof userSchema>

パターン3:非同期バリデーションとデバウンス

import { ref, watch, type Ref } from 'vue'

interface AsyncValidationOptions {
  debounceMs?: number
  validateOnChange?: boolean
}

export function useAsyncValidation(
  fieldValue: Ref<string>,
  asyncValidator: (value: string) => Promise<string | null>,
  options: AsyncValidationOptions = {}
) {
  const { debounceMs = 500, validateOnChange = true } = options

  const isValidating = ref(false)
  const asyncError = ref<string | null>(null)
  let debounceTimer: ReturnType<typeof setTimeout> | null = null

  const executeValidation = async (value: string): Promise<boolean> => {
    if (!value) {
      asyncError.value = null
      return true
    }

    isValidating.value = true
    try {
      const error = await asyncValidator(value)
      asyncError.value = error
      return error === null
    } catch (err) {
      asyncError.value = 'バリデーションリクエストに失敗しました。再試行してください'
      return false
    } finally {
      isValidating.value = false
    }
  }

  const debouncedValidate = (value: string) => {
    if (debounceTimer) {
      clearTimeout(debounceTimer)
    }
    debounceTimer = setTimeout(() => {
      executeValidation(value)
    }, debounceMs)
  }

  if (validateOnChange) {
    watch(fieldValue, (newValue) => {
      asyncError.value = null
      debouncedValidate(newValue)
    })
  }

  const validate = async (): Promise<boolean> => {
    if (debounceTimer) {
      clearTimeout(debounceTimer)
    }
    return executeValidation(fieldValue.value)
  }

  const reset = () => {
    if (debounceTimer) {
      clearTimeout(debounceTimer)
    }
    asyncError.value = null
    isValidating.value = false
  }

  return {
    isValidating,
    asyncError,
    validate,
    reset
  }
}

const checkUsernameUnique = async (username: string): Promise<string | null> => {
  const response = await fetch(`/api/check-username?username=${username}`)
  const data = await response.json()
  return data.exists ? 'このユーザー名は既に使用されています' : null
}

パターン4:ダイナミックフォームフィールド管理

import { ref, reactive, computed } from 'vue'
import type { ZodType, ZodError } from 'zod'

interface DynamicFieldConfig {
  key: string
  label: string
  schema: ZodType<any>
  defaultValue: any
}

export function useDynamicForm() {
  const fieldConfigs = reactive<Map<string, DynamicFieldConfig>>(new Map())
  const fieldValues = reactive<Record<string, any>>({})
  const fieldErrors = reactive<Record<string, string[]>>({})
  const fieldTouched = reactive<Record<string, boolean>>({})

  const addField = (config: DynamicFieldConfig) => {
    fieldConfigs.set(config.key, config)
    fieldValues[config.key] = config.defaultValue
    fieldErrors[config.key] = []
    fieldTouched[config.key] = false
  }

  const removeField = (key: string) => {
    fieldConfigs.delete(key)
    delete fieldValues[key]
    delete fieldErrors[key]
    delete fieldTouched[key]
  }

  const addFieldGroup = (configs: DynamicFieldConfig[]) => {
    for (const config of configs) {
      addField(config)
    }
  }

  const removeFieldGroup = (keys: string[]) => {
    for (const key of keys) {
      removeField(key)
    }
  }

  const validateField = async (key: string): Promise<boolean> => {
    const config = fieldConfigs.get(key)
    if (!config) return true

    try {
      await config.schema.parseAsync(fieldValues[key])
      fieldErrors[key] = []
      return true
    } catch (error) {
      const zodError = error as ZodError
      fieldErrors[key] = zodError.errors.map((e) => e.message)
      return false
    }
  }

  const validateAll = async (): Promise<boolean> => {
    let allValid = true
    for (const key of fieldConfigs.keys()) {
      if (!(await validateField(key))) {
        allValid = false
      }
    }
    return allValid
  }

  const touchField = (key: string) => {
    fieldTouched[key] = true
  }

  const isValid = computed(() => {
    return Object.values(fieldErrors).every(
      (errors) => errors.length === 0
    )
  })

  const fieldCount = computed(() => fieldConfigs.size)

  const resetAll = () => {
    for (const [key, config] of fieldConfigs) {
      fieldValues[key] = config.defaultValue
      fieldErrors[key] = []
      fieldTouched[key] = false
    }
  }

  return {
    fieldConfigs,
    fieldValues,
    fieldErrors,
    fieldTouched,
    isValid,
    fieldCount,
    addField,
    removeField,
    addFieldGroup,
    removeFieldGroup,
    validateField,
    validateAll,
    touchField,
    resetAll
  }
}

パターン5:エンドツーエンドフォーム送信とエラー処理

import { ref, computed, type Ref } from 'vue'
import type { ZodType } from 'zod'

interface SubmitOptions<T> {
  schema: ZodType<T>
  onSubmit: (values: T) => Promise<void>
  onSuccess?: () => void
  onError?: (error: unknown) => void
}

interface FormSubmitState {
  isSubmitting: boolean
  isSubmitSuccessful: boolean
  submitCount: number
  serverErrors: Record<string, string[]>
  rootError: string | null
}

export function useFormSubmit<T extends Record<string, any>>(
  getValues: () => T,
  options: SubmitOptions<T>
) {
  const state = ref<FormSubmitState>({
    isSubmitting: false,
    isSubmitSuccessful: false,
    submitCount: 0,
    serverErrors: {},
    rootError: null
  })

  const handleSubmit = async () => {
    state.value.isSubmitting = true
    state.value.rootError = null
    state.value.serverErrors = {}
    state.value.submitCount++

    try {
      const values = getValues()
      const validated = await options.schema.parseAsync(values)

      await options.onSubmit(validated)

      state.value.isSubmitSuccessful = true
      options.onSuccess?.()
    } catch (error: unknown) {
      state.value.isSubmitSuccessful = false

      if (error && typeof error === 'object' && 'errors' in error) {
        const zodLike = error as { errors: Array<{ path: (string | number)[]; message: string }> }
        for (const issue of zodLike.errors) {
          const path = issue.path[0]?.toString()
          if (path) {
            if (!state.value.serverErrors[path]) {
              state.value.serverErrors[path] = []
            }
            state.value.serverErrors[path].push(issue.message)
          } else {
            state.value.rootError = issue.message
          }
        }
      } else if (error instanceof Error) {
        state.value.rootError = error.message
      } else {
        state.value.rootError = '送信に失敗しました。後でもう一度お試しください'
      }

      options.onError?.(error)
    } finally {
      state.value.isSubmitting = false
    }
  }

  const canSubmit = computed(() => {
    return !state.value.isSubmitting
  })

  const clearServerErrors = () => {
    state.value.serverErrors = {}
    state.value.rootError = null
  }

  const resetSubmitState = () => {
    state.value = {
      isSubmitting: false,
      isSubmitSuccessful: false,
      submitCount: 0,
      serverErrors: {},
      rootError: null
    }
  }

  return {
    submitState: state,
    canSubmit,
    handleSubmit,
    clearServerErrors,
    resetSubmitState
  }
}

よくある落とし穴

シナリオ 誤ったアプローチ 正しいアプローチ
バリデーションタイミング ❌ 送信時のみ全フィールドをバリデーション ✅ blur時に個別フィールド、送信時に全フィールドをバリデーション
非同期デバウンス ❌ 入力イベントごとにリクエストを送信 ✅ デバウンスを使用、500ms間入力がない場合のみリクエスト送信
ダイナミックフィールドのクリーンアップ ❌ UIのみ削除、バリデーション状態は残す ✅ fieldValues/fieldErrors/fieldTouchedを同期的にクリーンアップ
クロスフィールドバリデーション ❌ 単一フィールドバリデータに他フィールド参照をハードコード ✅ Zodのrefine/superRefineで宣言的連動バリデーション
エラーメッセージの上書き ❌ サーバーエラーがクライアントバリデーションエラーを直接上書き ✅ clientErrorsとserverErrorsを区別、クライアントエラーを優先表示

エラートラブルシューティング

エラーメッセージ 原因 解決策
Cannot read properties of undefined (reading 'value') fields[key]が未初期化 useValidationで全フィールドに初期値が登録されていることを確認
z.parseAsync is not a function Zod Schema以外のオブジェクトが渡された schemaパラメータがz.object()等のZod型であることを確認
Validate function returned undefined バリデータがtrueまたはエラーメッセージを返さなかった Validatorはtrue(通過)またはstring(エラーメッセージ)を返す必要あり
Maximum call stack size exceeded watchループがバリデーションをトリガー バリデーション結果がwatchを再トリガーしないよう条件を追加
debounce is not cleared on unmount コンポーネントアンマウント時にタイマーがクリーンアップされていない onUnmountedで全debounceTimerをclearTimeout
Async validation result is stale デバウンス中に値が変化したが古いリクエストが返却 AbortControllerを使用するか、現在値とリクエスト値を比較
Field errors not cleared after reset resetFormがerrors配列をクリアしていない resetFormで全フィールドを反復しerrorsを[]にリセットすることを確認
Type 'string' is not assignable to type Zod推論型とフォーム値の型が不一致 z.infer<typeof schema>で型の一貫性を確保
Cross-field validation not triggered 現在フィールドのみバリデーションし関連フィールドをトリガーしていない パスワード確認等では両フィールドをwatchし連動validateが必要
Server errors not showing in UI serverErrorsとclientErrorsのマージロジックが不正 エラー表示エントリを統一、マージ時に重複排除し順序を維持

高度な最適化のヒント

1. バリデーション結果キャッシュ

import { ref, type Ref } from 'vue'

interface CacheEntry {
  value: string
  result: string | null
  timestamp: number
}

export function useValidationCache(
  validator: (value: string) => Promise<string | null>,
  ttlMs = 30000
) {
  const cache = new Map<string, CacheEntry>()

  const validate = async (value: string): Promise<string | null> => {
    const cached = cache.get(value)
    if (cached && Date.now() - cached.timestamp < ttlMs) {
      return cached.result
    }

    const result = await validator(value)
    cache.set(value, { value, result, timestamp: Date.now() })
    return result
  }

  const invalidate = (value?: string) => {
    if (value) {
      cache.delete(value)
    } else {
      cache.clear()
    }
  }

  return { validate, invalidate }
}

2. フォーム状態の永続化

import { watch, onMounted } from 'vue'
import type { Ref } from 'vue'

export function useFormPersistence(
  formKey: string,
  fieldValues: Ref<Record<string, any>>,
  options: { storage?: 'localStorage' | 'sessionStorage'; debounceMs?: number } = {}
) {
  const { storage = 'localStorage', debounceMs = 1000 } = options
  const storageKey = `form-draft:${formKey}`
  let timer: ReturnType<typeof setTimeout> | null = null

  const save = () => {
    const store = window[storage]
    store.setItem(storageKey, JSON.stringify(fieldValues.value))
  }

  const restore = () => {
    const store = window[storage]
    const saved = store.getItem(storageKey)
    if (saved) {
      try {
        const parsed = JSON.parse(saved)
        Object.assign(fieldValues.value, parsed)
      } catch {}
    }
  }

  const clear = () => {
    const store = window[storage]
    store.removeItem(storageKey)
  }

  onMounted(restore)

  watch(
    fieldValues,
    () => {
      if (timer) clearTimeout(timer)
      timer = setTimeout(save, debounceMs)
    },
    { deep: true }
  )

  return { restore, clear }
}

3. 条件付きバリデーションルール

import { z } from 'zod'

export function conditionalSchema<T>(
  condition: () => boolean,
  trueSchema: z.ZodType<T>,
  falseSchema: z.ZodType<T>
) {
  return z.custom<T>((value) => {
    const schema = condition() ? trueSchema : falseSchema
    return schema.safeParse(value).success
  })
}

const formSchema = z.object({
  userType: z.enum(['personal', 'enterprise']),
  companyName: conditionalSchema(
    () => true,
    z.string().min(2, '企業名は2文字以上必要'),
    z.string().optional()
  ),
  idNumber: conditionalSchema(
    () => true,
    z.string().regex(/^\d{18}$/, '18桁のID番号を入力してください'),
    z.string().regex(/^[A-Z0-9]{10}$/, '統一社会信用コードを入力してください')
  )
})

比較分析

次元 自作Composable VeeValidate FormKit Vuelidate
学習曲線 中(リアクティブの理解が必要) 高(豊富なAPI、概念が多い) 中(宣言的設定) 低(デコレータースタイル)
バンドルサイズ <5KB(必要に応じて実装) ~13KB(コア) ~32KB(フル) ~6KB
TypeScriptサポート ネイティブ(Zod推論) 良好(ジェネリック設定が必要) 良好 普通
非同期バリデーション カスタムデバウンス+キャッシュ 内蔵debounce 内蔵rules 手動実装が必要
ダイナミックフィールド 完全制御 useFieldArray Schema駆動 $modelバインディング
クロスフィールドバリデーション Zod refine yup.refine ルール設定 $each+$v
状態管理 細粒度reactive useForm統一管理 ノードツリー $vオブジェクト
カスタマイズ柔軟性 非常に高い 中(APIの制約あり) 高い
Vue 3互換性 ネイティブ v4ネイティブ ネイティブ @vuelidate/core
適用シナリオ カスタマイズ要件が強い 複雑なフォームプロジェクト 迅速な開発 シンプルなバリデーション

まとめと展望

Vue 3フォームバリデーションComposableのコアはバリデーションロジックをコンポーネントから抽出し、リアクティブ状態管理を通じて宣言的バリデーションを実現することにあります。5つのコアパターン — 基礎バリデーションComposable、Zod Schema型安全、非同期バリデーションとデバウンス、ダイナミックフィールド管理、エンドツーエンド送信処理 — はシンプルから複雑までの完全なフォームシナリオをカバーします。

2026年、ZodはTypeScriptエコシステムのSchemaバリデーション標準となり、Vue 3 Composableとの組み合わせにより真の型安全なフォーム開発が実現しています。Vue Vapor Modeの進展に伴い、フォームバリデーションのパフォーマンスはさらに向上するでしょう。useValidation基礎パターンから始め、徐々にZod Schemaと非同期バリデーションを導入し、最終的にチームに適した自作フォームバリデーションソリューションを構築することをお勧めします。


オンラインツールおすすめ

ブラウザローカルツールを無料で試す →

#Vue3表单验证#Composable#表单校验#异步验证#动态表单#2026#前端工程