Skip to content
Release notesVueUse

VueUse 15: the breaking changes in plain words

VueUse 15.0.0 was published on 16 September with six breaking changes. Most take a line or two to fix, but three change behaviour without an error, so each comes with a check you can run.

vuejs.school team17 Sep 20266 min readVueUse 15.0

VueUse, the collection of composables Vue apps reach for when they need the mouse position, local storage or a debounced function, published version 15.0.0 on 16 September. The release notes list six breaking changes, three new functions and a set of bug fixes. The new functions are additions and cannot break anything: useWebMCP exposes a function on your page as a tool that an AI agent can call, useLiveAnnouncer announces messages to screen reader users, and useTemporalNow is a reactive clock built on the Temporal date API.

The six breaking changes fall into two groups. Three make themselves known: an import of templateRef fails the build, the removed timer options fail the type check in a TypeScript project, and npm warns when it installs on Node 20. The other three change behaviour without any error: useThrottleFn runs a trailing call, useEventSource reports plain messages differently, and useIDBKeyval syncs between tabs. Each change below has a Check: what to search for in your code to see whether the change affects you.

templateRef is removed

templateRef was a VueUse shorthand that bound a ref to an element in the template. Vue 3.5 added useTemplateRef, which does the same job and is built in, and the VueUse docs already warned that templateRef would be removed in a future version. In 15 it is gone, and an import of it fails the build.

Check: search for templateRef( and for templateRef in import lines.

src/components/SearchBox.vueBreaks
<script setup lang="ts">
import { onMounted } from 'vue'
import { templateRef } from '@vueuse/core'

const searchInput = templateRef<HTMLInputElement>('search-input')
onMounted(() => searchInput.value?.focus())
</script>

<template>
  <input ref="search-input" type="search" aria-label="Search lessons">
</template>
src/components/SearchBox.vueWorks
<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue'

const searchInput = useTemplateRef<HTMLInputElement>('search-input')
onMounted(() => searchInput.value?.focus())
</script>

<template>
  <input ref="search-input" type="search" aria-label="Search lessons">
</template>

The template does not change. Only the import moves from VueUse to Vue.

Node 20 is no longer supported

Every published @vueuse/* package now declares "engines": { "node": ">=22" } in its package.json. The pull request gives the reason: Node 20 reached end of life in April, and VueUse's own CI already tested only on 22 and the current LTS. The field is read at install time, never in the browser, so what matters is the Node version that installs your dependencies and runs your build.

Check: run node --version locally, and look at the Node version in your CI configuration and your hosting platform's build settings. On Node 20, npm prints an EBADENGINE warning during install, and fails outright if engine-strict is set.

Timer options are replaced by scheduler

Several composables used to take their own timing options: an interval in milliseconds, an immediate flag, sometimes an updateInterval. VueUse 14.2 added one option to replace them all, scheduler: a function that receives the update callback and returns something you can pause and resume, such as the result of useIntervalFn.

The old options kept working in 14 with a deprecation note. In 15 they are removed:

  • useCountdown, useElementByPoint, useNow and useTimestamp: interval and immediate
  • useMemory: interval, immediate and immediateCallback
  • useTimeAgo and useTimeAgoIntl: updateInterval
  • useVibrate: interval
  • useWebSocket: heartbeat.interval

Check: search for calls to each of these functions and look for interval, immediate, immediateCallback or updateInterval in the options you pass. TypeScript flags an inline options object that still has one. In plain JavaScript there is no error: the composable ignores the option and runs on its default timing.

In this clock composable, useNow ticks every second and useTimeAgo refreshes its label every minute:

src/composables/useClock.tsBreaks
import { ref } from 'vue'
import { useNow, useTimeAgo } from '@vueuse/core'

export function useClock() {
  const lastSaved = ref(new Date())
  const now = useNow({ interval: 1000 })
  const savedAgo = useTimeAgo(lastSaved, { updateInterval: 60_000 })
  return { now, lastSaved, savedAgo }
}
src/composables/useClock.tsWorks
import { ref } from 'vue'
import { useIntervalFn, useNow, useTimeAgo } from '@vueuse/core'

export function useClock() {
  const lastSaved = ref(new Date())
  const now = useNow({ scheduler: cb => useIntervalFn(cb, 1000) })
  const savedAgo = useTimeAgo(lastSaved, { scheduler: cb => useIntervalFn(cb, 60_000) })
  return { now, lastSaved, savedAgo }
}

Each old option has a direct replacement:

  • interval: 1000 or updateInterval: 1000 becomes scheduler: cb => useIntervalFn(cb, 1000).
  • immediate: false and immediateCallback move into the scheduler's options: useIntervalFn(cb, 1000, { immediate: false }), or useRafFn(cb, { immediate: false }) where there was no interval.
  • In useWebSocket, heartbeat: { interval: 5000 } becomes heartbeat: { scheduler: cb => useIntervalFn(cb, 5000, { immediate: false }) }.

Leaving scheduler out is fine too: useNow then updates on every animation frame, and useTimeAgo every 30 seconds. Take care with useCountdown, useVibrate and the useWebSocket heartbeat. Their interval timer started paused: a countdown waited for start(), a repeating vibration for intervalControls.resume(), a heartbeat for the socket to open. useIntervalFn starts at once, so without { immediate: false } a countdown begins counting as soon as it is created.

useThrottleFn now runs the trailing call

Throttling limits how often a function runs: at most once per wait period, however often you call it. The question is what happens to calls made during the wait. With trailing off, they are dropped. With trailing on, the last one runs once more when the wait ends, so the final state always gets handled.

Until 15, useThrottleFn left trailing off unless you passed true as its third argument. In 15 the default is true, as in lodash's throttle and VueUse's own refThrottled; the pull request says the old difference from lodash made the function behave unexpectedly in some cases. For scroll and resize handlers the new default is what you want: the last position always counts.

Check: search for useThrottleFn( calls that pass no third argument. Those change behaviour; calls that pass true or false do not.

If you throttled something that must not run twice, such as a submit, keep the old behaviour explicitly:

src/composables/useFeedback.ts
import { useThrottleFn } from '@vueuse/core'

async function sendFeedback(message: string) {
  await fetch('/api/feedback', { method: 'POST', body: JSON.stringify({ message }) })
}

export function useFeedback() {
  // fn, wait in ms, trailing, leading
  const send = useThrottleFn(sendFeedback, 2000, false, true)
  return { send }
}

useEventSource changes how plain messages arrive

useEventSource wraps the browser's EventSource, which receives server-sent events: a stream of messages from the server over one HTTP connection. A server can send plain messages, or named events that carry an event: line such as event: notice. In 14, a plain message arrived with event.value set to null. In 15 it arrives with event.value set to 'message', the type the browser itself gives it.

The list of event names changed too: when you pass one, VueUse 15 listens for exactly those names. In 14 plain messages came through as well, on top of your list; and if your list included 'message', each plain message fired twice.

Check: search for useEventSource(. Look for comparisons of event.value with null, and for calls that pass named events while the server also sends plain messages.

If your server sends both kinds, list 'message' next to your named events and tell them apart with event.value:

src/composables/useLiveScores.ts
import { watch } from 'vue'
import { useEventSource } from '@vueuse/core'

export function useLiveScores(onScore: (raw: string) => void, onNotice: (raw: string) => void) {
  // 'message' is listed because the server also sends plain messages
  const { event, data } = useEventSource('/api/scores', ['notice', 'message'])

  watch(data, (raw) => {
    if (raw === null) return
    if (event.value === 'message') onScore(raw)
    else if (event.value === 'notice') onNotice(raw)
  })
}

useIDBKeyval syncs across tabs

useIDBKeyval, from @vueuse/integrations, keeps a ref in IndexedDB through the small idb-keyval library. In 15 it also keeps every open tab in step: when one tab writes the value, the others receive it through a BroadcastChannel and update their ref. This is on by default.

For most apps that is a fix rather than a break. A shopping cart or a settings object should look the same in every tab. It breaks code that relied on tabs being independent, such as a draft editor where each tab holds its own unsaved text. Turn the sync off with the same option name useStorage uses: useIDBKeyval('draft', '', { listenToStorageChanges: false }).

Check: search for useIDBKeyval( and ask, for each key, whether two tabs should see the same value.

How to upgrade

Upgrade every VueUse package you use to the same major in one command. Each package pins the others to its own exact version, so mixing 14 and 15 puts two copies of @vueuse/core and @vueuse/shared in your bundle.

npm install @vueuse/core@15 @vueuse/integrations@15
npx vue-tsc --build

Add @vueuse/router@15 or any other VueUse package you use to the first line. In a Nuxt app, also add @vueuse/nuxt@15, and run npx nuxt typecheck instead of the second line. Run the type check before the test suite: templateRef and the removed timer options show up as type errors with a file and a line, the fastest list of what to fix. It cannot see the useThrottleFn, useEventSource and useIDBKeyval changes, so go through their checks by hand.

If you are coming from VueUse 13, read the 14.0 release notes first. That release required Vue 3.5, deprecated the alias exports in favour of the original function names and changed computedAsync to flush synchronously, among other changes. Do the two upgrades as two commits, so a failing test points at one release.

Check it stuck3 questions from the Debounce, throttle and timers quiz.
Practice now
Was this helpful?

Keep reading