skip to content

A Vue 3 cart keeps total in a ref that a watch() on items updates; what goes wrong, and what should replace it?

level: seniorimportance: should knowfreq 55%

answer

  1. two copies of one truth
  2. first render and forgotten sources
  3. read on the next line
  4. derivation versus side effect

basics

~20 s

The watched copy is extra state that drifts: wrong until the callback first runs, stale when a source is unwatched, and briefly stale after each mutation. A computed derived from the items has none of these gaps.

solid answer

~40 s

Watch-and-set duplicates state: `total` is a second copy of something `items` already determines, and keeping it in sync is now your job. It is wrong on first render unless you add `immediate: true`; it goes stale when someone later adds a source, such as a discount code, that the watch does not list; watching the `items` ref misses in-place `push` calls unless the watch is deep; and because watch callbacks run in the `'pre'` flush by default, code that mutates `items` and reads `total.value` on the next line sees the old number. `const total = computed(() => ...)` removes the second copy: its dependencies are whatever the getter reads, and it is correct whenever it is read. Keep `watch` for side effects such as saving the cart or calling an API.

code

ts · 18 lines
ts
import { ref, watch, computed } from 'vue'

const items = ref([{ price: 10, qty: 1 }])

// Anti-pattern: a second copy kept in sync by hand
const watchedTotal = ref(0)
watch(items, (list) => {
  watchedTotal.value = list.reduce((s, i) => s + i.price * i.qty, 0)
}, { deep: true, immediate: true })

// Derivation: no copy to maintain
const total = computed(() =>
  items.value.reduce((s, i) => s + i.price * i.qty, 0)
)

items.value.push({ price: 5, qty: 2 })
console.log(watchedTotal.value) // 10: the watch callback has not run yet
console.log(total.value) // 20: recomputed on read

go deeper

for a junior

Recognise that a value calculated from other state belongs in computed, and that watch is for doing something when state changes.

for a middle

Name the concrete failures of watch-and-set: missing initial value, unlisted sources, shallow ref watching and the 'pre' flush delay.

for a senior

Spot the pattern in review, rewrite it as a computed, and keep the genuine side effects such as persistence or fetching in a separate watcher.

for a principal

Set the convention that derived state is never stored, so bug classes like one-step-behind values cannot appear, and make reviewers flag watch callbacks that only assign.

## The pattern A common first attempt at derived state in Vue 3 looks like this: ```ts import { ref, watch } from 'vue' const items = ref([{ price: 49, qty: 1 }]) const discount = ref(0) const total = ref(0) watch(items, (list) => { total.value = list.reduce((s, i) => s + i.price * i.qty, 0) - discount.value }, { deep: true }) ``` It works in a demo. In a real component it produces a family of bugs, all with one root cause: **`total` is a second copy of a truth that `items` and `discount` already hold**, and a watcher is a manual, incomplete sync mechanism. ## What goes wrong 1. **Wrong on first render.** `watch` does not call its callback until a source changes, so `total` shows `0` until the first edit. The fix inside the pattern is `immediate: true`, one more option to remember. 2. **Forgotten sources.** The callback reads `discount.value`, but `watch` only re-runs for the sources you passed. Changing the discount leaves `total` stale. Every new input to the formula must be added to the source list by hand. 3. **Shallow by default on a ref.** Watching the `items` ref only fires when `items.value` is replaced. `items.value.push(...)` does not fire it unless you add `deep: true`, which then traverses the whole array on every change. 4. **Stale on the next line.** By default a watch callback is scheduled with `flush: 'pre'`: it runs just before the next component update, not synchronously. Code that does `items.value.push(x)` and then reads `total.value` gets the old total. 5. **Extra work and extra state.** The component now holds one more ref, and the write inside the callback triggers another round of updates for anything reading `total`. ## The replacement ```ts import { ref, computed } from 'vue' const items = ref([{ price: 49, qty: 1 }]) const discount = ref(0) const total = computed(() => items.value.reduce((s, i) => s + i.price * i.qty, 0) - discount.value ) ``` Each failure disappears: | Problem | watch + ref | computed | |---|---|---| | Value before any change | needs `immediate: true` | correct on first read | | New input to the formula | must be added to the source list | tracked automatically when read | | `push` into the array | needs `deep: true` | tracked through the reads the getter makes | | Read right after a mutation | old value until the pre-flush | recomputed on read | | Extra state to keep in sync | yes | none | The computed's dependencies are exactly the reactive values the getter read on its last run, so a new term in the formula is tracked the moment it is written. Reading `total.value` after a mutation re-runs the getter if a dependency changed, so there is no window of staleness. ## When watch is the right tool The distinction is **derivation versus side effect**: - **Derivation**: a value that is a pure function of other reactive state (totals, filtered lists, labels, validity flags). Use `computed`. - **Side effect**: something that happens *because* state changed and touches the outside world: saving the cart to `localStorage`, calling an API when a query changes, sending an analytics event, focusing an element. Use `watch` or `watchEffect`. A useful review heuristic: a watch callback whose only statement assigns to another ref is almost always a computed in disguise. A watch callback that awaits a request and then assigns the result is legitimate, because a computed getter must not perform async work. ## Spotting it in code review - `watch(x, (v) => { y.value = f(v) })` with nothing else in the callback. - `immediate: true` added "to fix the initial value". - A bug report that a value is "one step behind" or "wrong until I edit something". - A formula that reads refs not listed as watch sources. Each is a sign that derived state is being maintained by hand. Rewriting it as a computed usually deletes code, removes a ref, and fixes the bug. ## Version note This advice holds across Vue 3. Since Vue 3.4 a computed also notifies its dependents only when its result actually changes, which makes the computed version cheaper still when many edits leave the total unchanged.

  • The cart must also be saved to localStorage whenever it changes. Is a watcher right for that?
    Yes. Persisting is a side effect, not a derivation, and a computed getter must not perform side effects. A `watch` on the items (deep, or on a getter that serialises them) that writes to storage is the intended use; the total itself stays a computed.
  • The total depends on a discount fetched from an API when the code changes. Can that be a computed?
    Not the fetch. A computed getter must be synchronous and side-effect free, so the request belongs in a watcher that stores the fetched discount in a ref. The total then stays a computed that reads that ref, keeping the async part and the derivation separate.

A watch-and-set total is a clerk copying the till figure onto a whiteboard whenever told to; a computed is a display wired to the till itself. The whiteboard lags and misses changes nobody announced.

saying these in an interview costs you the question

  • watch plus a ref is the standard way to keep derived values in Vue.
  • A watch callback runs synchronously as soon as its source changes by default.
  • immediate: true fixes all the problems of watch-and-set.
  • watch notices a push into a ref'd array without any extra option.
  • A computed must also list the refs it depends on, just like watch.
  • An async API call is fine inside a computed getter.