Vue - The Road To
        Enterprise logo
The Most Advanced Vue Book Buy Now

How to Create a Debounced Ref in Vue 3 (customRef + Composition API)

Vue 3 debounced search input showing delayed value update
By Thomas Findlay5 Apr 2021Updated: 2 Mar 2026

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.

Debounced search query

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.


Want to learn something new?

Subscribe to the newsletter!


Thomas Findlay photo

About the author

Thomas Findlay is a CTO, senior full-stack engineer, and the author of React - The Road To Enterprise and Vue - The Road To Enterprise. With 13+ years of experience, he has built and led engineering teams, architected multi-tenant SaaS platforms, and driven AI-augmented development workflows that cut feature delivery time by 50%+.

Thomas has spoken at international conferences including React Summit, React Advanced London, and Vue Amsterdam, and has written 50+ in-depth technical articles for the Telerik/Progress blog. He holds an MSc in Advanced Computer Science (Distinction) from the University of Exeter and a First-Class BSc in Web Design & Development from Northumbria University.

With a 5-star rating across 2,500+ mentoring sessions and 1,250+ reviews on Codementor, Thomas has helped developers and teams worldwide with architecture consulting, code reviews, and hands-on development support. Find him on LinkedIn, GitHub, or Twitter/X, or get in touch directly. Read the full bio →