@vueuse/core
VueUse is a collection of Vue 3 composition functions, 278 of them as of v14, that wrap browser APIs and common state patterns as refs. Instead of writing addEventListener plus a matching cleanup hook for the tenth time you call useEventListener; instead of hand-rolling localStorage sync you call useLocalStorage. It covers browser APIs (clipboard, geolocation, media queries, intersection and resize observers), reactive utilities (debounce, throttle, undo history, async state), sensors, animation and time formatting. Everything is tree-shakeable and typed, and companion packages add Router, RxJS, Firebase and Electron bindings plus a Nuxt module.
For a Vue 3.5+ app this is worth the dependency, because cleanup, SSR fallbacks and observer teardown are exactly the parts people get wrong by hand. Import only the functions you need, treat each major as a real upgrade, and do not mistake useFetch for a data layer.
Use it if
- You are on Vue 3.5 or newer and keep rewriting the same wrappers around browser APIs: storage, media queries, resize and intersection observers, clipboard, drag and drop
- You care about cleanup being correct: every composable registers its listeners and observers against the current effect scope, so they stop when the component unmounts without you writing onUnmounted
- You use Nuxt or unplugin-auto-import: the Nuxt module and the auto-import preset make the whole set available without import statements, and the Nuxt module also handles the SSR-only paths
- You need one of the fiddly browser APIs done properly rather than approximately: useIntersectionObserver, useResizeObserver, useMagicKeys, useDropZone, useVirtualList and useWebSocket are each a day of work to get right by hand
- You only need two or three helpers: a 30-line useEventListener living in your own repo has no upgrade treadmill, while VueUse ships a major roughly once a year and every one has moved something (Vue 2 dropped in v12, CommonJS dropped in v13, Vue 3.5 required plus throttle and computedAsync behavior changes in v14)
- Your build or test setup still needs CommonJS: since v13 the package is ESM-only, so Jest without transform config, older webpack setups, and CJS server-render entry points fail on require
- You are on Vue 2 or Vue 3.4 and below: you are pinned to v11 for Vue 2 or v12/v13 for older Vue 3, and those lines get security backports at best, not new functions
- Your actual problem is server state: useFetch has no cache, no request dedupe, no retry policy and no invalidation, so using it as your data layer means rebuilding all of that by hand
- You render on the server and cannot tolerate hydration differences: most composables read window during setup and return defaults on the server, so anything that drives markup (useBreakpoints, useDark, useLocalStorage) needs an explicit client guard or you get mismatch warnings and layout flashes
Setup reality
npm i @vueuse/core is quick, but the peer dependency is vue ^3.5.0 with no escape hatch: an older Vue means an older major of VueUse. The package has been ESM-only since v13, so CommonJS consumers need transform config, and v14 moved the dist files again when the build switched to tsdown, which breaks anyone who deep-imported paths. It pulls @vueuse/shared, @vueuse/metadata and, less obviously, @types/web-bluetooth as a regular runtime dependency, so it appears in production installs. Tree-shaking works only if your bundler sees the ESM build and you import named functions rather than a namespace object. On Nuxt install the module instead of the raw package so auto-imports and the server paths are wired for you. The add-ons (router, rxjs, firebase, integrations, electron) are separate packages with their own peer dependencies.
Patterns
Persist reactive state in localStoragepersist-state-to-localstorage
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
const prefs = useLocalStorage('app:prefs', {
theme: 'light',
fontSize: 14,
})
// any mutation is written back automatically
prefs.value.fontSize = 16
</script>It deep-watches the object and listens for the storage event, so other tabs stay in sync. During SSR the first render uses the default value, so anything derived from it that affects markup will produce a hydration mismatch unless you guard it.
Read pointer position and window size reactivelytrack-pointer-and-viewport
<script setup lang="ts">
import { useMouse, useWindowSize } from '@vueuse/core'
const { x, y, sourceType } = useMouse()
const { width, height } = useWindowSize()
</script>
<template>
<p>{{ x }},{{ y }} ({{ sourceType }}) in {{ width }}x{{ height }}</p>
</template>sourceType is 'mouse', 'touch' or null, which matters because touch events drive the same refs. Both register window listeners tied to the component scope, so nothing leaks on unmount.
Attach a listener that removes itselfauto-cleanup-event-listener
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { useEventListener } from '@vueuse/core'
const box = useTemplateRef<HTMLElement>('box')
// target may be a ref; the listener rebinds when it changes
const stop = useEventListener(box, 'pointerdown', e => console.log(e.button))
useEventListener(document, 'keydown', (e) => {
if (e.key === 'Escape')
stop()
})
</script>
<template>
<div ref="box">click me</div>
</template>Automatic removal only happens inside an active effect scope. Call it from a plain module, a setTimeout or an async callback after await and you own the returned stop function, otherwise the listener stays attached.
Close a menu when the user clicks elsewheredetect-click-outside
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue'
import { onClickOutside } from '@vueuse/core'
const open = ref(true)
const menu = useTemplateRef<HTMLElement>('menu')
onClickOutside(menu, () => { open.value = false }, {
ignore: ['.toolbar-button'],
})
</script>The handler fires on pointerdown, so the button that opened the menu will immediately close it again unless you list that element in ignore. Content rendered through a Teleport lives outside the subtree and counts as an outside click.
Wire a dark mode class to system preference and storagedark-mode-toggle
<script setup lang="ts">
import { useDark, useToggle } from '@vueuse/core'
const isDark = useDark({
selector: 'html',
attribute: 'class',
valueDark: 'dark',
valueLight: '',
})
const toggleDark = useToggle(isDark)
</script>
<template>
<button @click="toggleDark()">{{ isDark ? 'Light' : 'Dark' }}</button>
</template>The choice is stored under the vueuse-color-scheme key and falls back to prefers-color-scheme. The class is only applied after hydration, so add a tiny inline script in your HTML head if you want to avoid a flash of the wrong theme on first paint.
Debounce a value, a callback, and a watcherdebounce-and-throttle
import { ref } from 'vue'
import { refDebounced, useDebounceFn, watchDebounced } from '@vueuse/core'
const query = ref('')
const debouncedQuery = refDebounced(query, 300)
const save = useDebounceFn(() => api.save(form), 500, { maxWait: 2000 })
watchDebounced(query, q => search(q), { debounce: 300, maxWait: 1000 })useDebounceFn returns a promise resolving to the result of the invocation that actually ran. v14 changed useThrottleFn to standard throttle timing, so call timing shifts when upgrading from v13 if you were relying on the old behavior.
Load data with loading and error refsasync-state-and-fetch
<script setup lang="ts">
import { useAsyncState, useFetch } from '@vueuse/core'
const { state: user, isLoading, error, execute } = useAsyncState(
(id: number) => fetch(`/api/users/${id}`).then(r => r.json()),
null,
{ immediate: true, resetOnExecute: false },
)
const { data, isFetching, abort } = useFetch('/api/stats', {
refetch: true,
timeout: 5000,
}).get().json()
</script>useAsyncState captures rejections into error instead of throwing (throwError defaults to false), so a silently null state usually means the error ref is never rendered. useFetch is a thin wrapper: no cache, no dedupe, no retry.
Run work when an element scrolls into viewlazy-load-on-visible
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'
const sentinel = useTemplateRef<HTMLElement>('sentinel')
const loaded = ref(false)
const { stop } = useIntersectionObserver(
sentinel,
([entry]) => {
if (entry?.isIntersecting) {
loaded.value = true
stop()
}
},
{ threshold: 0.1, rootMargin: '200px' },
)
</script>
<template>
<div ref="sentinel" />
</template>The observer fires once shortly after setup with isIntersecting false, so branch on the entry rather than assuming the first call means visible. If you only want a boolean, useElementVisibility wraps the same observer.
Branch on breakpoints and media queriesresponsive-breakpoints
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints, useMediaQuery } from '@vueuse/core'
const breakpoints = useBreakpoints(breakpointsTailwind)
const isDesktop = breakpoints.greaterOrEqual('lg')
const isMobile = breakpoints.smaller('md')
const current = breakpoints.active()
const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
</script>These are matchMedia queries, not CSS, so on the server every one evaluates false and the first client render can visibly change the layout. Presets ship for Tailwind, Bootstrap, Vuetify, Ant Design and others, or pass your own object of names to widths.
Share one composable instance between componentsshare-state-across-components
import { computed, ref } from 'vue'
import { createGlobalState, createInjectionState } from '@vueuse/core'
export const useCartState = createGlobalState(() => {
const items = ref<string[]>([])
const count = computed(() => items.value.length)
return { items, count }
})
export const [provideCounter, useCounter] = createInjectionState((initial: number) => {
const count = ref(initial)
return { count }
})createGlobalState is a module-level singleton, so on a server it is shared across every request; use createInjectionState (provide and inject under the hood) for anything per-user. The injected value is typed as possibly undefined unless you pass a default.
Copy text with a copied indicatorcopy-to-clipboard
<script setup lang="ts">
import { ref } from 'vue'
import { useClipboard, usePermission } from '@vueuse/core'
const source = ref('npm i @vueuse/core')
const { text, copy, copied, isSupported } = useClipboard({
source,
copiedDuring: 1500,
})
const writeAccess = usePermission('clipboard-write')
</script>
<template>
<button :disabled="!isSupported" @click="copy()">
{{ copied ? 'Copied' : 'Copy' }}
</button>
</template>navigator.clipboard exists only on secure origins, so isSupported is false over plain http and inside some in-app webviews; pass legacy: true to fall back to document.execCommand. As of v14 the returned text is a readonly ref.
Keep a WebSocket connection with reconnect and heartbeatreactive-websocket
<script setup lang="ts">
import { useWebSocket } from '@vueuse/core'
const { data, status, send, open, close } = useWebSocket('wss://example.com/ws', {
immediate: true,
autoReconnect: {
retries: 5,
delay: 1000,
onFailed: () => console.warn('gave up reconnecting'),
},
heartbeat: { message: 'ping', interval: 15000, pongTimeout: 5000 },
})
</script>data holds only the most recent frame as a raw string or Blob, so keep your own array if you need history and parse JSON yourself. Setting autoReconnect retries to true means retry forever, which will hammer a server that is down.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @tanstack/vue-query | npm | Your real need is server data: caching, deduplication, retries, background refetch and invalidation rather than browser APIs. |
| @vue/reactivity | npm | You want ref, computed and effect scope outside components and no collection of composables at all. |
| nanostores | npm | You need tiny shared state that also works outside Vue, for example in a partially hydrated Astro or multi-framework app. |