【Vue.js】watchを活用してデータの変化を監視する方法

watchは、データが変わったときに、決まった処理を実行する仕組みです。入力値を保存する、条件が変わったらAPIを呼ぶ、といった「変化をきっかけにした処理」に使います。この記事では、Vue 3(<script setup>)を前提に、基本から、つまずきやすい挙動までを動くコードで整理します。

スポンサーリンク

基本の書き方

watch(監視する値, コールバック)の形で書きます。コールバックには、変更後の値と、変更前の値が渡されます。

Vue:watchの基本
<script setup>
import { ref, watch } from 'vue'

const count = ref(0)
const message = ref('')

// count が変わるたびに実行される
watch(count, (newValue, oldValue) => {
  message.value = `カウントが ${oldValue} から ${newValue} に変わりました`
})
</script>

<template>
  <button @click="count++">+1</button>
  <p>{{ message }}</p>
</template>

computedとの使い分け

watchを使う前に、まずcomputedで済まないかを考えます。ほかの値から計算できる値はcomputed、状態の変化に応じて何かを実行する副作用はwatch、という使い分けが基本です。たとえば、文字列の長さを別の変数に入れる処理は、watchではなくcomputedで書きます。

Vue:派生値はcomputedで書く
<script setup>
import { ref, computed } from 'vue'

const message = ref('Hello, Vue.js!')

// 他の値から計算できるものは computed で書く(watch で代入しない)
const length = computed(() => message.value.length)
</script>
Vue:副作用はwatchで書く
<script setup>
import { ref, watch } from 'vue'

const keyword = ref('')

// 値が変わったら外部に作用する処理(ここでは保存)は watch の出番
watch(keyword, (newValue) => {
  localStorage.setItem('keyword', newValue)
})
</script>

computedの詳しい使い方はcomputedプロパティで動的なデータ処理を行う方法をご覧ください。

監視できる対象と、getterの渡し方

watchの第1引数には、ref、reactiveオブジェクト、getter関数、またはそれらの配列を渡せます。注意したいのは、reactiveのプロパティです。watch(state.count, …)は、数値そのものを渡すことになり、監視になりません。() => state.countのように、関数で渡します。

Vue:reactiveのプロパティはgetterで渡す
<script setup>
import { reactive, watch } from 'vue'

const state = reactive({ count: 0 })

// NG:数値そのものを渡しても、監視にならない
// watch(state.count, (v) => { ... })

// OK:getter(関数)で渡す
watch(
  () => state.count,
  (count) => {
    console.log(`count は ${count}`)
  }
)
</script>

複数の値をまとめて監視する

Vue:配列で複数のソースを監視
<script setup>
import { ref, watch } from 'vue'

const x = ref(1)
const y = ref(2)

// 配列で複数のソースを監視(どちらかが変わると実行)
watch([x, y], ([newX, newY], [oldX, oldY]) => {
  console.log(`x: ${oldX}→${newX}, y: ${oldY}→${newY}`)
})
</script>

deepオプションと、oldValueの注意

オブジェクトの内部の変更まで拾うのが、deepです。挙動は、渡し方によって変わります。

  • reactiveのオブジェクトを直接渡すと、暗黙に深く監視されます。
  • getterで渡すと、そのオブジェクトが置き換えられたときだけ発火します。内部の変更も拾いたいなら、deep: trueを付けます。
  • ネストした変更では、オブジェクト自体が置き換わらない限り、newValueとoldValueは同じオブジェクトです。変更前の値を比べる用途には使えません。
  • 深い監視は、中のプロパティをすべてたどるため、大きなデータではコストが高くなります。必要なときだけ使ってください。
Vue:deepの挙動の違い
<script setup>
import { reactive, watch } from 'vue'

const user = reactive({ name: 'Alice', profile: { age: 25 } })

// reactive を直接渡すと、暗黙に deep になる
watch(user, (newValue, oldValue) => {
  // ネストした変更では newValue === oldValue(同じオブジェクト)
  console.log('変更されました', newValue.profile.age)
})

// getter で渡すと、オブジェクトが置き換えられたときだけ発火する
watch(
  () => user.profile,
  () => { console.log('profile が置き換えられました') }
)

// getter で渡したうえで、ネストの変更も拾いたいときは deep: true
watch(
  () => user.profile,
  () => { console.log('profile の中身が変わりました') },
  { deep: true }
)

user.profile.age++   // 1つ目と3つ目が発火
</script>

immediateとonce

immediate: trueを付けると、コールバックが、ウォッチャーの作成時にすぐ1回実行されます。「コンポーネントがマウントされた後」ではなく、作成時(<script setup>の実行時)です。初期値で一度処理したいときに使います。once: trueはVue 3.4以降で、最初の変更の1回だけ実行して終わります。

Vue:immediateとonce
<script setup>
import { ref, watch } from 'vue'

const query = ref('vue')

// immediate: true ならウォッチャーの作成時に1回、すぐに実行される
watch(
  query,
  (newValue) => {
    console.log('検索:', newValue)
  },
  { immediate: true }
)

// once: true(Vue 3.4 以降)なら、最初の変更の1回だけ実行される
watch(query, () => console.log('最初の変更'), { once: true })
</script>

flushでコールバックの実行タイミングを変える

コールバックは、既定ではDOMが更新される前に実行されます。更新後のDOMを参照したいときは、flush: 'post'を指定します。

Vue:flush: post
<script setup>
import { ref, watch } from 'vue'

const count = ref(0)

// 既定ではDOMの更新前にコールバックが動く。
// 更新後のDOMを触りたいときは flush: 'post'
watch(count, () => {
  console.log(document.querySelector('#count').textContent)
}, { flush: 'post' })
</script>

監視を止める

<script setup>やsetupの中で同期的に作ったウォッチャーは、コンポーネントが破棄されると自動で止まります。手動で止めたい場合は、watchが返す関数を呼びます。

Vue:unwatchで停止
<script setup>
import { ref, watch } from 'vue'

const count = ref(0)

// watch は停止用の関数を返す
const unwatch = watch(count, () => {
  console.log('監視中')
})

// 監視をやめる
unwatch()
</script>

古いリクエストを中止する

値が素早く何度も変わるときは、前のリクエストの結果が、後から返ってくることがあります。onWatcherCleanupを使うと、次の実行の前に、前回の処理を片付けられます。ここでは、AbortControllerで前のリクエストを中止しています。

Vue:onWatcherCleanupでfetchを中止
<script setup>
import { ref, watch, onWatcherCleanup } from 'vue'

const id = ref(1)

watch(id, (newId) => {
  const controller = new AbortController()

  fetch(`/api/items/${newId}`, { signal: controller.signal })
    .then(res => res.json())
    .then(data => { /* 結果を使う */ })

  // 次の実行の前に呼ばれる。古いリクエストを中止する
  onWatcherCleanup(() => controller.abort())
})
</script>

非同期データの取得の基本は非同期データの取得と表示で解説しています。

watchEffectとの違い

watchは監視する値を明示し、変わったときだけ実行します。watchEffectは、コールバックの中で使った値を自動で追跡し、最初に必ず1回実行します。詳しい使い分けはwatch・watchEffectの違いと使い分けをご覧ください。

Options APIでの書き方

Options APIでは、watchオプションに書きます。既存のコードを読むときのために、書き方を押さえておくと安心です。

Options API:watchオプション
export default {
  data() {
    return { question: '', user: { name: 'Alice' } }
  },
  watch: {
    // メソッドの形で書く
    question(newQuestion, oldQuestion) {
      // question が変わったときの処理
    },
    // オプションを付けるときは handler を使う
    user: {
      handler(newValue) { /* ... */ },
      deep: true,
      immediate: true
    },
    // ドットでつないだパスも指定できる
    'user.name'(newValue) { /* ... */ }
  }
}

関連記事

よくある質問(FAQ)

Qwatchとcomputedはどう使い分けますか?
Aほかの値から計算できる値はcomputed、データの変化をきっかけに何かを実行する処理(保存、API呼び出しなど)はwatchです。watchの中で別の変数に代入しているなら、computedで書けないか見直してください。
Qwatchが発火しないのはなぜですか?
Areactiveのプロパティをwatch(obj.count, …)のように渡している可能性があります。() => obj.countの形で渡してください。また、getterで渡したオブジェクトの内部だけが変わった場合は、deep: trueが必要です。
Qdeepのwatchで、oldValueが新しい値と同じなのはなぜですか?
Aネストした変更では、オブジェクト自体が置き換わらないため、newValueとoldValueが同じオブジェクトを指します。変更前の値が必要なら、変更前にコピーを取っておいてください。
Qimmediateはいつ実行されますか?
Aウォッチャーが作られた時点で、すぐに1回実行されます。コンポーネントのマウントを待ちません。

まとめ

watchは、データの変化をきっかけに、副作用を実行するための仕組みです。計算で済む値はcomputedで書き、reactiveのプロパティはgetterで渡し、deepのoldValueは同じオブジェクトであることに注意します。非同期の処理では、onWatcherCleanupで古いリクエストを片付けると、安全に書けます。