To debounce a ref in Vue 3, use the customRef API. It gives you direct control over when the reactive value updates. Wrap the update in a setTimeout and clear it on each new call. The result is a ref that only notifies watchers after the user stops typing for a specified delay.
With the introduction of Composition API, we got a new way of writing reactive logic, namely, ref and reactive methods. In this article, I want to show you how you can create a debounced ref that will update its value only after a specified delay. A denounced ref can be very useful, for example, if you have an input field with an autocomplete where an API request is made after the search query state changes.

Debouncing is a nice optimisation pattern, and without it, an API request would be made after every keystroke. Battering a server is not optimal, so let's get started.
Project Setup
To showcase this example, I have created a new project with Vite. If you would like to follow along, you can create one by running yarn create @vitejs/app debounced-ref --template vue or npm init @vitejs/app debounced-ref --template vue. You can also find the full code example in this GitHub repo.
App.vue
As I mentioned at the start, a debounced ref could be used to delay API requests when a user enters search criteria in an input field. Below you can see the setup for it.
<template>
<div>
<label :class="$style.label">Search query</label>
<input :class="$style.input" v-model="query" type="text" />
<div>Value: {{ query }}</div>
</div>
</template>
<script>
import { watch } from 'vue'
import useDebouncedRef from './composables/useDebouncedRef'
export default {
setup() {
const query = useDebouncedRef('', 400)
watch(query, newQuery => {
console.log({ newQuery })
// init an API request
})
return {
query,
}
},
}
</script>
<style module>
.label {
display: block;
}
.input {
margin-top: 5px;
margin-bottom: 20px;
}
</style>
In the template, we have a label, input, and div that contains the query value. When we're done, you will see that the value is updated only after a delay. In the setup method, we create a debounced ref using useDebouncedRef function and pass an empty string as an initial value as well as 400, which is the debounce delay. We will create it in a moment. Besides that, we also have a watcher that observes the query ref. That's where you could initialise a function that would perform an API request.
useDebouncedRef.js
Here is the implementation of the useDebouncedRef composable.
import { ref, customRef } from 'vue'
const debounce = (fn, delay = 0, immediate = false) => {
let timeout
return (...args) => {
if (immediate && !timeout) fn(...args)
clearTimeout(timeout)
timeout = setTimeout(() => {
fn(...args)
}, delay)
}
}
const useDebouncedRef = (initialValue, delay, immediate) => {
const state = ref(initialValue)
const debouncedRef = customRef((track, trigger) => ({
get() {
track()
return state.value
},
set: debounce(
value => {
state.value = value
trigger()
},
delay,
immediate
),
}))
return debouncedRef
}
export default useDebouncedRef
In the useDebouncedRef.js file, we have debounce and useDebouncedRef functions. The debounce function takes care of executing a callback function after the specified delay time passed. Besides a callback function and delay number, it also accepts a third parameter called immediate. As the name suggests, it is used to indicate if the callback should be executed immediately. This little helper function could be abstracted into its own utility file and reused in other parts of your applications.
In the useDebouncedRef composable, we declare a new reactive value using the ref method imported from the vue package. However, we also use a customRef, and that's where the magic happens. The customRef gives us explicit control over tracking dependencies and triggering state updates. The getter calls the track method and returns the current state value. For the setter, we assign a result of the debounce function, which receives a callback as the first parameter and forwards delay and immediate arguments. In the callback, we update the state ref and trigger the update of the customRef. That's all. If you try to enter something in the input field, you should see, that the value text updates only after you stop typing for a specified amount of time.
TypeScript version
Here is the same composable written in TypeScript:
composables/useDebouncedRef.ts
import { ref, customRef, type Ref } from 'vue'
const debounce = (fn: (...args: unknown[]) => void, delay = 0, immediate = false) => {
let timeout: ReturnType<typeof setTimeout> | undefined
return (...args: unknown[]) => {
if (immediate && !timeout) fn(...args)
clearTimeout(timeout)
timeout = setTimeout(() => {
fn(...args)
}, delay)
}
}
const useDebouncedRef = <T>(initialValue: T, delay: number, immediate = false): Ref<T> => {
const state = ref<T>(initialValue)
const debouncedRef = customRef<T>((track, trigger) => ({
get() {
track()
return state.value as T
},
set: debounce(
(value: unknown) => {
state.value = value as T
trigger()
},
delay,
immediate
),
}))
return debouncedRef
}
export default useDebouncedRef
VueUse alternative: refDebounced
If your project uses @vueuse/core, it ships a refDebounced composable (also exported as useDebounce) that does the same thing:
<script setup>
import { ref, watch } from 'vue'
import { refDebounced } from '@vueuse/core'
const query = ref('')
const debouncedQuery = refDebounced(query, 400)
watch(debouncedQuery, newQuery => {
// init an API request
console.log({ newQuery })
})
</script>
The difference from the custom composable: refDebounced takes a regular ref and returns a new read-only ref that updates with a delay. Your input still binds to the original query ref, and you watch debouncedQuery. Writing your own with customRef is good for understanding how Vue's reactivity system works at a lower level. Using VueUse is the practical choice for most projects.
Frequently asked questions
If you want to learn more about Vue patterns and best practices, check out the Vue - The Road To Enterprise book.



