採点されません。匿名です。 間違えることは学習の一部です。読むだけでもかまいません。

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 にする

isNameTakenstore 側 に置くと再利用しやすい:

// 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-validatevalibot のような スキーマ駆動 に移行
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)true30 文字以内で入力してください (現在 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 をエラー枠に付けないと、 メッセージが出る瞬間に画面がガタつく