mrkeyoor.com_
Fri 07 Aug 19:03 UTC
npmWeb Frontendupdated 07 Aug 2026

@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.

Verdict

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.

API stability3/5Minor releases are additive and deprecations get a cycle, but a major lands roughly yearly and each one has cost real work: Vue 2 removed in v12, ESM-only in v13, Vue 3.5 floor plus changed useThrottleFn timing and computedAsync flush in v14.
Docs5/5vueuse.org gives every function its own page with a live demo, the full type signature, and a link to the source and tests, which means you can check behavior instead of guessing. Migration notes for each major are in the release notes rather than the docs site.
Maintenance5/5Pushed July 2026 with 14.4.0 released the same month, a broad contributor base rather than one person, and 212 open issues out of 359 open issues and PRs on a project this size.
Ecosystem5/5About 9.55M weekly downloads, an official Nuxt module, add-on packages for Router, RxJS, Firebase, Electron and integrations, and built-in presets in unplugin-auto-import.

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
Skip it if

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

PackageRegistryPick it when
@tanstack/vue-querynpmYour real need is server data: caching, deduplication, retries, background refetch and invalidation rather than browser APIs.
@vue/reactivitynpmYou want ref, computed and effect scope outside components and no collection of composables at all.
nanostoresnpmYou need tiny shared state that also works outside Vue, for example in a partially hydrated Astro or multi-framework app.