VueUse Composable Patterns
Quick Guide: VueUse wraps browser APIs, sensors, network transports and small state utilities as composables that return refs and clean themselves up on scope disposal. Import each function by name from
@vueuse/core— the library is large and entirely tree-shakeable. Two things decide whether a call works: it must run synchronously during setup, because teardown binds to the effect scope that is current at call time; and browser-API composables behave differently on a server, some returning a safe default and some needing client-only rendering.
Detailed Resources:
- examples/core.md — storage, event listeners, media queries, small state utilities
- examples/sensors.md — mouse, scroll, intersection, resize, element visibility
- examples/network.md —
useFetch,createFetch,useWebSocket,useEventSource - examples/state.md —
createGlobalState,useRefHistory,syncRef, persistence - examples/component.md —
useVModel,useVirtualList,onClickOutside,onKeyStroke, animation - reference.md — composable index by category, the SSR table, gotchas, deprecations
Which path applies
Server rendering is the branch, because it changes which composables are usable at all.
- Client-rendered only — every composable is available; call it and read the ref.
- Server-rendered — storage, media-query and preference composables return their declared default on the server and hydrate on the client. Sensor and transport composables (
useMouse,useScroll,useIntersectionObserver,useWebSocket,useClipboard) need a DOM and belong inside a client-only boundary. The table in reference.md says which is which. - The composable wraps an API not every browser has — read its
isSupportedref and render a fallback, rather than assuming the call worked.
Before writing VueUse code
Call composables synchronously during setup. Teardown registers against the effect scope that
is current when the composable runs, so a call inside an await, a setTimeout or an event
handler has no scope to attach to and its listeners are never removed.
Import each composable by name from @vueuse/core. A namespace import pulls the whole library
past the bundler's tree-shaker, and the library is several hundred functions.
Read isSupported where the composable wraps an optional browser API — clipboard, share,
geolocation, battery, wake lock. It exists because the API can be absent or permission-gated, and
the fallback path is what the user sees when it is.
Decide the server behaviour before the composable ships. Each one either returns a declared default on the server or needs a client-only boundary; guessing produces a hydration mismatch, which reports as a rendering bug far from its cause.
Auto-detection: VueUse, vueuse, @vueuse/core, @vueuse/integrations, useLocalStorage, useSessionStorage, useClipboard, useFetch, createFetch, useMouse, useMouseInElement, useScroll, useIntersectionObserver, useResizeObserver, useElementVisibility, useElementSize, useMediaQuery, usePreferredDark, useDark, useToggle, useCounter, useCycleList, useWebSocket, useEventSource, useEventListener, useTransition, useRafFn, createGlobalState, useRefHistory, useManualRefHistory, syncRef, useVModel, useVirtualList, onClickOutside, onKeyStroke, useDebounceFn, watchDebounced
Applies to:
- Browser APIs as reactive refs — storage, clipboard, media queries, preferences
- Sensor readings — pointer position, scroll, intersection, element size and visibility
- Reactive HTTP, WebSocket and server-sent-event transports
- Small shared state, undo history, and ref synchronisation
- Component helpers — two-way binding, virtual lists, outside clicks, key strokes, value animation
Handled elsewhere:
- Server-state caching, invalidation and optimistic updates — a distinct problem that a reactive request wrapper does not solve
- Application state architecture — actions, modules and devtools belong to whatever owns the store
- The reactivity primitives themselves —
ref,computed,watchand effect scopes are the framework's, and these composables are built on top of them - Routing, forms and validation — none of these composables model any of it
Each composable owns one concern and its own teardown. That is the whole contract: it returns refs, it registers whatever listener or observer it needs, and it disposes of them when the surrounding effect scope ends. Nothing is registered globally and nothing needs a plugin.
The consequence is that the call site owns the lifetime. Two
components calling useMouse() get two independent listeners, both cleaned up separately — which
is why sharing one instance is an explicit act (createGlobalState, or a composable of your own)
rather than something the library does for you.
Core patterns
Pattern 1: Reactive storage
import { useLocalStorage } from "@vueuse/core";
const settings = useLocalStorage("app-settings", DEFAULTS, {
mergeDefaults: true, // stored data gains fields added to DEFAULTS since it was written
});
settings.value.theme = "dark"; // persists, and other tabs see it
settings.value = null; // removes the key entirely — it does not store null
Serialisation, cross-tab sync via the storage event, and a server-side default all come with it.
Full code: examples/core.md
Pattern 2: Listeners that remove themselves
import { useEventListener } from "@vueuse/core";
import { useTemplateRef } from "vue";
const dropZone = useTemplateRef("drop-zone");
useEventListener(window, "resize", onResize);
useEventListener(dropZone, "drop", onDrop); // waits for the ref to be populated
Passing a ref rather than an element is the point: the composable attaches once the element exists and re-attaches if it changes, so there is no mount-order problem to solve by hand.
Full code: examples/core.md
Pattern 3: Media queries and colour scheme
import {
useMediaQuery,
usePreferredDark,
useDark,
useToggle,
} from "@vueuse/core";
const isMobile = useMediaQuery("(max-width: 768px)");
const prefersDark = usePreferredDark(); // reads the OS setting
const isDark = useDark(); // reads it, persists an override, toggles a class
const toggleDark = useToggle(isDark);
usePreferredDark observes; useDark also writes — it persists the choice and toggles a class on
the root element, so the class name it uses has to match what the stylesheet expects.
Full code: examples/core.md
Pattern 4: Sensors
import { useScroll, useIntersectionObserver } from "@vueuse/core";
const { y, directions, arrivedState } = useScroll(container);
useIntersectionObserver(
sentinel,
([entry]) => {
if (entry?.isIntersecting) loadMore();
},
{ rootMargin: "200px" },
); // fire before it reaches the viewport
arrivedState and directions are the parts worth knowing — they replace the offset arithmetic
that scroll handlers usually get wrong at the edges.
Full code: examples/sensors.md
Pattern 5: Reactive fetch
import { useFetch } from "@vueuse/core";
const url = computed(() => `/api/users/${userId.value}`);
// Re-runs whenever `url` changes, and aborts the previous request
const { data, isFetching, error, abort } = useFetch(url, {
refetch: true,
}).json<User>();
// Manual: no request until execute() is called
const { execute } = useFetch("/api/users", { immediate: false })
.post(body)
.json();
The chained .json<T>() is what types data; without it you hold a Ref<string | null>.
Full code: examples/network.md
Pattern 6: Shared state without a store library
import { createGlobalState } from "@vueuse/core";
export const useGlobalCounter = createGlobalState(() => {
const count = shallowRef(0);
const double = computed(() => count.value * 2);
const increment = () => count.value++;
return { count, double, increment };
});
The factory runs once and every caller gets the same refs. Returning actions rather than the bare writable refs is what keeps the mutation points countable.
Full code: examples/state.md
Pattern 7: Two-way binding
import { useVModel } from "@vueuse/core";
const value = useVModel(props, "modelValue", emit);
const count = useVModel(props, "count", emit); // named v-model
value.value = next; // emits update:modelValue
One line where the get/set computed is five, and the event name is derived rather than typed out — which is where the typo used to live.
Full code: examples/component.md
Red flags
Breaks at runtime:
- A composable called inside
await,setTimeoutor an event handler binds to no effect scope: its listeners and observers are never disposed of, and the component leaks them on every mount. - A sensor or transport composable called during server rendering touches
windowordocumentand throws. Move it behind a client-only boundary. - Using a browser-API composable without checking
isSupportedfails on the browsers and contexts where the API is missing or permission-gated — clipboard write outside a user gesture is the usual first case. - A namespace import of
@vueuse/coredefeats tree-shaking and ships the whole library.
Surprising behaviour:
useLocalStorage(...).value = nulldeletes the key rather than storingnull. Store an explicit empty value where the key must survive.createGlobalStateis a singleton for the module's lifetime — it survives every component unmounting and is cleared only by a reload, which makes it a poor fit for per-user state that must reset on sign-out.useIntersectionObserverinvokes its callback once on registration with the element's current state, so "became visible" needs comparing against the previous value rather than trusting the first call.useFetchfires on mount by default;refetch: trueadds re-firing on URL change without removing the initial one.immediate: falseis what makes it fully manual.useWebSocketreconnects by default, so a server rejecting the connection produces a retry loop untilautoReconnect: falseor a boundedretriessays otherwise.useRefHistorysnapshots every change, which on a text input is one entry per keystroke. Give it acapacity, or useuseManualRefHistoryand commit at save points.shallowRefavoids deep-reactivity cost on large objects, but a composable that persists or watches nested changes —useLocalStorageamong them — needs the deep tracking to see them at all. Choose per composable rather than as a blanket rule.