watchと検索機能

この章では、watchとwatchEffectを使ってデータの変化を監視し、ブログの検索ボックスにリアルタイムフィルタリング機能を実装する方法を学びます。


どんなシーンでwatchが必要か?

ほとんどの場合、Vueのリアクティブバインディングで十分です。データが変われば、テンプレートが自動的に更新されます。

しかし、いくつかのシーンではデータの変化時に能動的にロジックを実行する必要があります。例えば:

  • 検索ボックスに入力した後、記事リストをフィルタリングする
  • ユーザーがカテゴリを切り替えたとき、ページタイトルを更新する
  • データが変化した後、localStorageに保存する
  • フォームフィールドが変化したとき、フォーム検証を行う

このような場合に必要になるのがwatchとwatchEffect。


watch — 正確な監視

watch指定したデータソースを監視し、データが変化したときにコールバック関数を実行します。

取得できるのは新しい値と古い値で、比較処理を行います。

例

<script setup>
import { ref, watch } from 'vue'

const keyword = ref('')

// watch(監視対象のデータソース, コールバック関数)
watch(keyword, (newVal, oldVal) => {
console.log(`検索キーワードが「${oldVal}」から「${newVal}」に変わりました`)
// ここでフィルタリングロジックを実行
})
</script>

<template>
  <input v-model="keyword" placeholder="記事を検索...">
  <p>現在の検索:{{ keyword }}</p>
</template>

watchの3つのデータソース

データソースの種類書き方例
単一のrefwatch(ref, callback)watch(keyword, fn)
reactiveオブジェクトのプロパティwatch(() => obj.prop, callback)watch(() => user.name, fn)
複数のデータソースwatch([ref1, ref2], callback)watch([kw, cat], fn)

例

import { ref, watch } from 'vue'

const keyword = ref('')
const category = ref('すべて')

// 単一のrefを監視
watch(keyword, (newVal) => {
  document.title = newVal ?`検索:${newVal}` : 'サンプル Blog'
})

// 複数のデータソースを監視
watch([keyword, category], ([newKw, newCat], [oldKw, oldCat]) => {
  console.log('キーワードまたはカテゴリが変わりました。記事を再フィルタリングします')
  // フィルタリングロジックを実行...
})

watchはデフォルトで遅延実行です——データに最初に値が設定されてもトリガーされず、その後の変化でのみトリガーされます。初期化時にも一度実行したい場合は、{ immediate: true }構成オプションを追加します。


watchEffect — 依存関係を自動追跡

watchEffectデータソースを手動で指定する必要はなく、コールバック内で使用されるリアクティブデータを自動的に追跡します。

watchとの最大の違い:watchEffectは即座に一度実行されることです。

例

<script setup>
import { ref, watchEffect } from 'vue'

const keyword = ref('')
const articles = ref([
{ id: 1, title: 'Vue3入門' },
{ id: 2, title: 'JS非同期' },
])

const filteredArticles = ref([])

// watchEffectがkeywordとarticlesを自動追跡
// また即座に一度実行され、filteredArticlesを初期化します
watchEffect(() => {
// 関数内で使われるすべてのリアクティブデータが自動追跡されます
  filteredArticles.value = articles.value.filter(
    a => a.title.includes(keyword.value)
  )
console.log(`フィルタリング結果:${filteredArticles.value.length} 件`)
})
</script>

<template>
  <input v-model="keyword" placeholder="検索">
  <div v-for="a in filteredArticles" :key="a.id">
    {{ a.title }}
  </div>
</template>

watch vs watchEffect 比較

特性watchwatchEffect
データソースの指定手動指定依存関係を自動追跡
初回実行デフォルトでは実行しない(immediate設定可能)即座に実行
旧値の取得旧値を取得できる旧値を取得できない
適用シーン新旧の値を比較し、タイミングを正確に制御する必要がある新旧の値を気にせず、「変化に反応」したいだけ

簡単な選び方:「Xが変化したらYを行う」というロジックなら、watchを使います。「依存が変わったら計算結果を再計算する」というロジックなら、watchEffectまたはcomputedを使います。実際には、計算できるデータはcomputedを優先し、watchは「副作用」——API呼び出し、ローカル保存、DOM操作などの処理に使います。


デバウンスの考え方

検索ボックスでキーを押すたびにフィルタリングが実行されます。ローカルデータならまだ良いですが、入力のたびにネットワークリクエストを送るとなると、大きな無駄が生じます。

デバウンス(Debounce)の考え方:ユーザーが入力を止めてからしばらく経った後に、操作を実行します。

例

import { ref, watch } from 'vue'

const keyword = ref('')
const filteredArticles = ref([])

let timer = null   // タイマーID

watch(keyword, (newVal) => {
  // 前回のタイマーをクリア
  clearTimeout(timer)
  // 300ms以内に新しい入力変化がなければ、フィルタリングを実行
  timer = setTimeout(() => {
    filteredArticles.value = articles.value.filter(
      a => a.title.includes(newVal) || a.summary.includes(newVal)
    )
  }, 300)
})

このブログプロジェクトでは、データがローカルにあるため、デバウンスは必須ではありません。しかし、この考え方を理解しておくことは、今後の大規模プロジェクトで非常に役立ちます。


実践:ブログにリアルタイム検索を追加する

NavBarコンポーネントに検索ボックスを追加し、キーワードを入力して記事のタイトルと要約をリアルタイムにフィルタリングします。

例

<!-- ファイルパス:src/views/HomeView.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import BlogCard from '../components/BlogCard.vue'
import CategoryFilter from '../components/CategoryFilter.vue'

const articles = ref([]) // すべての記事
const isLoading = ref(true)
const activeCategory = ref('すべて')
const keyword = ref('') // 検索キーワード

// すべてのカテゴリを抽出
const categories = computed(() => {
  const cats = articles.value.map(a => a.category)
return ['すべて', ...new Set(cats)]
})

// computedはkeywordとactiveCategoryの変化を自動追跡します
// どちらかが変わると、フィルタリング結果が自動的に再計算されます
const filteredArticles = computed(() => {
  let result = articles.value

// ステップ1:カテゴリでフィルタリング
if (activeCategory.value !== 'すべて') {
    result = result.filter(a => a.category === activeCategory.value)
  }

// ステップ2:キーワードでフィルタリング(タイトルと要約を検索)
  if (keyword.value.trim()) {
    const kw = keyword.value.trim().toLowerCase()
    result = result.filter(a =>
      a.title.toLowerCase().includes(kw) ||
      a.summary.toLowerCase().includes(kw)
    )
  }

  return result
})

// カテゴリを切り替えても検索をクリアせず、両方が組み合わさって機能します
function handleCategoryChange(cat) {
  activeCategory.value = cat
}

// データを読み込む
onMounted(async () => {
  try {
    const res = await fetch('/posts.json')
    articles.value = await res.json()
  } catch (err) {
console.error('読み込み失敗', err)
  } finally {
    isLoading.value = false
  }
})
</script>

<template>
  <div class="home">
    <!-- 検索ボックス -->
    <div class="search-bar">
      <input
       v-model="keyword"
       type="text"
       placeholder="記事のタイトルまたは要約を検索..."
       class="search-input"
     />
      <span v-if="keyword" class="clear-btn" @click="keyword = ''">✕</span>
    </div>

    <!-- カテゴリフィルタ -->
    <CategoryFilter
     :categories="categories"
     :active-category="activeCategory"
     @update-category="handleCategoryChange"
   />

    <!-- 読み込み中 / 空 / 正常 -->
    <p v-if="isLoading">読み込み中...</p>
    <p v-else-if="filteredArticles.length === 0">一致する記事が見つかりません</p>
    <div v-else class="article-grid">
      <BlogCard
       v-for="article in filteredArticles"
       :key="article.id"
       :id="article.id"
       :title="article.title"
       :summary="article.summary"
       :date="article.date"
       :category="article.category"
     />
    </div>
  </div>
</template>

<style scoped>
.home { max-width: 960px; margin: 0 auto; padding: 20px; }

.search-bar {
  position: relative;
  margin-bottom: 20px;
}

.search-input {
  width: 100%;
  padding: 12px 40px 12px 16px;
  border: 2px solid #eee;
  border-radius: 8px;
  font-size: 15px;
  outline: none;
  transition: border-color 0.2s;
}

.search-input:focus {
  border-color: #427DB8;
}

.clear-btn {
  position: absolute;
  right: 12px;
  top: 50%;
  transform: translateY(-50%);
  cursor: pointer;
  color: #999;
  font-size: 18px;
}

.article-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 24px;
  margin-top: 20px;
}
</style>

これで検索とカテゴリフィルタを同時に機能させることができます——検索キーワードを入力した後にカテゴリボタンをクリックすると、結果が重ねてフィルタリングされます。両方の条件がリアクティブなので、どちらかが変化すれば、filteredArticlesは自動的に更新されます。


本章のまとめ

この章では、watch(データソースを手動指定し、新旧の値を取得できる)とwatchEffect(依存関係を自動追跡し、即座に実行する)の使い方を学び、computedを使って検索+カテゴリの組み合わせフィルタリングを実装する方法を習得しました。

検索ボックスのリアルタイムフィルタリングは、「入力してボタンをクリックする」よりもはるかにスムーズです——これがリアクティブシステムの価値です。

その他の拡張