Vue — リアクティブバリデーション
テクノロジ系 / L10 forms — フォーム / リアクティブバリデーション
Vue — リアクティブバリデーション
forms / ch01
このチャプターの到達ゴール
| 翌日 |
|---|
| 「ボタンを押せる条件」 を if 文の塊ではなく 派生値 (computed) として書ける |
「触ってないのに怒られる」 を touched で防ぐ手筋を 1 つ持つ |
| 素 computed の限界 = 3 フィールド を意識し、 それ超えたらライブラリに移れる |
目的 (= 3 ルールのバリデーション)
入力フォームの追加ボタンを 「条件を満たすまで押せない」 状態にする:
| ルール | NG にしたい入力 | 表示するメッセージ |
|---|---|---|
| 必須 | 空文字 / 空白だけ | 「タスク名を入力してください」 |
| 文字数 | 31 文字以上 | 「30 文字以内で入力してください (現在 N)」 |
| 重複 | 既に store にある名前 | 「同じ名前のタスクが既にあります」 |
→ これを computed だけで 組む。
個別バリデーションを computed で書く
<script setup>
import { ref, computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useTasksStore } from '@/stores/tasks'
const store = useTasksStore()
const { tasks } = storeToRefs(store)
const name = ref('')
// 個別バリデーション (= computed なので入力が変わるたび自動再計算)
const nameRequired = computed(() => name.value.trim().length > 0)
const nameLength = computed(() => name.value.length <= 30)
const nameNotDup = computed(() => !tasks.value.some(t => t.name === name.value.trim()))
</script>統合バリデーション + メッセージ
// 全部通ったら true
const isValid = computed(() =>
nameRequired.value && nameLength.value && nameNotDup.value
)
// エラーメッセージ (= 優先順位で 1 つだけ出す)
const errorMessage = computed(() => {
if (!nameRequired.value) return 'タスク名を入力してください'
if (!nameLength.value) return `30 文字以内で入力してください (現在 ${name.value.length})`
if (!nameNotDup.value) return '同じ名前のタスクが既にあります'
return ''
})> エラーは 1 つずつ出す 方が読みやすい (= 全部並べると圧迫感が強い)。
テンプレートに反映
<template>
<input v-model="name" placeholder="タスク名 (30 文字まで)" />
<p class="error">{{ errorMessage }}</p>
<button :disabled="!isValid" @click="onSubmit">追加</button>
</template>
<style scoped>
.error { color: #b91c1c; font-size: 0.9em; min-height: 1.4em; }
button[disabled] { opacity: 0.5; cursor: not-allowed; }
</style>> エラーメッセージ枠は 常に DOM にある (= min-height で高さが揺れない)。
「初期表示でエラーが出る」 問題
name = '' で開始 → いきなり 「タスク名を入力してください」 が出る。
これは UX として悪い (= 触ってないのに怒られる)。
→ 「ユーザーが触ったか」 フラグ を持つ:
const touched = ref(false)
const errorMessage = computed(() => {
if (!touched.value) return '' // 触られるまでエラーは出さない
if (!nameRequired.value) return '...'
// ...
})
// input に @blur="touched = true" を付ける「重複チェック」 を store の getter にする
isNameTaken を store 側 に置くと再利用しやすい:
// stores/tasks.js
function isNameTaken(name) {
const trimmed = name.trim()
return tasks.value.some(t => t.name === trimmed)
}
return { tasks, load, add, isNameTaken }呼び出し側:
const nameNotDup = computed(() => !store.isNameTaken(name.value))
> ルール: 「複数画面で使いそうなロジック」 は store へ。 1 画面限定なら view にあってよい。
onSubmit で防御 (= touched + isValid)
async function onSubmit() {
touched.value = true
if (!isValid.value) return // 防御線 (= disabled をすり抜けても止まる)
await store.add(name.value.trim())
router.push('/')
}> disabled は UI、 isValid チェックは ロジック。 両方持つ (= 二重防御)。
⚠️ ボタンを連打されたときの 二重送信防止 は api/ch01-async-ux-3states で扱う (= 失敗 UX 込みで設計するのが筋)。
「素の computed」 はどこまで使えるか (= 実務境界)
| フィールド数 | 推奨アプローチ |
|---|---|
| 1〜3 | 素の computed で十分。 学ぶ価値が高い (= reactive の基本) |
| 4〜10 | 素の computed だと辛い。 vee-validate や valibot のような スキーマ駆動 に移行 |
| 10+ / 複雑な分岐 | zod + 専用フォームライブラリ。 型と整合させる |
> 今日学んだ「computed バリデーション」 は 基礎の理屈。 実務では多くがライブラリで書かれている。
> ライブラリを使う日が来たとき、 中で起きていることが分かる状態を作るのが今日の目的。
チャプター完了サイン
- [ ] 空 / 31 文字 / 重複 の各ケースで ボタンが disabled になる
- [ ] それぞれ違うエラーメッセージが出る
- [ ] 初回フォーカス時はエラーが出ない (= touched 制御)
- [ ] 正しい入力を入れると 追加 → 一覧に戻る
- [ ] 「素 computed の限界 = 3 フィールド」 を 1 文で言える
> ⚠️ trim() を忘れない (= 「 」 (空白のみ) で追加できてしまうバグの典型)。
次に学ぶ領域
| 領域 | 名前 |
|---|---|
| スキーマ駆動バリデーション | vee-validate / valibot / zod |
| 非同期バリデーション | サーバー側重複チェック / debounce |
| 失敗 UX / 二重送信防止 | api/ch01-async-ux-3states |
| フォーカス制御 / アクセシビリティ | a11y 専用 chapter |
考えてみよう
既定では正解・不正解の判定はしません。自分のペースで「答えを見る」を。採点してほしい場合だけ「採点してほしい」を押してください。
ch01 — 演習
Lv1: 個別 3 ルールを computed で書く
TaskAddView.vue で:
const name = ref('')
// TODO 1: nameRequired = computed(() => name.value.trim().length > 0)
// TODO 2: nameLength = computed(() => name.value.length <= 30)
// TODO 3: nameNotDup = computed(() => !tasks.value.some(t => t.name === name.value.trim()))検証
<p>{{ nameRequired }} {{ nameLength }} {{ nameNotDup }}</p> をテンプレートに置いて、 入力に応じて 3 つの true/false が変わることを確認。
Lv2: isValid + errorMessage で統合
3 つの個別 computed を isValid に統合し、 errorMessage で 優先順位 1 つだけ出す。
// TODO 4: isValid = computed(() => 3 つ全部 true) // TODO 5: errorMessage = computed(() => if-else chain で 1 文だけ返す)
ボタンに :disabled="!isValid"、 エラー表示に <p>{{ errorMessage }}</p>。
検証
| 入力 | disabled | メッセージ |
|---|---|---|
'' | true | タスク名を入力してください |
'a'.repeat(31) | true | 30 文字以内で入力してください (現在 31) |
'既存タスク名' (= store にある名前) | true | 同じ名前のタスクが既にあります |
'新規タスク' | false | (空) |
Lv3: touched フラグで初回エラー抑制
初期表示で「触ってないのに怒られる」 を解消する。
// TODO 6: const touched = ref(false) // TODO 7: errorMessage の頭で if (!touched.value) return '' // TODO 8: input に @blur="touched = true"
検証
- 初期表示: エラーメッセージ枠は 空
- input にフォーカスして抜けた直後: エラーメッセージ表示
Lv4: isNameTaken を store の getter に持ち上げる
重複チェックロジックを TaskAddView から stores/tasks.js に移す:
// stores/tasks.js
function isNameTaken(name) {
const trimmed = name.trim()
return tasks.value.some(t => t.name === trimmed)
}
return { tasks, load, add, isNameTaken }TaskAddView 側:
const nameNotDup = computed(() => !store.isNameTaken(name.value))
検証
同じ動作のまま、 TaskAddView から重複チェックロジックが消えていること。
提出物
「なぜ isNameTaken を store に持ち上げたか」 を 1 文で書け。
チャレンジ
- フィールドを 5 個 (= name, priority, dueDate, tags, memo) に増やした場合、 素 computed で書き切れるか試してみる
- 書けないと感じたら、 それが「ライブラリに移るべきタイミング」 (= 本編末尾の表参照)
ヒント
trim()を忘れがち (= 「 」 だけで追加できてしまう)disabledだけだと無効。onSubmit内でもif (!isValid.value) returnで防御min-height: 1.4emをエラー枠に付けないと、 メッセージが出る瞬間に画面がガタつく