【Vue.js】watch・watchEffectの違いと使い分け|リアクティブな監視処理の最適化

【Vue.js】watch・watchEffectの違いと使い分け|リアクティブな監視処理の最適化 Vue.js

Vueには、データの変化に反応して処理を実行する仕組みが2つあります。watchとwatchEffectです。どちらも副作用を扱うための仕組みで、一見似ていますが、依存する値の指定方法や、実行のタイミングが違います。この記事では、Vue 3を前提に、違いを整理して、使い分けの指針を示します。watchそのものの詳しい使い方はwatchを活用してデータの変化を監視する方法をご覧ください。

スポンサーリンク

watchとwatchEffectの違い

最大の違いは、「何に反応するか」を自分で指定するか、自動に任せるかです。watchは、監視する値を第1引数で明示し、その値が変わったときだけコールバックを呼びます。コールバックの中で使った値は追跡しません。一方、watchEffectは、関数の中で使ったリアクティブな値を自動で追跡し、依存が変わるたびに再実行します。

比較項目 watch watchEffect
依存する値 第1引数で明示する 関数の中で使った値を自動で追跡する
初回の実行 しない(lazy)。immediate: trueで実行できる 最初にすぐ1回実行する
旧値 受け取れる(oldValue) 受け取れない
クリーンアップ 第3引数のonCleanup コールバックの引数のonCleanup

公式は、watchは依存の追跡と副作用を分けるので、いつコールバックが動くかを細かく制御できると説明しています。watchEffectは、追跡と実行が一体で、コードが短くなる反面、依存関係が見えにくくなります。

同じ処理を両方で書いて比べる

「IDが変わったらデータを取得する」という同じ処理を、2通りで書きます。まずwatchです。監視する値を明示し、最初にも実行するためにimmediate: trueを付けています。

Vue:watchで書く
<script setup>
import { ref, watch } from 'vue'

const todoId = ref(1)
const data = ref(null)

// 監視する値(todoId)を第1引数で明示する
// watch は lazy なので、最初にも実行したいなら immediate: true を付ける
watch(
  todoId,
  async () => {
    data.value = null
    const response = await fetch(
      `https://jsonplaceholder.typicode.com/todos/${todoId.value}`
    )
    data.value = await response.json()
  },
  { immediate: true }
)
</script>

次にwatchEffectです。依存の指定が不要で、最初の実行もしてくれるため、短く書けます。

Vue:watchEffectで書く
<script setup>
import { ref, watchEffect } from 'vue'

const todoId = ref(1)
const data = ref(null)

// 関数の中で使った todoId が、自動で追跡される。最初に1回すぐ実行される
watchEffect(async () => {
  data.value = null
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/todos/${todoId.value}`
  )
  data.value = await response.json()
})
</script>

watchEffectの落とし穴:awaitの後は追跡されない

watchEffectが依存を追跡するのは、同期的に実行されている間だけです。公式は、asyncのコールバックでは、最初のawaitより前にアクセスした値だけが追跡されると明記しています。awaitの後で参照した値は、変わっても再実行されません。

Vue:awaitの前後で追跡の有無が変わる
<script setup>
import { ref, watchEffect } from 'vue'

const a = ref(1)
const b = ref(1)

watchEffect(async () => {
  const x = a.value                  // await の前:追跡される

  await Promise.resolve()

  const y = b.value                  // await の後:追跡されない
  console.log(x, y)
})

// a を変えると再実行される
// b を変えても、再実行されない
</script>

非同期の処理で、awaitの後に使う値にも反応させたい場合は、その値をawaitの前に変数へ取り出しておくか、watchで明示的に監視してください。

旧値が必要なら watch

変更前の値と比べたい場合は、watchを使います。watchEffectは旧値を受け取れません。

Vue:旧値の有無
<script setup>
import { ref, watch, watchEffect } from 'vue'

const count = ref(0)

// watch:変更前の値(oldValue)を受け取れる
watch(count, (newValue, oldValue) => {
  console.log(`${oldValue} → ${newValue}`)
})

// watchEffect:旧値は受け取れない。現在の値だけを使う
watchEffect(() => {
  console.log(`現在の値は ${count.value}`)
})
</script>

クリーンアップの渡し方

タイマーや、途中で打ち切りたい通信など、次の実行の前に片付けたい処理には、onCleanupを使います。どちらにも用意されていますが、渡される場所が違います。watchは第3引数、watchEffectはコールバックの引数です。戻り値で関数を返す書き方ではありません。

Vue:onCleanupの渡され方
<script setup>
import { ref, watch, watchEffect } from 'vue'

const id = ref(1)

// watch:第3引数に onCleanup が渡される
watch(id, (newId, oldId, onCleanup) => {
  const timer = setInterval(() => console.log('watch', newId), 1000)
  onCleanup(() => clearInterval(timer))
})

// watchEffect:コールバックの引数に onCleanup が渡される
// (戻り値で関数を返すのではない)
watchEffect((onCleanup) => {
  const timer = setInterval(() => console.log('effect', id.value), 1000)
  onCleanup(() => clearInterval(timer))
})
</script>

実行タイミング:flush

どちらも、コールバックは、既定ではコンポーネントのDOMが更新される前に実行されます。更新後のDOMを参照したいときは、flush: 'post'を指定します。watchEffectには、同じ意味のwatchPostEffectと、同期的に実行するwatchSyncEffectという別名もあります。同期的なウォッチャーはまとめ処理が効かず、値が何度も変わるたびに実行されるので、乱用は避けてください。

Vue:flushの指定
<script setup>
import { ref, watch, watchEffect, watchPostEffect } from 'vue'

const count = ref(0)

// 既定(flush: 'pre'):コンポーネントのDOM更新の前に実行される
watchEffect(() => { /* ... */ })

// 更新後のDOMを触りたいとき(どちらも同じ意味)
watchEffect(() => { /* ... */ }, { flush: 'post' })
watchPostEffect(() => { /* ... */ })

// watch でも同じオプションが使える
watch(count, () => { /* ... */ }, { flush: 'post' })
</script>

止める・一時停止する

watchもwatchEffectも、停止用のハンドルを返します。<script setup>やsetupの中で同期的に作ったものは、コンポーネントの破棄で自動的に止まります。手動で止めたいときだけ、ハンドルを使います。pauseとresumeが使えるバージョンは、公式のAPIリファレンスで確認してください。

Vue:停止用のハンドル
<script setup>
import { ref, watch } from 'vue'

const count = ref(0)

// どちらの関数も、停止用のハンドルを返す
const { stop, pause, resume } = watch(count, () => {
  console.log('変更されました')
})

pause()    // 一時停止
resume()   // 再開
stop()     // 完全に停止
</script>

使い分けの指針

  • watch:反応する値を明確にしたいとき、変更前の値が必要なとき、最初は実行したくないとき。
  • watchEffect:複数の値に反応する処理を短く書きたいとき、最初にすぐ実行したいとき。ただし、awaitの後の値は追跡されない点に注意。
  • ほかの値から計算できるだけなら、どちらでもなくcomputedです。詳しくはcomputedプロパティで動的なデータ処理を行う方法をご覧ください。

迷ったら、依存が分かりやすいwatchから始めるのがおすすめです。処理が単純で、反応する値が自明なときだけwatchEffectに切り替えると、読み手が混乱しにくくなります。

関連記事

よくある質問(FAQ)

QwatchEffectは、どの値に反応しますか?
A関数の中で、同期的に実行されている間にアクセスしたリアクティブな値です。asyncのコールバックでは、最初のawaitより前にアクセスした値だけが追跡されます。
QwatchEffectのクリーンアップは、関数を返せばよいですか?
Aいいえ。コールバックの引数に渡されるonCleanupに、片付ける関数を渡します。戻り値で関数を返す書き方は、Vueではありません。
Qwatchで最初にも実行するには?
A{ immediate: true }を第3引数に渡します。watchは、既定では、監視する値が変わるまでコールバックを呼びません。
Q変更前の値が欲しいときは?
Awatchを使います。コールバックの第2引数でoldValueを受け取れます。watchEffectは、旧値を受け取れません。

まとめ

watchは反応する値を明示し、変わったときだけ動いて、旧値も受け取れます。watchEffectは依存を自動で追跡し、最初にすぐ動いて、短く書けます。awaitの後の値が追跡されないことと、クリーンアップを引数で受け取ることを押さえておけば、どちらも安全に使えます。