- Vue.js
Vue watchEffectとwatchの違い|使い分けとflush
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期連続の黒字決算。