Skip to content
TutorialVueUse

Write a composable the VueUse way

VueUse publishes the guidelines its own composables are written to. Follow them line by line in two small composables, then see what the real versions handle that yours skip.

vuejs.school team25 Jun 20266 min readVueUse 14.3 · Vue 3.5

A composable that wraps a browser API meets the same problems every time. There is no window during a server render, listeners and timers must be removed when the component unmounts, and callers want to pass a plain value one day and a ref the next. VueUse, a library of more than two hundred composables, writes its answers down as guidelines and offers them as a reference for your own code.

This tutorial follows those guidelines to build two composables against VueUse 14.3 and Vue 3.5: useOnline, which tracks whether the browser has a connection, and useCopy, a smaller cousin of useClipboard. Both work on the server, clean up after themselves and have types that explain them, and useCopy takes its text as a plain value, a ref or a getter.

Six rules from VueUse's guidelines

The rules lean on two Vue terms. An effect scope is the container Vue uses to collect watchers and computed values so it can stop them together; every component has one, and it stops when the component unmounts. onScopeDispose registers a function to run when the current scope stops.

VueUse keeps its rules on a guidelines page in its docs. These six, taken from its list and its examples, shape the code below:

  • Take an options object as the last argument, with every default written where the options are read.
  • Prefer shallowRef to ref. Most state is replaced as a whole, so deep tracking is wasted work.
  • Accept configurable globals: a window or navigator option, so tests can pass a fake and the server can pass nothing.
  • Return an isSupported flag when the browser API is not available everywhere.
  • Clean up with tryOnScopeDispose, or with helpers like useEventListener that remove their listeners when the scope stops.
  • Accept MaybeRefOrGetter inputs and read them with toValue, so callers can pass a value, a ref or a getter.

Build useOnline

The browser fires online and offline events on window, and navigator.onLine holds the current state. Here is the composable:

src/composables/useOnline.ts
import { shallowReadonly, shallowRef, type ShallowRef } from 'vue'
import { isClient, useEventListener } from '@vueuse/core'

export interface UseOnlineOptions {
  /** Window to watch. Pass a fake one in tests. */
  window?: Window
}

export function useOnline(options: UseOnlineOptions = {}): Readonly<ShallowRef<boolean>> {
  const { window: win = isClient ? window : undefined } = options
  const isOnline = shallowRef(win?.navigator.onLine ?? true)

  if (win) {
    useEventListener(win, 'online', () => { isOnline.value = true }, { passive: true })
    useEventListener(win, 'offline', () => { isOnline.value = false }, { passive: true })
  }

  return shallowReadonly(isOnline)
}

Read it from the top. isClient is VueUse's check for a browser, so on the server win is undefined, nothing touches navigator, and the state starts as true, a safe default for a server render. The listeners are only registered when there is a window.

useEventListener adds the listener and removes it when the surrounding scope stops, usually when the component unmounts. You never write removeEventListener. It also returns a function if you want to stop listening early.

The return type is written out: a read-only shallow ref. shallowReadonly stops a caller from setting isOnline.value = false and confusing every other part of the app.

Build useCopy

The clipboard version adds an options object with defaults, an input that may be reactive, an isSupported flag and a timer to clean up:

src/composables/useCopy.ts
import { shallowReadonly, shallowRef, toValue, type ComputedRef, type MaybeRefOrGetter, type ShallowRef } from 'vue'
import { isClient, tryOnScopeDispose, useSupported } from '@vueuse/core'

export interface UseCopyOptions {
  /** Text to copy when copy() is called without an argument. */
  source?: MaybeRefOrGetter<string>
  /** How long `copied` stays true, in milliseconds. @default 1500 */
  copiedDuring?: number
  navigator?: Navigator
}

export interface UseCopyReturn {
  isSupported: ComputedRef<boolean>
  copied: Readonly<ShallowRef<boolean>>
  copy: (text?: string) => Promise<void>
}

export function useCopy(options: UseCopyOptions = {}): UseCopyReturn {
  const { source, copiedDuring = 1500, navigator: nav = isClient ? navigator : undefined } = options
  const isSupported = useSupported(() => nav && 'clipboard' in nav)
  const copied = shallowRef(false)
  let timer: ReturnType<typeof setTimeout> | undefined

  async function copy(text = toValue(source)) {
    if (!isSupported.value || text == null) return
    await nav!.clipboard.writeText(text)
    copied.value = true
    clearTimeout(timer)
    timer = setTimeout(() => { copied.value = false }, copiedDuring)
  }

  tryOnScopeDispose(() => clearTimeout(timer))

  return { isSupported, copied: shallowReadonly(copied), copy }
}

The options are destructured in one line, defaults included, so the documentation and the behaviour live in the same place. copiedDuring takes its name from VueUse's own option.

source is a MaybeRefOrGetter<string>, read with toValue(source) at the moment copy() runs. That means the caller can pass 'hello', a ref, or () => props.code, and always gets the current value.

useSupported wraps the check in a computed. It is safe to read on the server, where it is simply false, and VueUse checks it again once the component has mounted.

tryOnScopeDispose clears a pending timer when the scope stops. Unlike onUnmounted, it also works in a scope that is not a component, such as a Pinia setup store, and it quietly does nothing when there is no scope at all, instead of printing a warning.

Call copy() from a button

A copy button for a code sample. The @ in the import is the alias for src that create-vue sets up:

src/components/CopyButton.vue
<script setup lang="ts">
import { useCopy } from '@/composables/useCopy'

const props = defineProps<{ code: string }>()
const { copy, copied, isSupported } = useCopy({ source: () => props.code })
</script>

<template>
  <button v-if="isSupported" type="button" @click="copy()">
    {{ copied ? 'Copied' : 'Copy' }}
  </button>
</template>

Notice copy() with brackets. This version looks the same and copies the wrong thing:

src/components/CopyButton.vueBreaks
<button type="button" @click="copy">Copy</button>

Without brackets, Vue passes the click event as the first argument, so text is the event object, not undefined, and the default never applies. The typed return helps here: hover copy in your editor and it says it expects a string. Call it explicitly:

src/components/CopyButton.vueWorks
<button type="button" @click="copy()">Copy</button>

What VueUse's own versions add

Open VueUse 14.3's source for the same two functions to see what a production composable has to handle.

useOnline in VueUse is a two-line wrapper: it calls useNetwork and returns its isOnline. The core of useNetwork is what we wrote: the same two listeners behind a guarded window, and state returned through shallowReadonly.

Its extras are data you may never need. It records offlineAt and onlineAt timestamps, and where the Network Information API exists it also reports downlink, effectiveType, rtt, saveData and the connection type, listening for the connection's change event.

useClipboard is a different story. Compared with useCopy, it adds:

  • A fallback. When the permission is denied or clipboard.write() throws, it copies through a hidden <textarea> and document.execCommand('copy') instead. With legacy: true it does the same in browsers with no Clipboard API at all. Ours lets the error reach the caller.
  • Permission checks for clipboard-write and clipboard-read through usePermission, before it tries.
  • Reading. With read: true, it listens for copy and cut events and keeps the clipboard text in a text ref.
  • Async values. copy() also accepts a function that returns a promise of a string, wrapped in a ClipboardItem, so you can start copying text that is still being fetched.
  • A copyPending ref while a copy is in progress, and TypeScript overloads that make the argument to copy() optional only when you passed a source.

Write your own when you need a small, clear slice of behaviour and you control the browsers. Use VueUse's when the edge cases are the point: a copy button that silently fails in one browser is worse than no button.

Publish it in a shared package

When two projects need the same composables, move them into a package. Keep it boring: the composables in packages/composables/src/, one src/index.ts that re-exports each of them, and a build with tsdown, the bundler VueUse itself builds with.

packages/composables/package.json
{
  "name": "@acme/composables",
  "version": "0.1.0",
  "type": "module",
  "sideEffects": false,
  "files": ["dist"],
  "scripts": {
    "build": "tsdown",
    "prepack": "pnpm run build"
  },
  "peerDependencies": {
    "@vueuse/core": "^14.0.0",
    "vue": "^3.5.0"
  },
  "devDependencies": {
    "@vueuse/core": "^14.3.0",
    "tsdown": "^0.22.3",
    "typescript": "^6.0.0",
    "vue": "^3.5.38"
  }
}
packages/composables/tsdown.config.ts
import { defineConfig } from 'tsdown'

export default defineConfig({
  entry: 'src/index.ts',
  dts: true,
  exports: true,
})

Vue and VueUse are peer dependencies: the app provides them, so there is exactly one copy of Vue's reactivity at runtime. Two copies would mean two separate reactivity systems that cannot see each other's refs. tsdown leaves peer dependencies out of the bundle by default.

What the other settings do:

  • dts: true writes the type declaration files, so every user of the package gets the return types you wrote.
  • exports: true fills in the exports field of package.json from the build.
  • sideEffects: false tells bundlers that unused composables can be dropped.

Build it with pnpm run build. tsdown 0.22.3 needs Node 22.18 or a later 22.x, or Node 24.11 or later. Inside a pnpm workspace, an app depends on the package with "@acme/composables": "workspace:*", and nothing needs publishing to a registry at all.

Check it stuck3 questions from the Write a composable like VueUse quiz.
Practice now
Was this helpful?

Keep reading