| |

Vue.js 87 🟢 Custom Ref Implementations (customRef) and Debounced Inputs

⏱️ Estimated read: 12 min | |

Vue’s customRef is the escape hatch for creating a ref whose dependency tracking and update triggering are under explicit programmer control. A standard ref calls track() when read and trigger() when written, with no opportunity to intervene. customRef receives those two functions as arguments and returns a get/set pair, allowing the implementation to decide when a read should register a dependency, when a write should notify subscribers, or whether either should happen at all . The canonical example is a debounced ref: a ref whose value updates only after a quiet period following the last write, rather than on every keystroke.

This chapter covers the customRef factory signature, the debounced ref implementation that the Vue documentation uses as its reference example, and the caution about object-typed getters that is easy to overlook until it causes a parent-child re-render bug.

Key point: customRef does not create a new kind of reactivity. It delegates the two primitive operations—track and trigger—to the programmer. A get that does not call track() will not be re-evaluated when the ref changes. A set that does not call trigger() will not cause the UI to update. The correctness of the ref depends entirely on those two calls.


Why customRef exists

The immediate-update problem. A standard ref updates its value and triggers dependents on every assignment. For a text input bound with v-model, this means every keystroke updates the bound value and re-renders the template. If the value drives a network request or an expensive computation, the result is a request or computation per keystroke. customRef allows the assignment to be intercepted, stored, and applied after a delay, while the input itself remains responsive .

The validation problem. A ref that should only accept values meeting certain criteria cannot enforce that with a standard ref. The set method of a customRef can inspect the incoming value, reject it, transform it, or defer the trigger until a condition is met. The get method can return the last valid value rather than the rejected one.

The external-source problem. A ref that mirrors a value from a non-Vue source—a WebSocket message, a browser API, a third-party store—needs to decide when to pull from the source and when to notify Vue. customRef provides the two hooks that make that decision explicit.


a. The Factory Signature and the Debounced Ref

customRef takes a factory function that receives track and trigger and returns an object with get and set. The factory runs once, when the ref is created, and the returned object’s methods run on every read and write .

import { customRef } from 'vue'

export function useDebouncedRef<T>(value: T, delay = 200) {
  let timeout: ReturnType<typeof setTimeout>
  let current = value

  return customRef<T>((track, trigger) => {
    return {
      get() {
        track()          // register the reading effect as a dependent
        return current
      },
      set(newValue: T) {
        clearTimeout(timeout)
        timeout = setTimeout(() => {
          current = newValue
          trigger()      // notify dependents after the delay
        }, delay)
      },
    }
  })
}

The get method calls track() before returning the value. This is what causes a component that reads the ref to be re-evaluated when trigger() is eventually called. The set method clears any pending timer, schedules a new one, and calls trigger() only when the timer fires. Between the assignment and the timer firing, the ref’s value is unchanged, and no dependent is notified .

The component usage is identical to a standard ref, because the returned object is a valid Ref<T>:

<script setup lang="ts">
import { useDebouncedRef } from './debouncedRef'

const text = useDebouncedRef('hello', 500)
</script>

<template>
  <input v-model="text" />
</template>

Typing in the input updates the internal current value only after 500 milliseconds of inactivity. A watch on text fires once per quiet period, not once per keystroke .


b. The Object Getter Caution

The Vue documentation includes a warning that is specific to customRef and easy to trigger accidentally. If the get method returns a new object on every call—return { ...someState }, for example—the ref will cause unnecessary re-renders in child components when it is passed as a prop .

The mechanism is as follows. The parent component’s render function is triggered by some unrelated reactive state change. During the re-render, the parent reads the customRef, and the get method returns a new object. The child component receives this new object as a prop. The child compares the prop to its previous value, finds them different (because the objects have different identities even if their contents are equal), and re-renders. The parent’s customRef setter was never called, so trigger() was never invoked, but the child re-rendered anyway because the prop identity changed .

The fix is to avoid returning new objects from the get method, or to memoize the returned object so that the same reference is returned until the underlying state actually changes. A customRef that returns a primitive or a stable reference does not have this problem.


c. The Relationship to Other Debounce Approaches

customRef is not the only way to debounce an input in Vue. A composable that watches a ref and writes to a second ref is simpler to understand and does not require the customRef machinery . The customRef approach is appropriate when the debounced value should behave exactly like a ref—assignable, bindable with v-model, and usable anywhere a Ref<T> is expected—without a separate watcher and a second ref.

The Vue documentation presents the debounced ref as the primary example of customRef because it demonstrates the two calls in their natural positions: track() in get, trigger() in set. The delay is implemented by deferring the trigger() call, which is the only mechanism customRef provides for postponing an update. The value itself is stored in the closure variable, not in a separate ref, because the customRef is the ref.

A separate concern is cleanup. The debounced ref’s timer is not automatically cleared when the component unmounts. If the component unmounts while a timer is pending, the timer fires and calls trigger() on a ref whose dependents may no longer exist. In practice this is harmless, but a composable that wants to be precise can accept an onScopeDispose or use getCurrentScope() to clear the timer.


Complete Example Session

// ============================================
// PART 1: THE FACTORY SIGNATURE
// ============================================
import { customRef, type Ref } from 'vue'

function createCustom<T>(initial: T): Ref<T> {
  let value = initial
  return customRef<T>((track, trigger) => ({
    get() {
      track()
      return value
    },
    set(next: T) {
      value = next
      trigger()
    },
  }))
}


// ============================================
// PART 2: THE DEBOUNCED REF
// ============================================
export function useDebouncedRef<T>(initial: T, delay = 200): Ref<T> {
  let timer: ReturnType<typeof setTimeout> | undefined
  let current = initial

  return customRef<T>((track, trigger) => ({
    get() {
      track()
      return current
    },
    set(next: T) {
      clearTimeout(timer)
      timer = setTimeout(() => {
        current = next
        trigger()
      }, delay)
    },
  }))
}


// ============================================
// PART 3: COMPONENT USAGE
// ============================================
// <script setup lang="ts">
// import { useDebouncedRef } from './useDebouncedRef'
// const text = useDebouncedRef('hello', 500)
// </script>
// <template><input v-model="text" /></template>


// ============================================
// PART 4: WATCH BEHAVIOR
// ============================================
// watch(text, (v) => console.log(v))
// Typing "abc" quickly logs "abc" once, not three times


// ============================================
// PART 5: THE OBJECT GETTER BUG
// ============================================
// ❌ Returns a new object on every get
const badRef = customRef<{ n: number }>((track, trigger) => {
  let n = 0
  return {
    get() {
      track()
      return { n }       // new object every read
    },
    set(v) {
      n = v.n
      trigger()
    },
  }
})


// ============================================
// PART 6: THE FIX — STABLE REFERENCE
// ============================================
const goodRef = customRef<{ n: number }>((track, trigger) => {
  let state = { n: 0 }
  return {
    get() {
      track()
      return state         // stable reference
    },
    set(v) {
      state = v
      trigger()
    },
  }
})


// ============================================
// PART 7: TRACK WITHOUT TRIGGER (READ-ONLY EFFECT)
// ============================================
const readOnly = customRef<number>((track, _trigger) => {
  let v = 0
  return {
    get() {
      track()
      return v
    },
    set() {
      // ignore writes
    },
  }
})


// ============================================
// PART 8: TRIGGER WITHOUT TRACK (EXTERNAL SOURCE)
// ============================================
const external = customRef<number>((_track, trigger) => {
  let v = 0
  // poll an external source and trigger
  setInterval(() => { v++; trigger() }, 1000)
  return {
    get() {
      return v
    },
    set(next) {
      v = next
    },
  }
})

The eight parts trace the factory signature, the debounced implementation, and the two pathological cases: the object getter that returns a new reference on every read, and the refs that call only one of track or trigger.


Quick Reference

customRef Signature

ComponentType
customRef(factory) => Ref<T>
factory(track, trigger) => { get, set }
trackRegisters the current effect as a dependent
triggerNotifies all dependents to re-evaluate

track and trigger Placement

CallPositionEffect
track()Inside getReading registers dependency
trigger()Inside setWriting notifies dependents
Omit trackgetRef is not reactive to reads
Omit triggersetWrites do not update UI

Debounce Variants

ApproachMechanism
customRefDeferred trigger() in set
watch + second refWatcher writes to a separate ref
Library composableTanStack Pacer, VueUse useDebouncedRef

Best Practices

✅ Do This:

// Call track in get and trigger in set
get() { track(); return value }                                       // ✅
set(v) { value = v; trigger() }                                       // ✅
// Return a stable reference from get
let state = { n: 0 }
get() { track(); return state }                                       // ✅
// Clear pending timers on scope disposal
onScopeDispose(() => clearTimeout(timer))                             // ✅

❌ Don’t Do This:

// Return a new object from get on every read
get() { track(); return { n } }  // causes child re-renders              // ❌
// Forget to call track in get
get() { return value }  // no dependency registered                    // ❌
// Forget to call trigger in set
set(v) { value = v }  // UI never updates                              // ❌

Common Pitfalls

PitfallWhy It HappensFix
Ref updates but UI does notset omits trigger()Call trigger() after assignment
Ref changes but computed does not recomputeget omits track()Call track() before returning
Child re-renders on unrelated parent updateget returns a new objectReturn a stable reference
Debounced value never updatesTimer not cleared or trigger not calledEnsure trigger() inside the timeout
Memory leak from pending timerComponent unmounts before timer firesClear timer in onScopeDispose

Real-World Examples

1. Standard Pass-Through Ref

let v = 0
customRef((track, trigger) => ({
  get() { track(); return v },
  set(n) { v = n; trigger() },
}))

2. Debounced Ref

set(n) { clearTimeout(t); t = setTimeout(() => { v = n; trigger() }, 300) }

3. Stable Object Reference

let state = { a: 1 }
get() { track(); return state }

4. Read-Only Ref

set() {}  // ignore writes

5. External Polling Ref

setInterval(() => { v++; trigger() }, 1000)

6. Cleanup on Scope Dispose

onScopeDispose(() => clearTimeout(timer))

7. v-model Binding

<input v-model="debouncedText" />

8. Watch Debounced Value

watch(debouncedText, (v) => fetch(`/search?q=${v}`))

9. Validation in Set

set(n) { if (n > 0) { v = n; trigger() } }

10. Transformation in Set

set(n) { v = n.trim().toLowerCase(); trigger() }

Visual

The Factory Flow

┌──────────────────────────────────────────────────────────────┐
│  customRef(factory)                                          │
│         │                                                    │
│         ▼                                                  │
│  factory(track, trigger)                                     │
│         │                                                    │
│         ▼                                                  │
│  returns { get, set }                                        │
│                                                              │
│  Component reads ref                                         │
│         │                                                    │
│         ▼                                                  │
│  get() → track() → effect registered → return value          │
│                                                              │
│  Component writes ref                                        │
│         │                                                    │
│         ▼                                                  │
│  set(v) → update internal → trigger() → dependents notified │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Debounce Timing

┌──────────────────────────────────────────────────────────────┐
│  KEYSTROKES:  a  b  c  d                                     │
│               │  │  │  │                                     │
│  set() calls: │  │  │  │                                     │
│               ▼  ▼  ▼  ▼                                     │
│  clearTimeout  ────  ────  ────                              │
│  setTimeout        300ms  300ms  300ms                       │
│                              │                               │
│                              ▼                               │
│  trigger() fires after the last set + 300ms                  │
│  Watch fires once with value "abcd"                          │
│                                                              │
│  Without debounce: watch fires four times                    │
│                                                              │
└──────────────────────────────────────────────────────────────┘

The Object Getter Bug

┌──────────────────────────────────────────────────────────────┐
│  Parent re-renders (unrelated state change)                  │
│         │                                                    │
│         ▼                                                  │
│  get() returns { n: 0 }  ← new object                        │
│         │                                                    │
│         ▼                                                  │
│  Child receives prop { n: 0 }                                │
│         │                                                    │
│         ▼                                                  │
│  Child compares to previous { n: 0 }                         │
│  Different identity → child re-renders                       │
│                                                              │
│  But the customRef's trigger() was never called!             │
│  The parent did not write to the ref.                        │
│                                                              │
│  FIX: return the same object reference until set() runs.     │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
customRefFactory for a ref with manual track/trigger
Factory signature(track, trigger) => { get, set }
trackCall in get to register dependency
triggerCall in set to notify dependents
Debounced refDefer trigger() with setTimeout
Object getter riskNew object on every read causes child re-renders
Stable referenceReturn the same object until set runs
CleanupClear timers in onScopeDispose

Key takeaways:

  • customRef delegates the two reactivity primitives to the programmer. A standard ref calls track on read and trigger on write automatically. customRef passes those functions to the factory, and the returned get/set methods decide when to call them. A get without track is not reactive; a set without trigger does not update the UI .
  • The debounced ref is the canonical example. The set method clears the previous timer, schedules a new one, and calls trigger() only when the timer fires. The value is stored in a closure variable, and the get method returns it after calling track(). Typing quickly produces one update, not one per keystroke .
  • Returning a new object from get is a known hazard. When the custom ref is passed as a prop, the child component compares the prop’s identity to its previous value. If get returns a new object on every read, the child re-renders even when the ref’s setter was never called. The fix is to return a stable reference that changes only when the underlying state changes .
  • The timer should be cleared on scope disposal. A pending timer that fires after the component unmounts calls trigger() on a ref with no live dependents. It is harmless in most cases, but onScopeDispose(() => clearTimeout(timer)) makes the composable correct.
  • customRef is one of several debounce approaches. A watch on a source ref that writes to a separate debounced ref is simpler and does not require customRef. The customRef approach is appropriate when the debounced value must itself be a ref, assignable and bindable with v-model, without a separate watcher .

Remember: customRef is not a new reactivity system; it is the same system with the track and trigger calls exposed. The factory receives those two functions and returns the accessors. The get decides whether the read should register a dependency. The set decides when the write should notify dependents. The debounced ref demonstrates the pattern: get calls track, set defers trigger with a timer. The object getter caution demonstrates the cost of the pattern: the get method runs on every read, and if it allocates a new object each time, the ref becomes a source of re-renders that the trigger call did not cause. The power of customRef is the control; the responsibility is ensuring the two calls happen in the right places.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!