A Vue 3 page runs watch(filters, loadResults) on a reactive filters object; it refetches when an unrelated field changes and loads nothing initially. How do you fix the watcher?
answer
- reactive source is implicitly deep
- watch is lazy by default
- narrow to the query's own fields
- primitives compare by Object.is
- UI-only state out of the query
basics
~20 sPassing the reactive object makes the watch implicitly deep, so any nested edit refetches, and watch is lazy. Watch an array of getters for only the query's fields, with immediate: true for the first load.
solid answer
~40 s`watch(filters, …)` on a `reactive()` object is implicitly **deep**, so typing into a UI-only field such as `filters.draftLabel` triggers a fetch, and `watch` is **lazy**, so nothing loads until the first edit. I would narrow the source to what the query depends on: `watch([() => filters.category, () => filters.sort, () => filters.price.min, () => filters.price.max], load, { immediate: true })`. Each getter returns a primitive compared with `Object.is`, so replacing `filters.price` with an equal copy does not refetch, and `immediate` covers the initial load with the same code path. Better still, move UI-only fields out of the object that describes the query. Racing responses are a separate concern for watcher cleanup.
code
vue · 23 lines<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
const filters = reactive({ category: 'all', sort: 'relevance', price: { min: 0, max: 500 } })
const draftLabel = ref('') // UI-only, deliberately not in filters
const results = ref<string[]>([])
async function loadResults(q: { category: string; sort: string; min: number; max: number }) {
const params = new URLSearchParams({ ...q, min: String(q.min), max: String(q.max) })
results.value = await fetch(`/api/products?${params}`).then((r) => r.json())
}
watch(
[() => filters.category, () => filters.sort, () => filters.price.min, () => filters.price.max],
([category, sort, min, max]) => loadResults({ category, sort, min, max }),
{ immediate: true },
)
</script>
<template>
<input v-model="draftLabel" placeholder="Save search as" />
<ul><li v-for="r in results" :key="r">{{ r }}</li></ul>
</template>go deeper
Remember two defaults: a reactive object passed to watch() is watched deeply, and watch() does not run until something changes unless immediate is set.
Explain why getters for specific fields fix over-fetching: they track only what they read and compare primitives, so equal replacements do not refetch.
Show the redesign: separate UI-only state from query inputs, choose explicit getters or a query key, and keep races for cleanup rather than the source.
Frame it as data modelling: objects that describe a remote query should hold only query inputs, which keeps watchers, caching and URL sync simple.
## Diagnosing the two symptoms The component looks like this: ```ts const filters = reactive({ category: 'all', sort: 'relevance', price: { min: 0, max: 500 }, draftLabel: '', // bound to a 'save this search as' input }) watch(filters, loadResults) ``` Both bugs come from Vue 3 defaults of `watch()`: 1. **Refetch on unrelated edits.** A reactive object passed directly as the source is watched **implicitly deep**. Vue traverses every nested property, including `draftLabel`, and fires the callback on any mutation without comparing values. Every keystroke in the label field triggers a request. 2. **Nothing on first render.** `watch()` is **lazy**: the callback runs only after a change, so the list stays empty until the user touches a filter. ## Narrowing the source The fix is to tell the watcher exactly what the query depends on: ```ts watch( [() => filters.category, () => filters.sort, () => filters.price.min, () => filters.price.max], ([category, sort, min, max]) => loadResults({ category, sort, min, max }), { immediate: true }, ) ``` What each part buys: - **Getters** track only what they read, so `draftLabel` is no longer a dependency. - Each getter returns a **primitive** compared with `Object.is`, so replacing `filters.price` with an equal copy re-runs the getters but does not call `loadResults`. - The **array** source fires when any one entry changes and passes arrays of new and old values, which helps when logging what changed. - **`immediate: true`** runs the callback at creation, so the first load and every reload share one code path. ## Alternatives and their trade-offs | Approach | Refetches on | Trade-off | |---|---|---| | `watch(filters, load)` | any nested change | simplest; over-fetches and is lazy | | `watch(() => filters, load)` | never, the const is never replaced | a common false fix | | `watch(filters, load, { deep: 1 })` | root-level assignments only | still sees `draftLabel`, misses `price.min` | | array of primitive getters | a relevant field's value changing | an explicit list to maintain | | getter returning a key string | the serialized query changing | one join per run; old and new keys are comparable | | UI-only fields moved to their own state | whatever the query object holds | the cleanest data model | The last row is often the real fix: `draftLabel` is not a filter, so it should not live in the object that describes the query. Once that object holds only query inputs, even `watch(filters, load, { immediate: true })` is correct, with the remaining caveat that deep watching fires again when a nested object is replaced by an equal copy. A getter that builds a **query key**, such as `() => [filters.category, filters.sort, filters.price.min, filters.price.max].join('|')`, is another compact option: the callback fires only when the string changes, and old and new keys are distinct strings, which is handy when a results cache is keyed by the same value. ## Checks before shipping 1. Does the watcher run once on mount, and once per real query change rather than per keystroke? 2. Does replacing a nested object with an equal copy refetch? It should not. 3. Is any field in the source one that the request never reads? 4. Are free-text inputs debounced before they reach the query fields? Two neighbouring concerns are deliberately outside the choice of watch source: discarding a stale response when two requests race, which belongs to watcher cleanup, and mirroring filters into the URL, which belongs to the router.
- Why is `watch(() => filters, load)` not a fix?The getter returns the same reactive proxy on every run and reads none of its properties, so it tracks nothing and compares one object to itself. Because `filters` is a `const` that is never reassigned, the callback never fires at all, which swaps over-fetching for no fetching.
- Would `{ deep: 1 }` on the filters object be enough?No. Depth 1 tracks root-level properties, so assigning `draftLabel` still fires, while `filters.price.min = 10` is a nested mutation that is no longer tracked. Numeric depth bounds cost on big structures; it does not select which fields matter.
- The list sometimes shows results for an older filter after fast clicking. Is that the watch source?No, the source is fine; two requests are racing and the slower, older one resolves last. That is handled with the watcher's cleanup, marking the previous run as stale or aborting its request, rather than by changing what the watcher tracks.
saying these in an interview costs you the question
- watch(() => filters, load) narrows a reactive object watcher
- Vue re-runs every watcher when the component re-renders
- deep: 1 limits the watcher to the fields the query uses
- Deep watchers skip the callback when nested values are equal
- The initial load needs a separate onMounted call