skip to content

How would you build a debounced ref with Vue 3's `customRef`, and what do the `track` and `trigger` arguments of its factory do?

level: middleimportance: nice to knowfreq 25%

answer

  1. a factory returning get and set
  2. track in get, trigger in set
  3. trigger delayed by a timer
  4. you decide when readers update

basics

~20 s

customRef takes a factory that receives track and trigger and returns get and set. Call track() in get so readers subscribe; in set, restart a timer and only when it fires store the value and call trigger(), so readers update once typing pauses.

solid answer

~40 s

`customRef(factory)` hands the factory two functions bound to the ref's own dep: `track()` subscribes the currently running effect, and `trigger()` notifies every subscriber. The factory returns `{ get, set }`, and Vue calls them for `.value` reads and writes - with no automatic tracking or triggering. For a debounced ref, `get` calls `track()` and returns the stored value; `set` clears a pending `setTimeout` and schedules a new one that stores the value and calls `trigger()`. Bound with `v-model`, each keystroke calls `set`, but readers re-render once, after the delay. Keep the getter returning a stable value: the docs warn that a getter producing a new object on every read can make a child that receives it as a prop update without `set` ever running.

code

ts · 18 lines
ts
import { customRef } from 'vue'

export function useDebouncedRef<T>(value: T, delay = 300) {
  let timeout: ReturnType<typeof setTimeout> | undefined
  return customRef<T>((track, trigger) => ({
    get() {
      track()
      return value
    },
    set(newValue) {
      clearTimeout(timeout)
      timeout = setTimeout(() => {
        value = newValue
        trigger()
      }, delay)
    },
  }))
}

go deeper

for a junior

Recall that customRef exists for refs with custom read and write behaviour, with debouncing as the textbook example.

for a middle

Explain that track and trigger are the ref's own dep controls, and write the debounced ref from memory with its timer in set.

for a senior

Judge when customRef beats a ref plus watch, and guard against unstable getters and timers that outlive their component.

for a principal

Decide whether custom reactive primitives belong in a shared composable library, and document their notification rules for the team.

## What `customRef` gives you A normal `ref` hard-wires the reactivity rules: its `.value` getter always calls `track()`, and its setter calls `trigger()` whenever the value changes. `customRef` hands those two decisions to you. Its signature, from the API reference: ```ts function customRef<T>(factory: CustomRefFactory<T>): Ref<T> type CustomRefFactory<T> = ( track: () => void, trigger: () => void ) => { get: () => T set: (value: T) => void } ``` In the source, the ref creates its own `Dep` and passes the factory `dep.track` and `dep.trigger`, bound to that dep. Reading `.value` just calls your `get`; writing `.value` just calls your `set`. Nothing else happens automatically. ## The two arguments | Argument | What it does | Where you usually call it | |---|---|---| | `track()` | links this ref's dep to the currently running effect (render, computed, watcher); does nothing if none is running | inside `get` | | `trigger()` | bumps the dep's version and notifies every subscribed effect | inside `set`, when readers should update | Skipping `track()` makes the ref non-reactive for readers; calling `trigger()` at a moment you choose is the whole point of the API. ## Building the debounced ref 1. Keep the real value and a timer handle in the factory's closure. 2. In `get`, call `track()` and return the value. 3. In `set`, clear any pending timer and start a new one. 4. When the timer fires, store the new value and call `trigger()`. With `<input v-model="query">`, each keystroke calls `set`. The input element shows what the user typed, because the DOM owns that text; everything that *reads* `query` - a computed filter, a watcher that fetches - sees one change after typing pauses. ## Cleaning up A pending timer can still fire after the component that owns the ref is gone. In a composable, clear it when the owning scope is disposed so no late `trigger()` runs against dead state. ## Pitfalls - **Unstable getter results.** If `get` builds a new object or array on every call and the ref is passed as a prop, each parent re-render produces a new prop value; the child sees a changed prop and updates, although `set` never ran. Return the same stored value from `get`. - **Forgetting `track()`.** The ref still returns values, but no effect subscribes, so `trigger()` notifies nobody. - **A re-render during the delay.** If some other state re-renders the component while a timer is pending, `v-model` re-applies the ref's current (old) value to the input and the user's latest keystrokes disappear. Keep unrelated reactive state out of that component, or debounce a second ref instead. - **Calling `trigger()` without changing anything.** Unlike a normal ref, a `customRef` has no built-in equality check; every `trigger()` notifies subscribers. ## When to reach for it - Debounced or throttled input bound with `v-model`. - A ref whose writes are validated, clamped or transformed before readers see them. - Bridging an external value source where you decide when a change counts. For most cases a plain `ref` plus a debounced `watch` writing a second ref works too; `customRef` wins when you want one ref that callers use exactly like any other. ## customRef compared with a plain ref | Behaviour | `ref()` | `customRef()` | |---|---|---| | Tracking on read | automatic in the `.value` getter | only if your `get` calls `track()` | | Notification on write | automatic, only when `Object.is` says the value changed | only when your code calls `trigger()` | | Deep conversion of objects | yes, object values become reactive | no, you return whatever you stored | | Timing of notification | synchronous with the write | whenever you choose, including later | The table is the real answer to the interview question: a `customRef` is a ref with the automatic parts removed, so you rebuild exactly the parts you need.

  • What does the Vue documentation warn about returning a new object from a `customRef` getter?
    If that ref is passed as a prop, every parent re-render calls the getter and hands the child a new object. The child compares it with its previous prop, sees a difference and updates, even though the setter and `trigger()` never ran. Return a stable stored value instead.
  • Could you get the same debounce without `customRef`?
    Yes: keep a source `ref` bound to the input and a `watch` on it that writes a second ref after a timeout. It uses only everyday APIs but splits one concept into two refs; `customRef` keeps a single ref that behaves like any other with `v-model`.

saying these in an interview costs you the question

  • customRef tracks and triggers automatically; track and trigger are optional extras.
  • trigger() must be called synchronously inside set or Vue throws.
  • A pending debounce timer cannot fire once its component has unmounted.
  • customRef skips notifying when the new value equals the old one.
  • The getter can safely build a fresh object on every read.