watchEffectとwatchの違いは、「何を監視するか」を自動で決めるか、自分で指定するかです。 watchEffect は関数の中で読んだリアクティブな値をすべて監視対象にして、作成と同時に1回実行します。watch は第1引数で監視する値を指定し、値が変わったときにだけコールバックを呼び、変更前の値も受け取れます。

この記事では、同じ処理を両方で書いた実行ログを並べて違いを確かめます。さらに、await の後で読んだ値が追跡されない現象、flush(pre・post・sync)ごとの実行順、deep の数値指定と once、Vue 3.5 の onWatcherCleanup による通信の中断、stop・pause・resume まで、すべてコードと出力をセットで載せました。

確認環境:Ubuntu 24.04 / Node.js 22.22.2 / Vue 3.5.43 / jsdom 30.1.1。コードは .mjs ファイルとして Node.js で実行し、画面の内容を読む例だけ jsdom 上にコンポーネントをマウントしています(コンポーネントは h() の描画関数で書いています)。

watchEffectとwatchの早見表

比べる点 watchEffect watch
初回実行 作成と同時に1回実行する 値が変わるまで実行しない(immediate: true で初回も実行)
旧値の取得 受け取れない コールバックの第2引数で受け取れる
依存の指定方法 関数の中で読んだ値が自動で対象になる 第1引数に ref・getter・reactive・それらの配列を書く
依存が決まる時点 実行のたびに読んだ値で決め直す 第1引数だけで決まる(コールバック内で読んだ値は対象外)
await の後で読んだ値 追跡されない 第1引数に書いてあれば関係なく監視される
使えるオプション flush immediate・deep・once・flush
後始末 onWatcherCleanup / 第1引数の onCleanup onWatcherCleanup / 第3引数の onCleanup
停止・一時停止 戻り値の stop・pause・resume 同じ
向く場面 複数の値を読んで、すぐ結果を反映したい処理 特定の値の変化に反応したい、旧値と比べたい、初回は動かしたくない処理

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Vue watchEffectとwatchの違い|使い分けとflush」

オプションの早見表

オプション 指定できる値 働き 使える関数
immediate true 作成時にも1回実行する(旧値は undefined) watch
deep true / 数値(3.5以上) ネストした値の変化も検知する。数値は探索する階層数 watch
once true(3.4以上) 1回実行したら自動で停止する watch
flush 'pre'(既定)/ 'post' / 'sync' 実行するタイミングを選ぶ 両方

watchEffect で flush を指定する代わりに、watchPostEffect(flush: 'post')と watchSyncEffect(flush: 'sync')という別名の関数も使えます。

同じ処理をwatchとwatchEffectで書いて比べる

コードを動かす準備

この記事のコードは、次の準備をすればそのまま動きます。

mkdir watch-demo && cd watch-demo
npm init -y
npm install vue jsdom
node demo.mjs   # コードを demo.mjs に保存して実行

単価と数量から合計を出す例

単価と数量のどちらかが変わったら合計を表示する、という同じ処理を両方で書きます。watch は watchEffect と挙動をそろえるために immediate: true を付けています。

import { ref, watch, watchEffect, nextTick } from 'vue'

const price = ref(1200)
const quantity = ref(2)

// 同じ処理を watch で書く
watch(
  [price, quantity],
  ([p, q], [oldP, oldQ]) => {
    console.log(`[watch]       合計 ${p * q} 円 (前回: ${oldP === undefined ? 'なし' : oldP * oldQ + ' 円'})`)
  },
  { immediate: true },
)

// 同じ処理を watchEffect で書く
watchEffect(() => {
  console.log(`[watchEffect] 合計 ${price.value * quantity.value} 円`)
})

console.log('--- quantity を 3 に ---')
quantity.value = 3
await nextTick()
console.log('--- price を 1000 に ---')
price.value = 1000
await nextTick()

実行結果:

[watch]       合計 2400 円 (前回: なし)
[watchEffect] 合計 2400 円
--- quantity を 3 に ---
[watch]       合計 3600 円 (前回: 2400 円)
[watchEffect] 合計 3600 円
--- price を 1000 に ---
[watch]       合計 3000 円 (前回: 3600 円)
[watchEffect] 合計 3000 円

結果は同じでも、書き方に2つの差があります。 watch は監視する値を [price, quantity] と書き並べ、前回の値を第2引数で受け取っています。watchEffect は監視する値を書かず、関数の中で .value を読んだことで監視が始まります。前回の値は受け取れません。

初回実行と、まとめて1回になる動き

次は immediate を付けない watch と比べます。

import { ref, watch, watchEffect, nextTick } from 'vue'

const keyword = ref('vue')
const page = ref(1)

// watch: 監視する値を第1引数で指定する
watch([keyword, page], ([newKeyword, newPage], [oldKeyword, oldPage]) => {
  console.log(`[watch]       ${oldKeyword}/${oldPage} -> ${newKeyword}/${newPage}`)
})

// watchEffect: 関数の中で読んだ値が自動で監視対象になる
watchEffect(() => {
  console.log(`[watchEffect] 検索: ${keyword.value} の ${page.value} ページ目`)
})

console.log('--- 作成直後 ---')
await nextTick()

console.log('--- page を 2 に ---')
page.value = 2
await nextTick()

console.log('--- keyword と page を同時に変更 ---')
keyword.value = 'react'
page.value = 1
await nextTick()

console.log('--- 同じ値を代入 ---')
page.value = 1
await nextTick()
console.log('(どちらも動かない)')

実行結果:

[watchEffect] 検索: vue の 1 ページ目
--- 作成直後 ---
--- page を 2 に ---
[watch]       vue/1 -> vue/2
[watchEffect] 検索: vue の 2 ページ目
--- keyword と page を同時に変更 ---
[watch]       vue/2 -> react/1
[watchEffect] 検索: react の 1 ページ目
--- 同じ値を代入 ---
(どちらも動かない)

この出力から3つのことがわかります。

  • watchEffect は作成した行で実行される(「作成直後」の表示より前に出ている)。watch は値が変わるまで動きません。
  • 同じタイミングで2つの値を変えても、実行は1回にまとまります。 keyword と page を続けて変えた場面で、どちらも1行だけ出力されています。
  • 同じ値を代入しても動きません。 変化があったときだけ実行されます。

watchEffectの依存はどう決まるか

条件分岐で読まなかった値は対象にならない

watchEffect は、その回の実行で実際に読んだ値だけを監視します。条件分岐で通らなかった側の値は対象になりません。あわせて immediate: true の初回の旧値も確かめます。

import { ref, watch, watchEffect, nextTick } from 'vue'

console.log('=== immediate: true ===')
const size = ref(10)
watch(size, (v, old) => console.log(`watch(immediate): ${old} -> ${v}`), { immediate: true })
size.value = 20
await nextTick()

console.log('=== 条件分岐と依存 ===')
const enabled = ref(false)
const count = ref(0)
watchEffect(() => {
  if (enabled.value) {
    console.log(`effect: count=${count.value}`)
  } else {
    console.log('effect: 無効なので count は読まない')
  }
})
count.value = 1
await nextTick()
console.log('(count を変えても動かない)')
enabled.value = true
await nextTick()
count.value = 2
await nextTick()

実行結果:

=== immediate: true ===
watch(immediate): undefined -> 10
watch(immediate): 10 -> 20
=== 条件分岐と依存 ===
effect: 無効なので count は読まない
(count を変えても動かない)
effect: count=1
effect: count=2

enabled が false のあいだは count を読んでいないため、count を変えても動きません。enabled を true にして再実行されると count も読まれ、そこから監視対象に加わります。依存は実行のたびに決め直されるということです。immediate: true の初回は、旧値が undefined になる点も確認できます。

awaitの後で読んだ値は追跡されない

watchEffect に async 関数を渡した場合、追跡されるのは最初の await より前に読んだ値だけです。3つの書き方を同時に動かして確かめます。

import { ref, watch, watchEffect, nextTick } from 'vue'

const userId = ref(1)
const lang = ref('ja')
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))

// (1) await の後で lang を読んでいる
watchEffect(async () => {
  const id = userId.value            // await の前: 追跡される
  await sleep(10)
  console.log(`[A: awaitの後で読む] id=${id} lang=${lang.value}`) // 追跡されない
})

// (2) await の前に両方読んでおく
watchEffect(async () => {
  const id = userId.value
  const l = lang.value               // await の前: 追跡される
  await sleep(10)
  console.log(`[B: awaitの前で読む] id=${id} lang=${l}`)
})

// (3) watch で依存を明示する
watch([userId, lang], async ([id, l]) => {
  await sleep(10)
  console.log(`[C: watchで明示]     id=${id} lang=${l}`)
})

await sleep(30)
console.log('--- lang を en に ---')
lang.value = 'en'
await sleep(30)
console.log('--- userId を 2 に ---')
userId.value = 2
await sleep(30)

実行結果:

[A: awaitの後で読む] id=1 lang=ja
[B: awaitの前で読む] id=1 lang=ja
--- lang を en に ---
[B: awaitの前で読む] id=1 lang=en
[C: watchで明示]     id=1 lang=en
--- userId を 2 に ---
[A: awaitの後で読む] id=2 lang=en
[B: awaitの前で読む] id=2 lang=en
[C: watchで明示]     id=2 lang=en

lang を en に変えたとき、A(await の後で lang を読む書き方)だけが動いていません。 B と C は動いています。A は userId を変えたときには動き、その時点の lang(en)を表示しています。つまり値は読めるものの、変化のきっかけにはなっていません。

直し方は2つあります。

直し方 書き方 向く場面
await の前に読んでおく const l = lang.value を await より上に書く 処理が短く、読む値が少ないとき
watch で依存を明示する watch([userId, lang], async ([id, l]) => { ... }) 非同期処理が中心のとき。読み忘れが起きない

非同期処理が絡むときは watch を選ぶと、依存がコードの見た目どおりになります。

reactiveのプロパティはgetterで渡す

watch の第1引数に渡せるのは、ref・getter 関数・reactive オブジェクト・それらの配列です。reactive のプロパティが数値や文字列のとき、state.count と書くとただの値が渡ってしまいます。

import { reactive, watch, nextTick } from 'vue'

const state = reactive({ count: 0, user: { name: 'sato' } })

console.log('--- (1) 値をそのまま渡す ---')
watch(state.count, (v) => console.log('(1) 反応:', v))

console.log('--- (2) getter で渡す ---')
watch(() => state.count, (v, old) => console.log(`(2) 反応: ${old} -> ${v}`))

console.log('--- (3) オブジェクトのプロパティをそのまま渡す ---')
watch(state.user, (v) => console.log('(3) 反応:', v.name))

console.log('--- (4) オブジェクトのプロパティを getter で渡す ---')
watch(() => state.user, (v) => console.log('(4) 反応:', v.name))

console.log('=== count を 1 に ===')
state.count = 1
await nextTick()
console.log('=== user.name を書き換え ===')
state.user.name = 'suzuki'
await nextTick()
console.log('=== user を丸ごと差し替え ===')
state.user = { name: 'tanaka' }
await nextTick()

実行結果:

--- (1) 値をそのまま渡す ---
[Vue warn]: Invalid watch source:  0 A watch source can only be a getter/effect function, a ref, a reactive object, or an array of these types.
--- (2) getter で渡す ---
--- (3) オブジェクトのプロパティをそのまま渡す ---
--- (4) オブジェクトのプロパティを getter で渡す ---
=== count を 1 に ===
(2) 反応: 0 -> 1
=== user.name を書き換え ===
(3) 反応: suzuki
=== user を丸ごと差し替え ===
(4) 反応: tanaka
書き方 渡しているもの 結果
watch(state.count, ...) 数値の 0 警告が出て、何も監視されない
watch(() => state.count, ...) getter 関数 count の変化に反応する
watch(state.user, ...) reactive オブジェクト 中のプロパティの変化に反応する。user の差し替えには反応しない
watch(() => state.user, ...) getter 関数 user の差し替えに反応する。中の変化には deep が必要

(3) と (4) は反応する場面が逆になっています。(3) は最初の user オブジェクトを監視し続けるため、差し替えた後の新しいオブジェクトは見ていません。 差し替えと中身の変更の両方に反応させたいときは、watch(() => state.user, fn, { deep: true }) と書きます。

deepの数値指定とonce

deep と once の動きを1つのファイルで確かめます。deep は Vue 3.5 から数値を指定でき、探索する階層数を決められます。

import { ref, reactive, watch, nextTick } from 'vue'

const state = reactive({
  title: 'A',
  user: { name: 'sato', address: { city: 'Tokyo' } },
})

watch(() => state, () => console.log('getter (deep なし) が反応'))
watch(() => state, () => console.log('deep: 1 が反応'), { deep: 1 })
watch(() => state, () => console.log('deep: 2 が反応'), { deep: 2 })
watch(() => state, () => console.log('deep: true が反応'), { deep: true })
watch(state, () => console.log('reactive を直接渡す が反応'))

console.log('--- 1階層目: state.title ---')
state.title = 'B'
await nextTick()
console.log('--- 2階層目: state.user.name ---')
state.user.name = 'suzuki'
await nextTick()
console.log('--- 3階層目: state.user.address.city ---')
state.user.address.city = 'Osaka'
await nextTick()

console.log('=== once ===')
const n = ref(0)
watch(n, (v) => console.log(`once: n=${v}`), { once: true })
watch(n, (v) => console.log(`通常: n=${v}`))
n.value = 1
await nextTick()
n.value = 2
await nextTick()

console.log('=== deep の新旧の値 ===')
const form = ref({ name: 'a' })
watch(form, (nv, ov) => console.log('同じオブジェクト?', nv === ov, nv.name, ov.name), { deep: true })
form.value.name = 'b'
await nextTick()
watch(() => ({ ...form.value }), (nv, ov) => console.log('コピーを返す getter:', ov.name, '->', nv.name))
form.value.name = 'c'
await nextTick()

実行結果:

--- 1階層目: state.title ---
deep: 1 が反応
deep: 2 が反応
deep: true が反応
reactive を直接渡す が反応
--- 2階層目: state.user.name ---
deep: 2 が反応
deep: true が反応
reactive を直接渡す が反応
--- 3階層目: state.user.address.city ---
deep: true が反応
reactive を直接渡す が反応
=== once ===
once: n=1
通常: n=1
通常: n=2
=== deep の新旧の値 ===
同じオブジェクト? true b b
同じオブジェクト? true c c
コピーを返す getter: b -> c

階層ごとの結果を表にまとめます。

書き方 1階層目 state.title 2階層目 state.user.name 3階層目 state.user.address.city
watch(() => state, fn) 反応しない 反応しない 反応しない
{ deep: 1 } 反応する 反応しない 反応しない
{ deep: 2 } 反応する 反応する 反応しない
{ deep: true } 反応する 反応する 反応する
watch(state, fn) 反応する 反応する 反応する
  • getter でオブジェクトそのものを返すだけでは、中身の変化に反応しません。 deep を付けるか、reactive オブジェクトを直接渡します。
  • deep: true はすべての階層をたどるため、大きなデータでは数値で階層を絞ると処理が軽くなります。
  • once: true は1回実行したら自動で止まります。 「最初の入力があったら1回だけ処理する」といった場面に向きます。
  • deep で中身の変更を検知したとき、新旧の値は同じオブジェクトです。 変更前の内容と比べたい場合は、getter でコピーを返します(出力の最後の行)。

flushで実行タイミングを選ぶ

pre・post・syncの実行順

flush は、コールバックを「いつ」実行するかを決めます。同じ値に4つの watch を付け、そのときの画面の内容も一緒に出力します。

import { JSDOM } from 'jsdom'
const dom = new JSDOM('<div id="app"></div>')
globalThis.window = dom.window
globalThis.document = dom.window.document
globalThis.Element = dom.window.Element
globalThis.SVGElement = dom.window.SVGElement

const { createApp, ref, watch, nextTick, h, onUpdated } = await import('vue')

let count
const App = {
  setup() {
    count = ref(0)
    const text = () => document.querySelector('#out').textContent

    watch(count, (v) => console.log(`post: count=${v} 画面=${text()}`), { flush: 'post' })
    watch(count, (v) => console.log(`pre : count=${v} 画面=${text()}`), { flush: 'pre' })
    watch(count, (v) => console.log(`sync: count=${v} 画面=${text()}`), { flush: 'sync' })
    watch(count, (v) => console.log(`既定: count=${v} 画面=${text()}`))
    onUpdated(() => console.log(`onUpdated: 画面=${text()}`))

    return () => h('p', { id: 'out' }, String(count.value))
  },
}
createApp(App).mount('#app')

console.log('--- count を 1, 2 と続けて代入 ---')
count.value = 1
count.value = 2
console.log('--- 代入の直後(同期コードの終わり) ---')
await nextTick()
console.log('--- nextTick の後 ---')

実行結果:

--- count を 1, 2 と続けて代入 ---
sync: count=1 画面=0
sync: count=2 画面=0
--- 代入の直後(同期コードの終わり) ---
pre : count=2 画面=0
既定: count=2 画面=0
post: count=2 画面=2
onUpdated: 画面=2
--- nextTick の後 ---
flush 実行される時点 続けて2回代入したとき 画面(DOM)
'sync' 代入したその場 2回実行される 更新前
'pre'(既定) コンポーネントの再描画の前 1回にまとまる 更新前
'post' コンポーネントの再描画の後 1回にまとまる 更新後
  • 既定は 'pre' です。 何も指定しない watch は pre と同じ時点で動いています。
  • 更新後の DOM を読みたいとき(高さの計測、スクロール位置の調整など)は 'post' を選びます。
  • 'sync' はまとめ処理が効かず、代入のたびに実行されます。 配列に何件も追加するような値では実行回数が増えるので、真偽値のような単純な値に限って使うのがよい選び方です。

watchEffectで使う場合

watchEffect でも同じタイミングを選べます。別名の関数で書いた例です。

import { JSDOM } from 'jsdom'
const dom = new JSDOM('<div id="app"></div>')
globalThis.window = dom.window
globalThis.document = dom.window.document
globalThis.Element = dom.window.Element
globalThis.SVGElement = dom.window.SVGElement

const { createApp, ref, watchEffect, watchPostEffect, watchSyncEffect, nextTick, h } = await import('vue')

let count
const App = {
  setup() {
    count = ref(0)
    const text = () => document.querySelector('#out')?.textContent ?? '(まだ無い)'

    watchEffect(() => console.log(`watchEffect    : count=${count.value} 画面=${text()}`))
    watchPostEffect(() => console.log(`watchPostEffect: count=${count.value} 画面=${text()}`))
    watchSyncEffect(() => console.log(`watchSyncEffect: count=${count.value} 画面=${text()}`))

    return () => h('p', { id: 'out' }, String(count.value))
  },
}
console.log('--- マウント ---')
createApp(App).mount('#app')
console.log('--- count を 1 に ---')
count.value = 1
console.log('--- 代入の直後 ---')
await nextTick()

実行結果:

--- マウント ---
watchEffect    : count=0 画面=(まだ無い)
watchSyncEffect: count=0 画面=(まだ無い)
watchPostEffect: count=0 画面=0
--- count を 1 に ---
watchSyncEffect: count=1 画面=0
--- 代入の直後 ---
watchEffect    : count=1 画面=0
watchPostEffect: count=1 画面=1

初回の実行にも違いがあります。watchEffect と watchSyncEffect は setup の中でその場で実行されるため、まだ画面がありません。watchPostEffect はマウントが終わってから実行されるので、最初から画面の内容を読めます。

後始末:onWatcherCleanupと従来のonCleanup引数

検索語が変わるたびに通信するような処理では、前回の通信を中断しないと、遅れて届いた結果で表示が上書きされることがあります。ウォッチャーには「次の実行の直前」と「停止したとき」に呼ばれる後始末の仕組みがあり、書き方は2通りです。

書き方 使えるバージョン 受け取り方 await の後
onWatcherCleanup(fn) 3.5以上 vue から import する 呼べない
onCleanup(fn) 引数 3.0以上 watch は第3引数、watchEffect は第1引数 呼べる

300ミリ秒後に応答するサーバーをローカルに立て、fetch を AbortController で中断する例です。

import http from 'node:http'
import { ref, watch, watchEffect, onWatcherCleanup, nextTick } from 'vue'

// 300ms 後に応答する確認用サーバー
const server = http.createServer((req, res) => {
  setTimeout(() => {
    res.setHeader('Content-Type', 'application/json')
    res.end(JSON.stringify({ path: req.url }))
  }, 300)
})
await new Promise((r) => server.listen(8920, '127.0.0.1', r))
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))

async function load(label, id, signal) {
  try {
    const res = await fetch(`http://127.0.0.1:8920/items/${id}`, { signal })
    console.log(`${label} 完了:`, await res.json())
  } catch (e) {
    console.log(`${label} id=${id} は中断: ${e.name}`)
  }
}

console.log('=== onWatcherCleanup (Vue 3.5+) ===')
const id = ref(1)
const stop1 = watch(id, (newId) => {
  const controller = new AbortController()
  onWatcherCleanup(() => controller.abort())
  load('[A]', newId, controller.signal)
}, { immediate: true })

await sleep(50); id.value = 2
await sleep(50); id.value = 3
await sleep(500)
stop1()

console.log('=== onCleanup 引数 (watch は第3引数) ===')
const stop2 = watch(id, (newId, oldId, onCleanup) => {
  const controller = new AbortController()
  onCleanup(() => controller.abort())
  load('[B]', newId, controller.signal)
})
id.value = 4
await sleep(50); id.value = 5
await sleep(500)
stop2()

console.log('=== onCleanup 引数 (watchEffect は第1引数) ===')
const stop3 = watchEffect((onCleanup) => {
  const controller = new AbortController()
  onCleanup(() => controller.abort())
  load('[C]', id.value, controller.signal)
})
await sleep(50); id.value = 6
await sleep(50)
console.log('--- stop() を呼ぶ ---')
stop3()
await sleep(400)

console.log('=== await の後で onWatcherCleanup を呼ぶ ===')
const stop4 = watch(id, async () => {
  await sleep(10)
  onWatcherCleanup(() => console.log('ここは呼ばれない'))
})
id.value = 7
await sleep(50)
id.value = 8
await sleep(50)
stop4()

console.log('=== await の後でも使える onCleanup 引数 ===')
const stop5 = watch(id, async (v, old, onCleanup) => {
  await sleep(10)
  onCleanup(() => console.log(`[D] id=${v} の後始末が動いた`))
})
id.value = 9
await sleep(50)
id.value = 10
await sleep(50)
stop5()
server.close()

実行結果:

=== onWatcherCleanup (Vue 3.5+) ===
[A] id=1 は中断: AbortError
[A] id=2 は中断: AbortError
[A] 完了: { path: '/items/3' }
=== onCleanup 引数 (watch は第3引数) ===
[B] id=4 は中断: AbortError
[B] 完了: { path: '/items/5' }
=== onCleanup 引数 (watchEffect は第1引数) ===
[C] id=5 は中断: AbortError
--- stop() を呼ぶ ---
[C] id=6 は中断: AbortError
=== await の後で onWatcherCleanup を呼ぶ ===
[Vue warn] onWatcherCleanup() was called when there was no active watcher to associate with.
[Vue warn] onWatcherCleanup() was called when there was no active watcher to associate with.
=== await の後でも使える onCleanup 引数 ===
[D] id=9 の後始末が動いた
[D] id=10 の後始末が動いた
  • A(onWatcherCleanup):id を 1 → 2 → 3 と変えると、1 と 2 の通信は中断され、最後の 3 だけが完了しています。
  • B・C(onCleanup 引数):同じ結果になります。C では stop() を呼んだ時点でも後始末が動き、進行中の通信が中断されています。
  • await の後で onWatcherCleanup を呼ぶと、警告が出て登録されません。 公式ドキュメントにも、同期的な実行の最中に呼ぶ必要があると書かれています。
  • onCleanup 引数はウォッチャーに結び付いているため、await の後でも登録できます(D の出力)。

使い分けの目安は次のとおりです。コールバックの先頭で登録できるなら、import するだけで使える onWatcherCleanup が簡潔です。await の後で登録する必要がある場合や、Vue 3.4 以前でも動かすコードでは onCleanup 引数を使います。

停止と一時停止:stop・pause・resume

戻り値で止める

watch と watchEffect の戻り値は、呼び出すと停止する関数です。Vue 3.5 からは pause・resume・stop のメソッドも付いています。

import { ref, watch, watchEffect, nextTick } from 'vue'

const count = ref(0)
const handle = watchEffect(() => console.log(`effect: count=${count.value}`))
console.log('ハンドルの中身:', typeof handle, Object.keys(handle))

console.log('--- pause() ---')
handle.pause()
count.value = 1
await nextTick()
count.value = 2
await nextTick()
console.log('(停止中は動かない)')

console.log('--- resume() ---')
handle.resume()
await nextTick()

console.log('--- pause 中に変更なしで resume() ---')
handle.pause()
handle.resume()
await nextTick()
console.log('(変更が無ければ動かない)')

console.log('--- stop() ---')
handle.stop()
count.value = 3
await nextTick()
console.log('(stop 後は動かない)')
handle.resume()
count.value = 4
await nextTick()
console.log('(stop 後は resume しても動かない)')

console.log('--- 分割代入でも受け取れる ---')
const { stop, pause, resume } = watch(count, (v) => console.log(`watch: count=${v}`))
count.value = 5
await nextTick()
stop()
count.value = 6
await nextTick()
console.log('(stop 後は動かない)')

実行結果:

effect: count=0
ハンドルの中身: function [ 'pause', 'resume', 'stop' ]
--- pause() ---
(停止中は動かない)
--- resume() ---
effect: count=2
--- pause 中に変更なしで resume() ---
(変更が無ければ動かない)
--- stop() ---
(stop 後は動かない)
(stop 後は resume しても動かない)
--- 分割代入でも受け取れる ---
watch: count=5
(stop 後は動かない)
操作 働き 再開
handle.pause() 一時停止する。停止中の変更では実行しない resume() で再開できる
handle.resume() 再開する。停止中に変更があった場合は、その場で1回実行する ―
handle.stop() または handle() 完全に停止する 再開できない

停止中に count を 1、2 と変えたあと resume() を呼ぶと、最新の値(2)で1回だけ実行されています。停止中に変更が無ければ、resume() しても実行されません。モーダルを閉じているあいだだけ処理を休ませる、といった用途に使えます。

コンポーネントで自動的に止まる場合と、自分で止める場合

setup(script setup を含む)の中で同期的に作ったウォッチャーは、コンポーネントがアンマウントされると自動で止まります。setTimeout や await の後など、非同期のタイミングで作ったものは自動では止まりません。

import { JSDOM } from 'jsdom'
const dom = new JSDOM('<div id="app"></div>')
globalThis.window = dom.window
globalThis.document = dom.window.document
globalThis.Element = dom.window.Element
globalThis.SVGElement = dom.window.SVGElement

const { createApp, ref, watch, nextTick, h, onUnmounted } = await import('vue')
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))

const count = ref(0)   // コンポーネントの外にある共有の状態
const App = {
  setup() {
    // (1) setup の中で同期的に作る: アンマウント時に自動で止まる
    watch(count, (v) => console.log(`(1) 同期で作成: ${v}`))

    // (2) 非同期のコールバックの中で作る: 自動では止まらない
    setTimeout(() => {
      watch(count, (v) => console.log(`(2) 後から作成: ${v}`))
    }, 0)

    // (3) 後から作るが、ハンドルを取っておいて自分で止める
    let handle
    setTimeout(() => {
      handle = watch(count, (v) => console.log(`(3) 後から作成+stop: ${v}`))
    }, 0)
    onUnmounted(() => handle?.stop())

    return () => h('p', String(count.value))
  },
}
const app = createApp(App)
app.mount('#app')
await sleep(10)

console.log('--- マウント中に count を 1 に ---')
count.value = 1
await nextTick()

app.unmount()
console.log('--- アンマウント後に count を 2 に ---')
count.value = 2
await nextTick()

実行結果:

--- マウント中に count を 1 に ---
(2) 後から作成: 1
(3) 後から作成+stop: 1
(1) 同期で作成: 1
--- アンマウント後に count を 2 に ---
(2) 後から作成: 2

アンマウント後に動いているのは (2) だけです。後から作る必要があるときは、(3) のようにハンドルを取っておき onUnmounted で stop() を呼びます。 条件が整うまで待つだけなら、ウォッチャーは最初に同期的に作り、コールバックの中で条件を判定する書き方にすると、自動停止に任せられます。

watchEffectとwatchの使い分け

判断表

やりたいこと 選ぶもの 理由
複数の値を読んで、初回からすぐ処理したい watchEffect 依存を書き並べずに済み、初回も実行される
変更前の値と比べたい watch 第2引数で旧値を受け取れる
初回は動かさず、変わったときだけ処理したい watch 既定で初回は実行しない
非同期処理の中で複数の値を使う watch await の後で読む値も、第1引数に書けば監視できる
読む値は多いが、きっかけにしたい値は1つだけ watch コールバック内で読んだ値は依存にならない
1回だけ反応したい watch + once: true 自動で停止する
ネストの深いオブジェクトの一部だけ監視したい watch + getter () => state.user.name のように必要な値だけ指定できる
更新後の DOM を使いたい どちらでも flush: 'post' watchPostEffect でも書ける
値から別の値を作りたいだけ computed 副作用が無い計算は computed が向く

迷ったときの考え方

  • 「この値が変わったら、これをする」と言い切れるなら watch です。きっかけがコードに書かれるので、後から読む人にも伝わります。
  • 「ここで使っている値のどれかが変わったら、やり直す」なら watchEffect です。使う値が増えても、依存を書き足す必要がありません。
  • watchEffect は watch に immediate: true を付けたものに近い動きをします。 違いは、依存を自動で集めることと、旧値を受け取れないことです。

つまずきやすい点と直し方

起きること 理由 直し方
watch(state.count, ...) が動かず警告が出る 数値そのものを渡している watch(() => state.count, ...) と getter で渡す
watchEffect が一部の値の変更で動かない await の後や、通らなかった条件分岐の中で読んでいる await の前で読む、または watch で依存を明示する
ref に入れたオブジェクトの中身を変えても watch が動かない ref の監視は .value の差し替えだけを見る { deep: true } を付ける、または getter で必要なプロパティを返す
新旧の値が同じ内容になる 中身の書き換えでは同じオブジェクトが渡される getter でコピー({ ...obj })を返す
コールバックで DOM を読むと変更前の内容になる 既定の flush: 'pre' は再描画の前に動く flush: 'post' にする
続けて代入したのに1回しか動かない pre と post は同じタイミングの変更をまとめる 仕様どおり。毎回必要なら flush: 'sync'
onWatcherCleanup で警告が出る await の後で呼んでいる await の前で呼ぶ、または onCleanup 引数を使う
アンマウント後もウォッチャーが動き続ける 非同期のタイミングで作成している ハンドルを保持して onUnmounted で stop() を呼ぶ

よくある質問

Q. watchEffectとwatchは、どちらを基本にするとよいですか?

A. 「きっかけになる値」がはっきりしている処理は watch、読む値をそのまま依存にしたい短い処理は watchEffect が向きます。非同期処理を含む場合は、依存が見た目どおりになる watch を選ぶと読みやすくなります。

Q. watchEffectで前回の値を使いたいときはどうしますか?

A. watchEffect は旧値を受け取れません。旧値が必要なら watch に書き換えます。この記事の最初の例のように、watch([a, b], ([a1, b1], [a0, b0]) => { ... }, { immediate: true }) と書けば、初回実行つきで旧値も使えます。

Q. immediate: true を付けたwatchと、watchEffectは同じですか?

A. 初回から実行される点は同じです。違いは、watch は第1引数に書いた値だけを監視し旧値を受け取れること、watchEffect は関数内で読んだ値を自動で監視することです。await の後で読む値がある場合は結果が変わります。

Q. flush: 'post' と nextTick はどう使い分けますか?

A. 値が変わるたびに更新後の DOM で処理したいなら flush: 'post' のウォッチャー、ボタンを押したときなど特定の処理の中で1回だけ待ちたいなら await nextTick() が向きます。

まとめ

  • watchEffect は読んだ値を自動で監視して初回から実行、watch は指定した値が変わったときに実行し、旧値も受け取れます。
  • watchEffect で追跡されるのは最初の await より前に読んだ値だけです。非同期処理では watch で依存を明示すると確実です。
  • reactive のプロパティは () => state.count と getter で渡します。 値をそのまま渡すと警告が出て監視されません。
  • flush は pre(再描画の前・既定)、post(再描画の後)、sync(その場で毎回) の3つです。DOM を読むなら post を選びます。
  • deep は数値で階層を絞れ(3.5以上)、once は1回で自動停止します(3.4以上)。
  • 後始末は onWatcherCleanup(3.5以上)が簡潔で、await の後で登録するなら onCleanup 引数を使います。
  • 戻り値の stop・pause・resume で停止と再開ができ、非同期に作ったウォッチャーは自分で stop() します。

参考

  • Vue.js 公式ガイド「ウォッチャー」 https://ja.vuejs.org/guide/essentials/watchers
  • Vue.js 公式 API リファレンス「リアクティビティー API: コア」 https://ja.vuejs.org/api/reactivity-core

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Vue watchEffectとwatchの違い|使い分けとflush」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?