【Vue.js】入力フォームのバリデーション機能を実装する方法|リアルタイムでエラー表示

【Vue.js】入力フォームのバリデーション機能を実装する方法|リアルタイムでエラー表示 Vue.js

フォームの入力チェックは、間違いをすぐに伝えられるほど親切ですが、タイミングを間違えると、かえって使いにくくなります。この記事では、Vue 3(<script setup>)で、v-modelとcomputedを使った基本のバリデーションを作り、「いつエラーを出すか」「アクセシビリティ」「ブラウザ標準の検証との関係」まで整理します。

スポンサーリンク

よくある実装の問題点

入力値からcomputedでエラーメッセージを作り、そのまま画面に出す書き方は、シンプルでよく見かけます。ただ、次の点で使いにくくなります。

  • ページを開いた瞬間に、全項目が赤くなる:まだ入力していないのに、「必須です」が表示されます。入力を終えたり、送信を試みたりするまでは、エラー状態にしないのが望ましいとされています。
  • 送信ボタンが最初から押せない:エラーがある間はdisabledにする書き方では、押せない理由が分かりません。無効なボタンはフォーカスもできず、キーボードや読み上げソフトの利用者は、原因にたどり着けません。
  • ラベルや入力欄とエラーが結びついていない:読み上げソフトに、どの入力欄のエラーかが伝わりません。
  • 空白だけの入力が通る:半角スペース2つでも「2文字以上」を満たしてしまいます。

完成コード:触れたあとと送信後にエラーを出す

エラーメッセージは常にcomputedで算出しておき、画面に出すのは「入力欄から離れたあと(blur)」か「送信を試みたあと」だけにします。送信ボタンは押せるままにして、押したときに、エラーのある最初の項目へフォーカスを移します。

Vue:バリデーション付きフォーム
<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へのアクセスでは、簡単にすり抜けられます。送信されたデータは、必ずサーバー側でも検証してください。

非同期のバリデーション:メールの重複チェック

メールアドレスがすでに登録されているか、のようなチェックは、サーバーへの問い合わせが必要です。入力のたびに通信しないよう、少し待ってから確認する(デバウンス)のがコツです。形式が正しくないうちは、確認しません。

JavaScript:重複チェックの考え方
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)

Qページを開いた直後に、エラーが表示されるのを防ぐには?
A入力欄から離れたか(blur)、送信を試みたかの状態を持ち、そのあとだけエラーを表示します。エラーの算出自体は、常に行っておいて構いません。
Qエラーがあるときは、送信ボタンを無効にしたほうがよいですか?
Aおすすめしません。押せない理由が分からず、キーボードや読み上げソフトでは、原因にたどり着けません。ボタンは押せるままにして、送信時にエラーを示し、最初のエラー項目へフォーカスを移してください。
Qtype=”email”を付けたのに、独自のエラーが表示されません。
Aブラウザ標準の検証が先に動き、不正な形式だと送信を止めるためです。独自のエラー表示に統一するなら、<form novalidate>を付けてください。
Qクライアント側で検証すれば、サーバー側の検証は不要ですか?
A不要ではありません。ブラウザ上の検証は、簡単にすり抜けられます。入力の助けとしてのクライアント側の検証と、安全のためのサーバー側の検証は、両方必要です。

まとめ

バリデーションは、エラーを算出するだけでなく、「いつ出すか」が使い勝手を決めます。触れたあと、または送信後にエラーを出し、送信ボタンは押せるままにして、label、aria-invalid、aria-describedbyで入力欄と結びつけてください。クライアント側の検証は、入力を助けるためのものです。サーバー側でも、必ず検証し直しましょう。