customRef and Advanced Watchers
Build a ref with your own get and set, such as a debounced one, and choose when a watcher callback runs.
customRef<T>((track, trigger) => ({ get: () => T, set: (v: T) => void }))customRef(factory) hands you the two internal functions from the reactivity system and lets you decide when they are called. The factory receives track and trigger and returns an object with get and set. Call track() inside get so readers subscribe, and trigger() inside set when you want dependents to re-run. Since you control set, you can delay it, clamp it, validate it, or write it through to localStorage on the way past.
Flush timing
A watcher callback does not run the instant state changes. The flush option decides when:
'pre'(the default) runs it before the owner component's DOM is patched, so the DOM you read is still the old one.'post'runs it after the patch, which is what you want when the callback measures or scrolls an element.'sync'runs it immediately on every mutation, with no batching.
watchPostEffect(fn) is shorthand for watchEffect(fn, { flush: 'post' }), and watchSyncEffect(fn) for watchEffect(fn, { flush: 'sync' }). They are the same calls.
Other options worth knowing
once: true (Vue 3.4) stops the watcher after its first callback. In 3.5, deep accepts a number as a maximum traversal depth, the returned handle gained pause() and resume() next to stop(), and onWatcherCleanup() registers work to undo before the next run.
Example
<script setup lang="ts">
import { customRef, watch, type Ref } from 'vue'
function debouncedRef<T>(initial: T, delay = 300): Ref<T> {
let value = initial
let timeout: ReturnType<typeof setTimeout> | undefined
return customRef<T>((track, trigger) => ({
get() {
track() // subscribe whoever is reading
return value
},
set(next: T) {
clearTimeout(timeout)
timeout = setTimeout(() => {
value = next
trigger() // only now do dependents re-run
}, delay)
}
}))
}
const query = debouncedRef('', 300)
// 'post' runs the callback after Vue has patched the DOM.
watch(
query,
(next: string) => {
console.log('searching for', next)
},
{ flush: 'post' }
)
</script>
<template>
<input v-model="query" type="search" placeholder="Search" />
<p>Settled query: {{ query || 'empty' }}</p>
</template>
<style scoped>
input {
display: block;
}
</style>When to use it
- Debouncing a search field at the state layer rather than in every component
- Keeping a value inside a valid range wherever it is written from
- Measuring or scrolling an element after Vue has applied the update
- Silencing an autosave watcher while a large import writes to the store
More examples
A ref that clamps every write
The invariant lives in the ref, so no caller can get it wrong and no component needs a watcher to correct a bad value. The early return matters: skipping trigger() on an unchanged value keeps dependents from re-running for nothing.
// src/composables/clampedRef.ts
import { customRef, type Ref } from 'vue'
export function clampedRef(value: number, min: number, max: number): Ref<number> {
let current = Math.min(Math.max(value, min), max)
return customRef<number>((track, trigger) => ({
get() {
track()
return current
},
set(next: number) {
const clamped = Math.min(Math.max(next, min), max)
if (clamped === current) return // no change, no trigger
current = clamped
trigger()
}
}))
}
// src/demo/volume.ts
import { clampedRef } from '../composables/clampedRef'
const volume = clampedRef(5, 0, 10)
volume.value = 50
console.log(volume.value)
// Output: 10
volume.value = -3
console.log(volume.value)
// Output: 0Flush timing, shorthand and longhand
Pick post when the callback touches the DOM Vue just updated, and keep sync for simple flags only, since it fires on every single mutation without batching. The shorthand aliases are what you will meet in other people's code, so recognising both forms matters more than preferring one.
// src/components/MeasureBox.vue (script block only)
import {
ref,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
onWatcherCleanup,
useTemplateRef
} from 'vue'
const items = ref<string[]>(['a'])
// useTemplateRef was added in Vue 3.5.
const list = useTemplateRef<HTMLUListElement>('list')
// These two calls are identical.
watchPostEffect(() => {
console.log('height after patch', list.value?.offsetHeight)
})
watchEffect(
() => {
console.log('height after patch', list.value?.offsetHeight)
},
{ flush: 'post' }
)
// So are these two.
watchSyncEffect(() => console.log('count', items.value.length))
watchEffect(() => console.log('count', items.value.length), { flush: 'sync' })
// once was added in 3.4: the watcher stops after one callback.
watch(items, () => console.log('first change only'), { once: true })
// onWatcherCleanup was added in 3.5. It must be called synchronously,
// before any await.
watch(items, () => {
const controller = new AbortController()
void fetch('/api/items', { signal: controller.signal })
onWatcherCleanup(() => controller.abort())
})Pausing a watcher instead of stopping it
Use pause when a bulk update would otherwise fire a watcher dozens of times, for example while applying a server snapshot, and resume afterwards. Before 3.5 the only options were stop and re-create, or a guard flag inside the callback.
// src/demo/watch-handle.ts
import { ref, watch, type WatchHandle } from 'vue'
const position = ref(0)
const handle: WatchHandle = watch(position, (next) => {
console.log('saving position', next)
})
position.value = 10
// Output (next tick): saving position 10
// pause() and resume() were added in Vue 3.5.
handle.pause()
position.value = 20 // callback does not run
handle.resume()
position.value = 30
// Output (next tick): saving position 30
handle.stop() // same as calling handle()
Discussion