Building Custom Refs in Vue 3: customRef, track and trigger
AI generated
<v/>
{ }
Vue.js · Reactivity · Composition API · customRef
Building Custom Refs in Vue 3
customRef, track and trigger explained

A Custom Ref gives full control over dependency tracking and update triggering. Once you understand the customRef API, you can encapsulate debounce, validation, storage synchronization and other patterns as a clean, reusable reactive primitive instead of wiring them up again in every component.

18 min read customRef · track · trigger · composables Vue 3.4+ · TypeScript · Vitest

1. Why build a Custom Ref at all

A Custom Ref is a reactive primitive that behaves like a normal ref(), but whose dependency tracking and update triggering are fully controlled by you. By default, Vue's ref() automatically couples reads to track() and every write to trigger(). A Custom Ref breaks that fixed coupling: you decide yourself when a dependency is registered and when a component actually receives an update.

That sounds like a niche case at first, but in practice it is the clean way to encapsulate patterns like debounce, validation with rollback, or synchronization with external data sources. Without a Custom Ref, that logic often ends up scattered across watch() callbacks and local timers, directly inside the component. With a Custom Ref, the same logic becomes a testable, reusable function that feels like an ordinary reactive value from the outside.

The benefit shows up most clearly in composables. A function that returns a Custom Ref hides all internal complexity from the caller. The component writes myValue.value = x just like with any other ref, without noticing that a debounce timer is running or a network request is being fired in the background. That encapsulation is the actual value of building your own Custom Ref.

2. The customRef API in detail: track and trigger

The customRef() function expects a factory function that receives two parameters: track and trigger. That factory must return an object with get() and set(value), similar to a JavaScript property descriptor. Inside get() you call track() to tell Vue that a reactive dependency is being read here. Inside set(), after the actual value change, you call trigger() to re-run all dependent effects.

The key difference from a normal ref(): between setting the internal value and calling trigger(), you can put arbitrary logic, including asynchronous delay. That is exactly what makes a Custom Ref the right tool for debounce. A normal ref() fires trigger() synchronously and immediately on every assignment, whereas a Custom Ref can freely decide that timing.

It is important that track() runs on every single get() call, otherwise Vue never notices that a component depends on this value, and updates simply stop happening. If trigger() is forgotten, the component never sees a change even though the internal value was updated correctly. Both calls are therefore not optional, they are a mandatory part of every working Custom Ref.


import { customRef } from 'vue'

// Minimal custom ref: behaves exactly like ref(), fully manual
function myRef(initialValue) {
  let value = initialValue
  return customRef((track, trigger) => {
    return {
      get() {
        track() // register this read as a dependency
        return value
      },
      set(newValue) {
        value = newValue
        trigger() // notify all dependent effects
      }
    }
  })
}

const count = myRef(0)
console.log(count.value) // 0
count.value = 5 // triggers re-render of anything reading count.value

3. A Custom Ref for debounced search input

The classic use case for a Custom Ref is a search input that should not fire a request on every keystroke. Instead of implementing debounce in the component with watch() and setTimeout, you encapsulate it directly inside the Custom Ref. The component binds the ref normally with v-model, and the debounce behavior stays internal and invisible.

The trick is that set() updates the internal value immediately, so the input field itself reacts without delay, but trigger() is only called after the timer expires. That way the text field stays responsive, while all dependent effects, for example an API call inside a watch(), only fire after the debounce window. That is a benefit you could only replicate with plain watch()-based debounce by adding extra local state.


import { customRef } from 'vue'

// Custom ref that delays the trigger, not the internal value
export function useDebouncedRef(initialValue, delay = 300) {
  let value = initialValue
  let timeout

  return customRef((track, trigger) => ({
    get() {
      track()
      return value
    },
    set(newValue) {
      value = newValue
      clearTimeout(timeout)
      timeout = setTimeout(() => {
        trigger() // fires only after the user stops typing
      }, delay)
    }
  }))
}

// In a component:
// const searchTerm = useDebouncedRef('')
// watch(searchTerm, (term) => fetchResults(term))

4. Custom Ref with validation and rollback

Another strong use case for a Custom Ref is validation with automatic rollback for invalid values. Instead of manually checking whether an input value is allowed in every component, you encapsulate that rule inside the Custom Ref's set(). If the new value is invalid, it is simply discarded and trigger() is never called, so the old value remains visibly in place.

This technique is especially useful for numeric inputs with bounds, such as quantity fields in a shopping cart that must never go negative. The Custom Ref can additionally expose an error state as a separate reactive value, so the component can display an error message without knowing the validation logic itself. That keeps components lean and the validation rule reusable in a single place.


import { customRef, ref } from 'vue'

// Custom ref with validation and automatic rollback on invalid input
export function useValidatedNumber(initialValue, { min = 0, max = Infinity } = {}) {
  let value = initialValue
  const error = ref(null)

  const numberRef = customRef((track, trigger) => ({
    get() {
      track()
      return value
    },
    set(newValue) {
      const parsed = Number(newValue)
      if (Number.isNaN(parsed) || parsed < min || parsed > max) {
        error.value = `Value must be between ${min} and ${max}`
        return // rollback: internal value stays unchanged, no trigger
      }
      error.value = null
      value = parsed
      trigger()
    }
  }))

  return { value: numberRef, error }
}

5. Syncing a Custom Ref with localStorage

A Custom Ref is a great fit for transparently syncing reactive state with localStorage. On read, get() returns the current in memory value, on write, set() updates both the internal value and the entry in browser storage. From the component's perspective, the ref looks just like any other reactive value, while persistence happens fully transparently in the background.

It gets interesting when you additionally listen to the browser's storage event, which fires when another tab changes the same key. The Custom Ref can then call trigger() on an external storage event without set() being called locally, fully encapsulating cross tab synchronization inside the ref. That is a pattern a plain ref() could not cleanly model, because there is no control point for external triggers there.


import { customRef } from 'vue'

// Custom ref synced with localStorage, including cross-tab updates
export function useStorageRef(key, initialValue) {
  let value = localStorage.getItem(key)
    ? JSON.parse(localStorage.getItem(key))
    : initialValue

  let triggerFn

  window.addEventListener('storage', (event) => {
    if (event.key === key && triggerFn) {
      value = JSON.parse(event.newValue)
      triggerFn() // sync value changed in another browser tab
    }
  })

  return customRef((track, trigger) => {
    triggerFn = trigger
    return {
      get() {
        track()
        return value
      },
      set(newValue) {
        value = newValue
        localStorage.setItem(key, JSON.stringify(newValue))
        trigger()
      }
    }
  })
}

6. Typing custom refs with TypeScript

In TypeScript, a Custom Ref benefits greatly from generic type parameters. customRef<T>() lets you type the returned value exactly, so a caller gets correct autocomplete, just like with a normal Ref<T>. The factory function's return type must match the internal CustomRefFactory<T> interface, which requires get(): T and set(value: T): void.

A common mistake is leaving out the generic type parameter and letting Vue infer the type from the initial value. That works for simple primitives, but fails for more complex union types, or when the Custom Ref initially holds null and later takes on a concrete value. Explicit typing with customRef<string | null>() avoids such surprises and makes the composable's API signature self documenting.


import { customRef, type Ref } from 'vue'

interface DebouncedRefOptions {
  delay?: number
}

// Explicit generic typing for a reusable custom ref factory
export function useDebouncedRef<T>(
  initialValue: T,
  options: DebouncedRefOptions = {}
): Ref<T> {
  const { delay = 300 } = options
  let value = initialValue
  let timeout: ReturnType<typeof setTimeout>

  return customRef<T>((track, trigger) => ({
    get(): T {
      track()
      return value
    },
    set(newValue: T) {
      value = newValue
      clearTimeout(timeout)
      timeout = setTimeout(() => trigger(), delay)
    }
  }))
}

7. Testing custom refs with Vitest

A Custom Ref can be tested in isolation, without mounting a Vue component, since customRef() is also valid to use outside of component setup. For tests with time based logic like debounce, vi.useFakeTimers() is essential: without fake timers a test would have to wait a real 300 milliseconds, with them you can deterministically fast forward time.

When testing a Custom Ref, you typically check three things: that .value synchronously returns the expected internal state, that reactive effects observing the ref fire correctly and at the right time, and that invalid input on validation refs actually causes a rollback. A watchEffect() inside the test, combined with a spy, reveals exactly how often and when trigger() actually fired.


import { describe, it, expect, vi } from 'vitest'
import { watchEffect } from 'vue'
import { useDebouncedRef } from './useDebouncedRef'

describe('useDebouncedRef', () => {
  it('delays trigger until after the debounce window', () => {
    vi.useFakeTimers()
    const debounced = useDebouncedRef('', 300)
    const spy = vi.fn()

    watchEffect(() => {
      spy(debounced.value)
    })

    debounced.value = 'a'
    debounced.value = 'ab'
    debounced.value = 'abc'

    expect(spy).toHaveBeenCalledTimes(1) // only the initial run so far

    vi.advanceTimersByTime(300)
    expect(spy).toHaveBeenCalledTimes(2) // trigger fired once, latest value
    expect(debounced.value).toBe('abc')

    vi.useRealTimers()
  })
})

8. Common mistakes building your own refs

The most common mistake with a hand built Custom Ref is forgetting to call track(), or only calling it conditionally. If track() only runs inside an if block, for example, Vue registers the dependency only sometimes, and updates seem to disappear at random. track() must run unconditionally on every get() call, regardless of which value is returned.

A second typical mistake is storing a Custom Ref's internal value outside the factory function in a shared module scope. If the same composable is called multiple times across different components, all instances then accidentally share the same state, because the closure is not recreated per call. Every call to the factory function must create its own, independent closure variable for the internal value.

9. Custom Ref compared to alternatives

Not every reactive pattern requires a dedicated Custom Ref. The overview below shows when a Custom Ref is the right choice and when simpler built in tools are enough.

Requirement Without Custom Ref With Custom Ref Benefit
Debounce on v-model watch + setTimeout in the component useDebouncedRef() Reusable, component stays lean
Field validation with rollback Manual check before every assignment Validation inside set() Invalid values never visible
Cross tab sync Own event listener per component Custom ref with storage event Sync logic encapsulated in one place
Simple local value ref() Unnecessary overhead customRef only when real control is needed
Read only derivation Custom ref with empty set() computed() Simpler, more fitting primitive

The rule of thumb is simple: a Custom Ref pays off whenever additional logic is needed between setting a value and triggering the update, whether that is a time delay, validation, or external synchronization. For every case where reads and writes can stay directly coupled, a normal ref() is the simpler and equally performant choice.

Mironsoft

Vue 3, Nuxt and Composition API consulting

Is reactivity in Vue 3 turning into a bug magnet for your team?

We audit your composables, build custom refs for debounce, validation and storage sync, and bring your Vue 3 reactivity onto a maintainable foundation.

Composable audit

Reviewing existing refs and watch logic for clean encapsulation

Custom ref library

Debounce, validation and storage sync as tested composables

TypeScript typing

Generic refs with correct type inference for your team

10. Summary

A Custom Ref is the right tool whenever additional logic is needed between setting a value and triggering reactive updates. The customRef() API, with its track and trigger parameters, gives you full control over dependency tracking and update timing. Debounce, validation with rollback and storage synchronization are the three most common practical use cases where a hand built Custom Ref makes code noticeably cleaner than scattered watch() logic inside the component.

In TypeScript, Custom Refs benefit from explicit generic type parameters, and when testing them, they can be checked in isolation with fake timers, without mounting a full component. If you consistently call track() and trigger() correctly and cleanly encapsulate internal state per factory call, you end up with reactive primitives that feel no different to the caller than an ordinary ref(), while being capable of far more under the hood.

Custom Refs in Vue 3 — The Essentials at a Glance

customRef API

A factory with track and trigger as parameters, returning get()/set() like a property descriptor.

Typical use cases

Debounce, validation with rollback, localStorage sync including cross tab updates.

Most common mistake

Calling track() conditionally or not at all, causing updates to seem to disappear at random.

Testing

Possible in isolation outside of components, with vi.useFakeTimers() for time based refs.

11. FAQ: Custom Refs in Vue 3

1What is a Custom Ref?
A reactive primitive created with customRef(), with manual track()/trigger() instead of the automatic coupling used by ref().
2When Custom Ref instead of ref()?
When extra logic is needed between set and trigger: debounce, validation with rollback, external synchronization.
3What if track() is missing?
Vue never registers the dependency, updates seem to stop happening at random.
4Rollback without trigger()?
Yes, exactly the pattern for validation: on invalid input the old value stays, no trigger().
5Building a Custom Ref with debounce?
set() updates immediately, setTimeout calls trigger() only after the delay elapses.
6Typing in TypeScript?
customRef() with get(): T and set(value: T): void, explicit types avoid inference mistakes.
7Testing debounce logic?
vi.useFakeTimers() combined with watchEffect() and a spy to check trigger timing.
8Synchronizing multiple components?
Yes, with a shared module scope for the closure variable and a shared ref instance.
9Faster than watch()?
Marginal, the benefit is encapsulation and reusability, not raw performance.
10Does it replace Reactivity Transform?
No, independent concepts: customRef() is a stable runtime API, Reactivity Transform was experimental compiler syntax.