こんにちは、かつコーチです。
前回は、VueUseを使ってブラウザAPIまわりの処理を効率化する方法を解説しました。
今回は、Vueアプリで避けて通れない「フォームバリデーション」を、専用ライブラリのVeeValidateで実装する方法を扱います。
データ連携編の最後のテーマです。
なぜバリデーションを自前で書くと辛くなるのか
自作バリデーションが複雑化しやすい理由
会員登録フォームを例に考えてみましょう。
「必須入力」「メール形式」「パスワードの一致確認」といった項目が増えるほど、refとif文だけで管理するコードは複雑になっていきます。
- 入力中に検証するのか、送信時だけ検証するのか
- エラーメッセージをどこに、どのタイミングで表示するか
- 項目同士が関係するバリデーション(パスワード確認欄など)をどう表現するか
これらを自前で管理すると、コンポーネントが「表示のコード」と「バリデーションのロジック」でどんどん肥大化してしまいます。
VeeValidateが解決してくれること
VeeValidateは、Vue向けに作られたフォームバリデーション専用ライブラリです。
入力値の検証ルール、エラーメッセージの管理、送信タイミングの制御といった、フォーム特有の面倒な処理をまとめて引き受けてくれます。
バリデーションルール自体は、後述するZodやYupといったスキーマライブラリと組み合わせて定義するのが定番です。
VeeValidateの導入
インストール
npm install vee-validate zod @vee-validate/zod
ここでは、TypeScriptとの相性が良いZodをスキーマ定義に使います。
基本のフォームを作る
シンプルなお問い合わせフォームを例に見てみましょう。
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
const schema = toTypedSchema(
z.object({
name: z.string().min(1, '名前を入力してください'),
email: z.string().min(1, 'メールアドレスを入力してください').email('メールアドレスの形式が正しくありません'),
})
)
const { handleSubmit, defineField, errors } = useForm({
validationSchema: schema,
})
const [name, nameAttrs] = defineField('name')
const [email, emailAttrs] = defineField('email')
const onSubmit = handleSubmit((values) => {
console.log('送信された値:', values)
})
</script>
<template>
<form @submit="onSubmit">
<div>
<label for="name">名前</label>
<input id="name" v-model="name" v-bind="nameAttrs" />
<p v-if="errors.name" class="error">{{ errors.name }}</p>
</div>
<div>
<label for="email">メールアドレス</label>
<input id="email" v-model="email" v-bind="emailAttrs" />
<p v-if="errors.email" class="error">{{ errors.email }}</p>
</div>
<button type="submit">送信</button>
</form>
</template>
z.object()でルールを定義し、toTypedSchemaでVeeValidate向けの型付きスキーマに変換します。
defineFieldで各項目をv-modelに紐付けると、errors.nameのように項目ごとのエラーメッセージが自動的に管理されます。
よくあるつまずきポイント:送信タイミングとエラー表示
私が実際に困った「入力中にいきなりエラーが出る」問題
VeeValidateを初めて導入したとき、初期設定のまま使うと「入力欄をクリックしただけ(フォーカスして離れただけ)」でもエラーメッセージが表示されてしまい、ユーザーからは「何も入力していないのに怒られる」と感じられる見た目になってしまったことがあります。
原因は、validateOnBlur(フォーカスが外れたタイミングで検証する設定)がデフォルトで有効になっていたことでした。
初回表示や未入力の状態で赤いエラーが並んでいると、フォームを開いた瞬間から圧迫感を与えてしまいます。
❌ Before:初期表示から全項目のバリデーションが走ってしまう
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
const schema = toTypedSchema(
z.object({
name: z.string().min(1, '名前を入力してください'),
})
)
// 設定を何も変えずに使うと、意図せず早いタイミングでエラーが出てしまう
const { handleSubmit, defineField, errors } = useForm({
validationSchema: schema,
})
const [name, nameAttrs] = defineField('name')
</script>
✅ After:入力が一度も行われるまではエラーを表示しないよう制御する
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
const schema = toTypedSchema(
z.object({
name: z.string().min(1, '名前を入力してください'),
})
)
const { handleSubmit, defineField, errors } = useForm({
validationSchema: schema,
})
// validateOnModelUpdateをfalseにし、入力途中では検証しないようにする
const [name, nameAttrs] = defineField('name', {
validateOnModelUpdate: false,
})
const onSubmit = handleSubmit((values) => {
console.log('送信された値:', values)
})
</script>
<template>
<form @submit="onSubmit">
<input v-model="name" v-bind="nameAttrs" />
<!-- 送信ボタンを押した後、または入力欄からフォーカスが外れた後に表示される -->
<p v-if="errors.name" class="error">{{ errors.name }}</p>
</form>
</template>
defineFieldの第2引数でバリデーションのタイミングを細かく制御できます。
「初回は静かに、送信時やフォーカスアウト時にしっかり検証する」というバランスが、ユーザー体験としては扱いやすいです。
応用:項目間の関係を検証する
パスワード確認欄のような相互チェック
パスワードと確認用パスワードが一致しているかのような、複数項目にまたがるチェックはZodのrefineで表現します。
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
const schema = toTypedSchema(
z
.object({
password: z.string().min(8, 'パスワードは8文字以上で入力してください'),
passwordConfirm: z.string(),
})
.refine((data) => data.password === data.passwordConfirm, {
message: 'パスワードが一致しません',
path: ['passwordConfirm'], // エラーをどの項目に紐付けるか指定する
})
)
const { handleSubmit, defineField, errors } = useForm({
validationSchema: schema,
})
const [password, passwordAttrs] = defineField('password')
const [passwordConfirm, passwordConfirmAttrs] = defineField('passwordConfirm')
const onSubmit = handleSubmit((values) => {
console.log('登録処理を実行:', values)
})
</script>
<template>
<form @submit="onSubmit">
<input v-model="password" v-bind="passwordAttrs" type="password" />
<p v-if="errors.password" class="error">{{ errors.password }}</p>
<input v-model="passwordConfirm" v-bind="passwordConfirmAttrs" type="password" />
<p v-if="errors.passwordConfirm" class="error">{{ errors.passwordConfirm }}</p>
</form>
</template>
pathオプションで、エラーメッセージをどちらの入力欄に表示するかを指定できるのがポイントです。
こうした項目間の相互チェックも、スキーマの中に集約できるのがVeeValidate+Zodの強みです。
次回は認証・セキュリティ編に入り、こうしたフォームで入力された情報をどう安全に扱うかを見ていきます。
まとめ
この記事のポイント
- フォームバリデーションを自前の
refとif文だけで管理すると、コンポーネントが肥大化しやすい - VeeValidateはZod・Yupなどのスキーマと組み合わせて、ルール定義とエラー管理を一元化できる
validateOnModelUpdateなどの設定で、エラー表示のタイミングをユーザー体験に合わせて調整できる- 初期設定のままだと、入力直後からエラーが表示されて圧迫感を与えることがある
- パスワード確認欄のような項目間チェックは、Zodの
refineとpathオプションで表現する
次に読むべき記事
次回からは認証・セキュリティ編に入り、「Vueアプリに認証機能を実装するパターン」を解説します。
→ 次の記事:Vueアプリに認証機能を実装するパターン
