computedとwatchの違いとは、「値を作るためのもの(computed)か、値の変化をきっかけに処理を行うためのもの(watch)か」の違いです。 computed は元の状態から計算した値を返してキャッシュし、watch は変化を受けて API 呼び出しや保存などの副作用を実行します。watchEffect は watch の仲間で、監視対象を書かなくても中で読んだ値を自動で追跡します。

この記事では、3つの API を5つの軸で比べる早見表と、deep・immediate・once・flush の全オプション一覧を最初に置きました。さらに「reactive のプロパティを直接渡して監視が効かない」「deep で新旧の値が同じになる」といったつまずきやすい動きを、Vue 3.5.43 で実際に動かしたログつきで再現しています。Vue 3.5 で加わった onWatcherCleanup と数値指定の deep も扱います。

computed・watch・watchEffectの早見表

比べる軸 computed watch watchEffect
役割 値を計算して返す 変化をきっかけに処理する 変化をきっかけに処理する
戻り値 読み取り専用の ref(ComputedRef) 停止用のハンドル(関数) 停止用のハンドル(関数)
旧値の取得 ゲッターの第1引数で前回の結果を受け取れる コールバックの第2引数で取れる 取れない
初回実行 最初に読まれたときに計算(遅延) しない(immediate: true で実行) 定義した時点で1回実行
依存の指定方法 自動(ゲッター内で読んだもの) 第1引数で明示 自動(同期的に読んだもの)
副作用(通信・保存・DOM 操作) 書かない 書ける 書ける
キャッシュ あり(依存が変わるまで再計算しない) なし なし
テンプレートでの利用 そのまま表示に使う 使わない 使わない

選び方は次の3行で足ります。

やりたいこと 選ぶもの
状態から別の値を作って表示したい(合計、絞り込み、整形) computed
特定の値が変わったときだけ処理したい、変更前の値も欲しい watch
使っている値のどれかが変わったら同じ処理をやり直したい、初回も実行したい watchEffect

watchのオプション早見表

オプション 値 既定 効果 使えるバージョン
deep true / false / 数値 ソースによる ネストした変更も検知する。数値は探索する階層の深さ 数値指定は 3.5 以降
immediate true / false false 定義した時点で1回実行する(旧値は undefined) ―
once true / false false 最初の1回だけ実行して自動で止まる 3.4 以降
flush 'pre' / 'post' / 'sync' 'pre' コールバックを実行するタイミング ―

バージョンの条件は Vue 公式ガイドの Watchers のページ(今回参照)の記載です。watchEffect で使えるのは flush のみで、watchPostEffect・watchSyncEffect という別名の関数も用意されています。

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

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

検証した環境

項目 バージョン
Vue 3.5.43
@vue/test-utils 2.5.1
jsdom 30.1.1
Node.js 22.22.2

コンポーネントを使う例(flush と自動停止)は、<script setup> の SFC を @vue/compiler-sfc でコンパイルし、Vue Test Utils で jsdom 上にマウントして動かしました。それ以外の例は、同じ watch・computed の呼び出しを Node.js のスクリプト(.mjs)として実行しています。vue パッケージさえ入っていれば、次のコマンドで同じ結果を得られます。

npm i vue
node demo.mjs

3つを同じデータで動かして違いを見る

税抜価格 price から税込価格を求める例で、3つを同時に動かします。

// demo.mjs
import { ref, computed, watch, watchEffect, nextTick } from 'vue'
const log = console.log

const price = ref(100)
const withTax = computed(() => { log('  computed 実行'); return price.value * 1.1 })
watch(price, (n, o) => log(`  watch: ${o} -> ${n}`))
watchEffect(() => log(`  watchEffect: price=${price.value}`))

log('-- 定義直後')
log('  withTax.value =', Math.round(withTax.value))
log('  withTax.value =', Math.round(withTax.value), '(2回目)')
price.value = 200
log('-- price=200 を代入した直後(同期)')
await nextTick()
log('-- nextTick 後')
log('  withTax.value =', Math.round(withTax.value))

実行結果:

  watchEffect: price=100
-- 定義直後
  computed 実行
  withTax.value = 110
  withTax.value = 110 (2回目)
-- price=200 を代入した直後(同期)
  watch: 100 -> 200
  watchEffect: price=200
-- nextTick 後
  computed 実行
  withTax.value = 220

このログから読み取れることを整理します。

観察 意味
watchEffect だけが「定義直後」より前に出ている watchEffect は定義した時点で1回実行される
「computed 実行」は最初に読んだときに1回だけ computed は読まれるまで計算しない。2回目はキャッシュを返す
watch は price を変えるまで出ない watch は既定では初回実行しない
watch と watchEffect は「代入した直後」の行より後に出る コールバックは同期ではなく、まとめて後から実行される
watch だけが 100 -> 200 と旧値を出せている 旧値が取れるのは watch
最後にもう一度「computed 実行」 依存が変わったので、次に読まれたときに再計算した

computedの使い方と細かな動き

基本形とキャッシュ

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

const items = ref([
  { id: 1, name: 'ノート', price: 300, done: false },
  { id: 2, name: 'ペン', price: 120, done: true },
])
const keyword = ref('')

// 絞り込み
const filtered = computed(() =>
  items.value.filter(i => i.name.includes(keyword.value))
)
// 合計
const total = computed(() =>
  filtered.value.reduce((sum, i) => sum + i.price, 0)
)
</script>

<template>
  <input v-model="keyword" placeholder="検索" />
  <ul><li v-for="i in filtered" :key="i.id">{{ i.name }} {{ i.price }}円</li></ul>
  <p>合計 {{ total }}円</p>
</template>

computed は別の computed を依存にできます。テンプレートに関数呼び出しを書くと再描画のたびに実行されますが、computed は依存が変わらない限り前回の結果を返すので、絞り込みや集計に向いています。

結果が同じなら、その先は動かない

computed を watch や watchEffect の監視対象にしたとき、再計算しても結果が同じなら、その先の処理は実行されません。

import { ref, computed, watch, watchEffect, nextTick } from 'vue'
const log = console.log

let calls = 0
const n = ref(1)
const isEven = computed(() => { calls++; return n.value % 2 === 0 })
watch(isEven, (v) => log('  watch(isEven):', v))
watchEffect(() => log('  effect isEven =', isEven.value))

n.value = 3; await nextTick(); log('  n=3 (奇数のまま) 計算回数', calls)
n.value = 4; await nextTick(); log('  n=4 計算回数', calls)
  effect isEven = false
  n=3 (奇数のまま) 計算回数 2
  watch(isEven): true
  effect isEven = true
  n=4 計算回数 3

n を 1 から 3 に変えると isEven は再計算されますが(計算回数 2)、結果は false のままなので watch も watchEffect も動いていません。「細かく変わる値を、変化の少ない値に変換してから監視する」と、無駄な処理を減らせます。

computedに代入するとどうなるか

[Vue warn] Write operation failed: computed value is readonly

computed は読み取り専用です。代入したい場合は、get と set を持つ書き込み可能な computed にします。

import { ref, computed } from 'vue'

const first = ref('Taro'), last = ref('Sato')
const full = computed({
  get: () => first.value + ' ' + last.value,
  set: (v) => { [first.value, last.value] = v.split(' ') },
})
full.value = 'Hana Kato'
console.log(first.value, '/', last.value)
  書き込み可能 computed: Hana / Kato

v-model に渡す値を加工したいときに便利な形です。

前回の結果を受け取る

computed のゲッターは、第1引数で前回の結果を受け取れます。「これまでの最大値」のような値を作れます。

const c = ref(0)
const maxSeen = computed((prev) => Math.max(prev ?? 0, c.value))
c.value = 5; maxSeen.value
c.value = 2
console.log(maxSeen.value)   // 5
  computed(prev):  5

computedに書かないほうがよいこと

書かないもの 代わりに
API 呼び出し、タイマー watch / watchEffect
他の ref への代入 watch、または書き込み可能な computed
DOM の操作 watch(flush: 'post')や onMounted
元の配列を書き換える sort・reverse [...list].sort() のようにコピーしてから

computed は「同じ入力なら同じ結果を返す計算」に絞ると、いつ実行されても困らない読みやすいコードになります。

watchの第1引数に渡せるものと渡せないもの

渡すもの 例 監視できるか
ref watch(count, ...) できる
computed watch(total, ...) できる
reactive のオブジェクト watch(state, ...) できる(自動で深い監視になる)
ゲッター関数 watch(() => state.count, ...) できる
上記の配列 watch([a, b], ...) できる
reactive のプロパティそのもの watch(state.count, ...) できない
.value を付けた ref watch(count.value, ...) できない

reactiveのプロパティを直接渡すと監視が効かない

import { reactive, watch, nextTick } from 'vue'
const log = console.log

const state = reactive({ count: 0 })
watch(state.count, (n) => log('  A 直接渡し:', n))               // 数値 0 を渡している
watch(() => state.count, (n, o) => log(`  B getter: ${o} -> ${n}`))
state.count++
await nextTick()

実行結果:

[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.
  B getter: 0 -> 1

state.count と書いた時点で数値の 0 が取り出され、watch には「0」というただの値が渡ります。A は一度も呼ばれず、開発時には上の警告が出ます。プロパティを監視するときはゲッター関数で包む、と覚えておくと確実です。

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

const first = ref('Taro'), last = ref('Sato')
watch([first, last], ([f, l], [of, ol]) => log(`  ${of} ${ol} -> ${f} ${l}`))
first.value = 'Jiro'; last.value = 'Kato'
await nextTick()
  Taro Sato -> Jiro Kato

同じタイミングで2つを変更しても、コールバックは1回にまとめられています。

deepの動きと「新旧の値が同じになる」理由

5つの書き方を同時に比べる

import { reactive, watch, nextTick } from 'vue'
const log = console.log

const state = reactive({ user: { name: 'Sato', tags: ['a'] } })

watch(state, (n, o) =>
  log('  A reactiveそのもの: new===old', n === o, '/ new.user.name', n.user.name, '/ old.user.name', o.user.name))
watch(() => state.user, () => log('  B getter(deepなし) 呼ばれた'))
watch(() => state.user, (n, o) =>
  log('  C getter+deep: new===old', n === o, '/ old.name', o.name), { deep: true })
watch(() => JSON.parse(JSON.stringify(state.user)), (n, o) =>
  log(`  D コピーを返すgetter: ${o.name} -> ${n.name}`))
watch(() => state.user.name, (n, o) => log(`  E プロパティのgetter: ${o} -> ${n}`))

state.user.name = 'Tanaka'
await nextTick()
log('-- state.user を丸ごと置き換え')
state.user = { name: 'Kato', tags: [] }
await nextTick()

実行結果:

  A reactiveそのもの: new===old true / new.user.name Tanaka / old.user.name Tanaka
  C getter+deep: new===old true / old.name Tanaka
  D コピーを返すgetter: Sato -> Tanaka
  E プロパティのgetter: Sato -> Tanaka
-- state.user を丸ごと置き換え
  A reactiveそのもの: new===old true / new.user.name Kato / old.user.name Kato
  B getter(deepなし) 呼ばれた
  C getter+deep: new===old false / old.name Tanaka
  D コピーを返すgetter: Tanaka -> Kato
  E プロパティのgetter: Tanaka -> Kato
書き方 プロパティの変更(name を書き換え) 丸ごと置き換え 旧値
A: watch(state) 反応する 反応する 新旧が同じオブジェクト
B: ゲッター(deep なし) 反応しない 反応する 取れる
C: ゲッター + deep: true 反応する 反応する プロパティ変更時は新旧が同じ
D: コピーを返すゲッター 反応する 反応する 取れる
E: プロパティのゲッター 反応する 反応する 取れる

deep で新旧の値が同じになるのは、オブジェクトの中身を書き換えただけでは「同じオブジェクト」のままだからです。watch は変更前のコピーを取っておくわけではなく、参照を渡しているため、コールバックが呼ばれた時点ではどちらも変更後の姿になっています。C の2回目のように、オブジェクトを丸ごと置き換えたときだけ新旧が別物になります。

変更前の値が必要なときは、次のどちらかにします。

方法 書き方 向いている場面
必要なプロパティだけを監視する(E) watch(() => state.user.name, ...) 見たい項目が決まっている
コピーを返すゲッターにする(D) watch(() => JSON.parse(JSON.stringify(state.user)), ...) オブジェクト全体の差分を取りたい(小さなデータ向け)

D は変更のたびに全体を複製するので、大きなデータには E のように対象を絞る書き方が向いています。

refにオブジェクトを入れたときはdeepが必要

const user = ref({ name: 'Sato' })
watch(user, () => log('  A watch(user) deepなし'))
watch(user, () => log('  B watch(user, {deep:true})'), { deep: true })
watch(() => user.value.name, (n) => log('  C getter:', n))

user.value.name = 'Tanaka'; await nextTick()
log('-- user.value = {...} 置き換え')
user.value = { name: 'Kato' }; await nextTick()
  B watch(user, {deep:true})
  C getter: Tanaka
-- user.value = {...} 置き換え
  A watch(user) deepなし
  B watch(user, {deep:true})
  C getter: Kato
ソース 既定の深さ
reactive のオブジェクトをそのまま渡す 深い(deep を書かなくても全階層)
ref(中身がオブジェクト) 浅い(.value の入れ替えだけ)
ゲッター関数 浅い(返す値が変わったときだけ)

「ref に入れたオブジェクトのプロパティを変えたのに watch が動かない」ときは、A の状態になっています。deep: true を付けるか、C のようにプロパティのゲッターにします。

数値指定のdeepで探索する深さを決める(Vue 3.5)

Vue 3.5 以降は、deep に数値を渡して探索する階層を制限できます。

const s = reactive({ l1: { l2: { l3: { v: 0 } } }, top: 0 })
for (const d of [1, 2, 3, true]) {
  watch(() => s, () => log(`  deep:${d} が反応`), { deep: d })
}
log('-- s.top++ (1階層目)');                 s.top++;               await nextTick()
log('-- s.l1.l2 を置き換え (2階層目)');       s.l1.l2 = { l3: { v: 0 } }; await nextTick()
log('-- s.l1.l2.l3 を置き換え (3階層目)');    s.l1.l2.l3 = { v: 1 };  await nextTick()
log('-- s.l1.l2.l3.v++ (4階層目)');          s.l1.l2.l3.v++;        await nextTick()
-- s.top++ (1階層目)
  deep:1 が反応
  deep:2 が反応
  deep:3 が反応
  deep:true が反応
-- s.l1.l2 を置き換え (2階層目)
  deep:2 が反応
  deep:3 が反応
  deep:true が反応
-- s.l1.l2.l3 を置き換え (3階層目)
  deep:3 が反応
  deep:true が反応
-- s.l1.l2.l3.v++ (4階層目)
  deep:true が反応

deep: N は「N 階層目のプロパティまで」を監視していることがログからわかります。deep: true は全階層をたどるため、大きなオブジェクトでは探索に時間がかかります。「一覧の各行の直下の項目までで十分」のように範囲が決まっているなら、数値指定が合っています。

immediate・onceの動き

const q = ref('a')
watch(q, (n, o) => log(`  immediate: ${o} -> ${n}`), { immediate: true })
watch(q, (n, o) => log(`  once: ${o} -> ${n}`), { once: true })
watch(q, (n, o) => log(`  通常: ${o} -> ${n}`))
log('-- 定義直後')
q.value = 'b'; await nextTick()
q.value = 'c'; await nextTick()
  immediate: undefined -> a
-- 定義直後
  immediate: a -> b
  once: a -> b
  通常: a -> b
  immediate: b -> c
  通常: b -> c
オプション ログからわかること
immediate: true 定義した時点で1回実行される。そのときの旧値は undefined
once: true 最初の変更(a から b)だけ実行され、2回目(b から c)は呼ばれない

immediate を付けたコールバックで旧値を使うときは、初回の undefined を考えた書き方にします(o?.id のようにオプショナルチェーンを使う、など)。「画面を開いたときに取得し、検索条件が変わったら取り直す」という処理は、immediate: true の watch か watchEffect で1か所にまとめられます。

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

flush は「コールバックを DOM 更新の前に実行するか、後に実行するか」を決めます。次のコンポーネントで、コールバックの中から画面の文字を読んで確かめました。

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

const n = ref(0)
const p = ref(null)
const log = (s) => console.log('  ' + s + ' / 画面: ' + p.value.textContent)

watch(n, (v) => log('pre(既定)  n=' + v))
watch(n, (v) => log('post       n=' + v), { flush: 'post' })
watch(n, (v) => log('sync       n=' + v), { flush: 'sync' })

const add3 = () => { n.value++; n.value++; n.value++ }
</script>

<template>
  <button @click="add3">+3</button>
  <p ref="p">{{ n }}</p>
</template>

ボタンを1回クリックした実行結果:

  sync       n=1 / 画面: 0
  sync       n=2 / 画面: 0
  sync       n=3 / 画面: 0
  pre(既定)  n=3 / 画面: 0
  post       n=3 / 画面: 3
flush 実行タイミング 3回続けて変更したとき コールバック内で見える DOM
'pre'(既定) コンポーネントの再描画の前 1回にまとまる 更新前
'post' 再描画の後 1回にまとまる 更新後
'sync' 値を変更したその場 3回とも実行 更新前

更新後の DOM(要素の高さ、スクロール位置など)を読みたいときは flush: 'post' を選びます。'sync' は変更のたびに実行されてまとめが効かないので、真偽値のような単純な値の監視にとどめるのが扱いやすい使い方です。

全オプションをまとめたコピペ用コード

<script setup> にそのまま貼れる形で、ここまでの書き方をまとめます。

<script setup>
import {
  ref, reactive, computed,
  watch, watchEffect, watchPostEffect, onWatcherCleanup,
} from 'vue'

const keyword = ref('')
const page = ref(1)
const form = reactive({ name: '', tags: [], address: { city: '' } })
const result = ref([])

// 1. computed:値を作る
const trimmed = computed(() => keyword.value.trim())

// 2. watch:ref を監視(旧値つき)
watch(keyword, (newVal, oldVal) => {
  console.log(oldVal, '->', newVal)
})

// 3. watch:reactive のプロパティはゲッターで
watch(() => form.name, (name) => { /* ... */ })

// 4. watch:複数ソース
watch([trimmed, page], ([kw, p]) => { /* ... */ })

// 5. immediate:定義時にも実行
watch(page, (p, old) => { /* old は初回 undefined */ }, { immediate: true })

// 6. deep:ネストした変更も検知(数値は 3.5 以降)
watch(() => form.address, () => { /* ... */ }, { deep: true })
watch(() => form, () => { /* ... */ }, { deep: 2 })

// 7. once:最初の1回だけ(3.4 以降)
watch(() => form.name, () => { /* 初めて入力されたとき */ }, { once: true })

// 8. flush:DOM 更新後に実行
watch(result, () => { /* 更新後の DOM を読む */ }, { flush: 'post' })
watchPostEffect(() => { /* watchEffect の post 版 */ })

// 9. watchEffect:中で読んだものを自動で追跡、初回も実行
watchEffect(() => {
  document.title = `${trimmed.value} (${page.value}ページ)`
})

// 10. 後始末つきの取得(3.5 以降の onWatcherCleanup)
watch([trimmed, page], async ([kw, p]) => {
  const controller = new AbortController()
  onWatcherCleanup(() => controller.abort())
  const res = await fetch(`/api/items?q=${encodeURIComponent(kw)}&page=${p}`, {
    signal: controller.signal,
  })
  result.value = await res.json()
}, { immediate: true })

// 11. 停止・一時停止
const handle = watch(keyword, () => {})
handle.pause()    // 一時停止
handle.resume()   // 再開
handle()          // 停止(handle.stop() でも同じ)
</script>

このブロックは書き方の一覧として用意したもので、ブロック全体をひとつのコンポーネントとして動かしたものではありません(10 の fetch を含む形は未実行です)。個々のオプションの動きは、この記事の各セクションのコードで確認しています。

停止・一時停止・後始末

停止と一時停止

const n = ref(0)
const h = watch(n, (v) => log('  watch n=', v))
n.value++; await nextTick()

h.pause()
n.value++; await nextTick()
log('  (pause中は反応なし n=' + n.value + ')')

h.resume(); await nextTick()
log('-- resume 後')
n.value++; await nextTick()

h()
n.value++; await nextTick()
log('  (stop後は反応なし n=' + n.value + ')')
  watch n= 1
  (pause中は反応なし n=2)
  watch n= 2
-- resume 後
  watch n= 3
  (stop後は反応なし n=4)

watch と watchEffect の戻り値は関数で、呼び出すと停止します。Vue 3.5.43 では、この関数に pause・resume・stop の3つのメソッドが付いていました。pause 中に起きた変更は、resume した時点で1回実行されることがログからわかります(resume の直後に「watch n= 2」が出ています)。

コンポーネント内で作ったwatcherは自動で止まる

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

const props = defineProps(['src'])   // 外から渡された ref

watch(() => props.src.value, (v) => console.log('  同期で作成: ', v))

setTimeout(() => {
  watch(() => props.src.value, (v) => console.log('  setTimeout内で作成: ', v))
}, 0)
</script>

<template><i /></template>

外側で src を 1 に変え、コンポーネントをアンマウントしてから 2 に変えた結果です。

  setTimeout内で作成:  1
  同期で作成:  1
-- アンマウント後
  setTimeout内で作成:  2
watcher を作った場所 アンマウント時
setup(script setup)の同期的な部分 自動で停止する
setTimeout や await の後など、非同期のコールバック内 停止しない(戻り値を保持して自分で止める)

非同期の中で作る必要があるときは、戻り値のハンドルを変数に持っておき、onUnmounted で呼び出します。多くの場合は、watch 自体は同期的に作り、条件のほうを監視対象に含める書き方にすると自動停止に任せられます。

onWatcherCleanupで前回の処理を取り消す(Vue 3.5)

検索語が続けて変わったとき、先に始めた取得の結果が後から届いて表示を上書きしてしまうことがあります。onWatcherCleanup は、次の実行が始まる直前(と停止時)に呼ばれる後始末を登録する関数です。

import { ref, watch, onWatcherCleanup, nextTick } from 'vue'
const log = console.log
const sleep = (ms) => new Promise(r => setTimeout(r, ms))

const id = ref(1)
watch(id, async (newId) => {
  let cancelled = false
  onWatcherCleanup(() => {
    cancelled = true
    log(`  cleanup: id=${newId} の処理を取り消し`)
  })
  await sleep(30)            // 通信の代わり
  if (cancelled) return
  log(`  id=${newId} の結果を反映`)
})

id.value = 2; await nextTick()
id.value = 3; await nextTick()
await sleep(80)
  cleanup: id=2 の処理を取り消し
  id=3 の結果を反映

id=2 の処理は、id=3 の実行が始まる前に取り消されています。fetch を使う場合は、先ほどのコピペ用コードの 10 のように AbortController の abort を登録します。

公式ガイドには「同期的な実行中に呼ぶ必要があり、await の後では呼べない」とあります。実際に await の後で呼ぶと、次の警告が出ました。

[Vue warn] onWatcherCleanup() was called when there was no active watcher to associate with.

Vue 3.4 以前でも使える書き方として、コールバックの引数で受け取る onCleanup があります。

const id3 = ref(1)
watch(id3, (v, o, onCleanup) => {
  onCleanup(() => log('  第3引数 onCleanup: 旧', v))
})
watchEffect((onCleanup) => {
  const v = id3.value
  onCleanup(() => log('  watchEffect onCleanup: 旧', v))
})
id3.value = 2; await nextTick()
id3.value = 3; await nextTick()
  watchEffect onCleanup: 旧 1
  第3引数 onCleanup: 旧 2
  watchEffect onCleanup: 旧 2
後始末の書き方 受け取り方 await の後で登録
onWatcherCleanup(3.5 以降) import して呼ぶ できない(警告)
watch の第3引数 onCleanup コールバックの引数 今回は未実行
watchEffect の第1引数 onCleanup コールバックの引数 今回は未実行

watchEffectの使い方と注意点

依存を書かなくてよい

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

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

watchEffect(() => {
  // keyword と page のどちらが変わっても実行される
  console.log(`検索: ${keyword.value} / ${page.value}ページ`)
})
</script>

watch で書くと watch([keyword, page], ..., { immediate: true }) になる処理を、短く書けます。

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

import { ref, watchEffect } from 'vue'
const log = console.log
const sleep = (ms) => new Promise(r => setTimeout(r, ms))

const a = ref(1), b = ref(1)
watchEffect(async () => {
  const x = a.value          // await の前:追跡される
  await sleep(1)
  const y = b.value          // await の後:追跡されない
  log(`  effect a=${x} b=${y}`)
})
await sleep(10); log('-- a++'); a.value++
await sleep(10); log('-- b++'); b.value++
await sleep(10); log('  (b の変更では再実行されない)')
  effect a=1 b=1
-- a++
  effect a=2 b=1
-- b++
  (b の変更では再実行されない)

watchEffect が追跡するのは、最初の await より前に同期的に読んだ値だけです。非同期処理の中で使う値は、await の前に変数へ取り出しておくか、依存を明示できる watch を選びます。

watchとwatchEffectの選び方

判断の軸 watch watchEffect
旧値が必要 ○ ―
初回は実行したくない ○(既定) ―
依存が多く、書き並べるのが手間 ― ○
「何が変わったら動くか」をコードで明示したい ○ ―
非同期処理の中で値を読む ○ await の前に読む工夫が必要
読んだ値のうち一部の変更では動かしたくない ○ ―

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

症状 原因 直し方
watch が一度も呼ばれず「Invalid watch source」の警告が出る state.count や count.value を直接渡している ゲッター関数 () => state.count にする
ref のオブジェクトのプロパティを変えても watch が動かない ref は既定で浅い監視 deep: true を付けるか、プロパティのゲッターにする
新しい値と旧い値が同じになる オブジェクトの中身の変更は同じ参照のまま プロパティのゲッター、またはコピーを返すゲッター
コールバック内で DOM を読むと更新前の値になる 既定の flush は 'pre' flush: 'post' にする
画面を開いたときに実行されない watch は既定で初回実行しない immediate: true か watchEffect
先に始めた取得の結果で上書きされる 前回の処理が残っている onWatcherCleanup で取り消す
アンマウント後も動き続ける 非同期のコールバック内で watcher を作った 同期的に作る、またはハンドルで止める
computed に代入して警告が出る computed は読み取り専用 get / set を持つ computed にする
watchEffect が一部の値に反応しない await の後で読んでいる await の前に読む、または watch にする

よくある質問

Q. computedとwatchはどちらを使えばよいですか? A. 表示や他の計算に使う「値」が欲しいなら computed、値が変わったことをきっかけに通信・保存・ログ出力などの「処理」をしたいなら watch です。watch の中で別の ref に計算結果を代入しているだけなら、computed に置き換えられることが多いです。

Q. watchとwatchEffectの違いは何ですか? A. watch は監視対象を第1引数で明示し、旧値を受け取れて、既定では初回実行しません。watchEffect は中で読んだ値を自動で追跡し、定義した時点で1回実行され、旧値は受け取れません。今回の検証では、watchEffect は await の後で読んだ値を追跡しませんでした。

Q. deepを付けると新しい値と旧い値が同じになるのはなぜですか? A. オブジェクトの中のプロパティを書き換えても、オブジェクト自体は同じものだからです。watch は変更前のコピーを保存しないため、新旧どちらの引数も同じオブジェクトを指します。変更前の値が必要なときは、プロパティをゲッターで監視するか、コピーを返すゲッターを使います。

Q. onWatcherCleanupはどのバージョンから使えますか? A. 公式ガイドの Watchers のページによると Vue 3.5 以降です。3.4 以前では、watch のコールバックの第3引数、または watchEffect の第1引数として渡される onCleanup を使います。今回は 3.5.43 で両方の動作を確認しました。

まとめ

  • computed は値を作る、watch と watchEffect は変化をきっかけに処理する
  • 旧値が取れるのは watch。watchEffect は定義時に1回実行され、依存を自動で追跡する
  • reactive のプロパティはゲッター関数で渡す。直接渡すと「Invalid watch source」の警告が出て監視されない
  • deep で新旧の値が同じになるのは同じオブジェクトを指すため。プロパティのゲッターかコピーを返すゲッターで解決できる
  • deep は数値で階層を指定できる(3.5 以降)。once は1回だけ、immediate は定義時にも実行
  • flush: 'post' で更新後の DOM を読める。'sync' は変更のたびに実行される
  • onWatcherCleanup で前回の処理を取り消す。同期的に作った watcher はアンマウントで自動停止する

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

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

株式会社RJCでは、Vueのcomputedとwatchの使い分けを仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社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で一緒に開発しながら、 成長を楽しみませんか?