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と非同期バリデーションを導入し、最終的にチームに適した自作フォームバリデーションソリューションを構築することをお勧めします。
オンラインツールおすすめ
- Vue SFC Playground — オンラインでVue 3コンポジション関数を記述・デバッグ
- Zod Playground — オンラインでZod Schemaバリデーションルールをテスト
- Regex101 — 正規表現のオンラインデバッグ、バリデーションルール作成を支援
- JSON Schema Validator — Schemaバリデーション比較ツール
- ToolsKu JSONフォーマッター — APIレスポンスJSONをオンラインでフォーマット
ブラウザローカルツールを無料で試す →