フォームの入力チェックは、間違いをすぐに伝えられるほど親切ですが、タイミングを間違えると、かえって使いにくくなります。この記事では、Vue 3(<script setup>)で、v-modelとcomputedを使った基本のバリデーションを作り、「いつエラーを出すか」「アクセシビリティ」「ブラウザ標準の検証との関係」まで整理します。
よくある実装の問題点
入力値からcomputedでエラーメッセージを作り、そのまま画面に出す書き方は、シンプルでよく見かけます。ただ、次の点で使いにくくなります。
- ページを開いた瞬間に、全項目が赤くなる:まだ入力していないのに、「必須です」が表示されます。入力を終えたり、送信を試みたりするまでは、エラー状態にしないのが望ましいとされています。
- 送信ボタンが最初から押せない:エラーがある間は
disabledにする書き方では、押せない理由が分かりません。無効なボタンはフォーカスもできず、キーボードや読み上げソフトの利用者は、原因にたどり着けません。 - ラベルや入力欄とエラーが結びついていない:読み上げソフトに、どの入力欄のエラーかが伝わりません。
- 空白だけの入力が通る:半角スペース2つでも「2文字以上」を満たしてしまいます。
完成コード:触れたあとと送信後にエラーを出す
エラーメッセージは常にcomputedで算出しておき、画面に出すのは「入力欄から離れたあと(blur)」か「送信を試みたあと」だけにします。送信ボタンは押せるままにして、押したときに、エラーのある最初の項目へフォーカスを移します。
<script setup>
import { ref, reactive, computed } from 'vue'
const form = reactive({ name: '', email: '' })
const touched = reactive({ name: false, email: false }) // 触れたあと、フォーカスが外れた項目
const submitted = ref(false) // 送信を試みたか
// 項目ごとの検証ルール:エラーメッセージを返す(問題なければ空文字)
const rules = {
name: (v) => {
const value = v.trim()
if (value === '') return '名前は必須です。'
if (value.length < 2) return '2文字以上で入力してください。'
return ''
},
email: (v) => {
const value = v.trim()
if (value === '') return 'メールアドレスは必須です。'
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return '正しいメール形式で入力してください。'
return ''
}
}
// エラーは、常にリアルタイムで算出しておく
const errors = computed(() => ({
name: rules.name(form.name),
email: rules.email(form.email)
}))
const hasError = computed(() => Object.values(errors.value).some(Boolean))
// 画面に出すのは、「触れたあと」か「送信を試みたあと」だけ
function visibleError(key) {
return touched[key] || submitted.value ? errors.value[key] : ''
}
function handleSubmit() {
submitted.value = true
if (hasError.value) {
// 最初のエラー項目にフォーカスを移す
const firstKey = Object.keys(errors.value).find((key) => errors.value[key])
document.getElementById(firstKey)?.focus()
return
}
// ここで送信する(サーバー側でも、必ず検証し直す)
console.log('送信する値:', { ...form })
}
</script>
<template>
<!-- novalidate:ブラウザ標準の検証を止め、エラー表示を自前に統一する -->
<form novalidate @submit.prevent="handleSubmit">
<div class="field">
<label for="name">名前</label>
<input
id="name"
v-model="form.name"
type="text"
autocomplete="name"
:aria-invalid="visibleError('name') !== ''"
aria-describedby="name-error"
@blur="touched.name = true"
>
<p id="name-error" class="error" role="alert">{{ visibleError('name') }}</p>
</div>
<div class="field">
<label for="email">メールアドレス</label>
<input
id="email"
v-model="form.email"
type="email"
autocomplete="email"
:aria-invalid="visibleError('email') !== ''"
aria-describedby="email-error"
@blur="touched.email = true"
>
<p id="email-error" class="error" role="alert">{{ visibleError('email') }}</p>
</div>
<!-- ボタンは無効にしない。押したときに、エラーを示す -->
<button type="submit">送信</button>
</form>
</template>
<style scoped>
.field { margin-bottom: 1em; }
label, input { display: block; }
.error { margin: 0.25em 0 0; color: #c62828; font-size: 0.9em; }
input[aria-invalid="true"] { border-color: #c62828; }
</style>
コードのポイント
エラーを出すタイミングを分ける
touchedは項目ごと、submittedはフォーム全体の状態です。visibleError()が、触れた項目か、送信を試みたあとだけ、エラーを返します。一度エラーが表示された項目は、入力に合わせて、リアルタイムで更新・解消されます。
labelとinputを結びつける
<label for="name">と<input id="name">を結びつけると、ラベルをクリックして入力欄にフォーカスでき、読み上げソフトにもラベルが伝わります。
aria-invalidとaria-describedbyでエラーを伝える
エラーのある入力欄にaria-invalid="true"を付け、aria-describedbyでエラーのメッセージの要素を指します。エラー文言にはrole="alert"を付けると、表示されたときに読み上げられます。MDNも、aria-invalidは入力や送信の試行の前にはtrueにしないよう注意しています。Vueでは、falseをaria-invalidのような属性に渡すと、"false"と出力されます。詳しくは属性バインディングの使い方をご覧ください。
novalidateでブラウザ標準の検証を止める
type="email"を付けると、ブラウザが標準の検証を行い、形式が不正なら、独自の吹き出しを出して送信を止めます。この場合、記事の独自のエラー表示が使われません。<form novalidate>で標準の検証を止め、エラー表示を自前に統一しています。なお、標準の検証はusername@domainのようなドットなしのアドレスも許容します。上の正規表現はドットを要求するため、両者の基準が異なる点には注意してください。
trimして判定する
判定の前にtrim()を挟むと、前後の空白だけの入力を弾けます。computedの基本はcomputedプロパティで動的なデータ処理を行う方法、v-modelは双方向データバインディングでフォームの入力値を反映させる方法をご覧ください。
ブラウザ上の検証は、入力を助けるためのもので、セキュリティの対策にはなりません。改ざんや、直接のAPIへのアクセスでは、簡単にすり抜けられます。送信されたデータは、必ずサーバー側でも検証してください。
非同期のバリデーション:メールの重複チェック
メールアドレスがすでに登録されているか、のようなチェックは、サーバーへの問い合わせが必要です。入力のたびに通信しないよう、少し待ってから確認する(デバウンス)のがコツです。形式が正しくないうちは、確認しません。
import { ref, watch } from 'vue'
const emailTaken = ref(false) // すでに登録されているか
const checking = ref(false)
let timer
// 入力のたびに通信しないよう、少し待ってから確認する(デバウンス)
watch(
() => form.email,
(value) => {
clearTimeout(timer)
emailTaken.value = false
// 形式が正しくないうちは、確認しない
if (!value || errors.value.email) return
timer = setTimeout(async () => {
checking.value = true
try {
const res = await fetch(`/api/email-exists?email=${encodeURIComponent(value)}`)
emailTaken.value = (await res.json()).exists
} finally {
checking.value = false
}
}, 500)
}
)
続けて入力したときに、古い応答が新しい結果を上書きする問題(競合状態)や、リクエストの中止などの扱いは、非同期APIの状態管理パターンで解説しています。
ロジックを再利用する・ライブラリを使う
フォームが増えてきたら、検証のロジックをComposableにまとめると、使い回せます。作り方はComposable関数でロジックを共通化する方法、項目が増減するフォームは動的フォームの作り方とバリデーション設計をご覧ください。スキーマで検証ルールを管理したい場合は、バリデーションライブラリvee-validateの導入と使い方が参考になります。
よくある質問(FAQ)
blur)、送信を試みたかの状態を持ち、そのあとだけエラーを表示します。エラーの算出自体は、常に行っておいて構いません。<form novalidate>を付けてください。まとめ
バリデーションは、エラーを算出するだけでなく、「いつ出すか」が使い勝手を決めます。触れたあと、または送信後にエラーを出し、送信ボタンは押せるままにして、label、aria-invalid、aria-describedbyで入力欄と結びつけてください。クライアント側の検証は、入力を助けるためのものです。サーバー側でも、必ず検証し直しましょう。

