Vue.js 87 🟢 Custom Ref Implementations (customRef) and Debounced Inputs
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
| Component | Type |
|---|---|
customRef | (factory) => Ref<T> |
factory | (track, trigger) => { get, set } |
track | Registers the current effect as a dependent |
trigger | Notifies all dependents to re-evaluate |
track and trigger Placement
| Call | Position | Effect |
|---|---|---|
track() | Inside get | Reading registers dependency |
trigger() | Inside set | Writing notifies dependents |
Omit track | get | Ref is not reactive to reads |
Omit trigger | set | Writes do not update UI |
Debounce Variants
| Approach | Mechanism |
|---|---|
customRef | Deferred trigger() in set |
watch + second ref | Watcher writes to a separate ref |
| Library composable | TanStack 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Ref updates but UI does not | set omits trigger() | Call trigger() after assignment |
| Ref changes but computed does not recompute | get omits track() | Call track() before returning |
| Child re-renders on unrelated parent update | get returns a new object | Return a stable reference |
| Debounced value never updates | Timer not cleared or trigger not called | Ensure trigger() inside the timeout |
| Memory leak from pending timer | Component unmounts before timer fires | Clear 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
| Item | Value |
|---|---|
customRef | Factory for a ref with manual track/trigger |
| Factory signature | (track, trigger) => { get, set } |
track | Call in get to register dependency |
trigger | Call in set to notify dependents |
| Debounced ref | Defer trigger() with setTimeout |
| Object getter risk | New object on every read causes child re-renders |
| Stable reference | Return the same object until set runs |
| Cleanup | Clear timers in onScopeDispose |
Key takeaways:
customRefdelegates the two reactivity primitives to the programmer. A standardrefcallstrackon read andtriggeron write automatically.customRefpasses those functions to the factory, and the returnedget/setmethods decide when to call them. Agetwithouttrackis not reactive; asetwithouttriggerdoes not update the UI .- The debounced ref is the canonical example. The
setmethod clears the previous timer, schedules a new one, and callstrigger()only when the timer fires. The value is stored in a closure variable, and thegetmethod returns it after callingtrack(). Typing quickly produces one update, not one per keystroke . - Returning a new object from
getis a known hazard. When the custom ref is passed as a prop, the child component compares the prop’s identity to its previous value. Ifgetreturns 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, butonScopeDispose(() => clearTimeout(timer))makes the composable correct. customRefis one of several debounce approaches. Awatchon a source ref that writes to a separate debounced ref is simpler and does not requirecustomRef. ThecustomRefapproach is appropriate when the debounced value must itself be a ref, assignable and bindable withv-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.
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!