---
title: "Write a composable the VueUse way · vuejs.school"
description: "Build useOnline and a small clipboard composable with the conventions VueUse uses, compare them with the real thing, and publish them as a shared package."
canonical_url: "https://vuejs.school/community/blog/write-a-composable-the-vueuse-way"
meta:
  "og:description": "Build useOnline and a small clipboard composable with the conventions VueUse uses, compare them with the real thing, and publish them as a shared package."
  "og:image": "https://vuejs.school/_og/d/a_~V3JpdGUgYSBjb21wb3NhYmxlIHRoZSBWdWVVc2Ugd2F5IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Write%2Ba%2Bcomposable%2Bthe%2BVueUse%2Bway%2Cdescription_~QnVpbGQgdXNlT25saW5lIGFuZCBhIHNtYWxsIGNsaXBib2FyZCBjb21wb3NhYmxlIHdpdGggdGhlIGNvbnZlbnRpb25zIFZ1ZVVzZSB1c2VzLCBjb21wYXJlIHRoZW0gd2l0aCB0aGUgcmVhbCB0aGluZywgYW5kIHB1Ymxpc2ggdGhlbSBhcyBhIHNoYXJlZCBwYWNrYWdlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy93cml0ZS1hLWNvbXBvc2FibGUtdGhlLXZ1ZXVzZS13YXki%2Cs_NeBKNwJbmq257ZWE.png"
  "og:image:alt": "Write a composable the VueUse way · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Write a composable the VueUse way · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~V3JpdGUgYSBjb21wb3NhYmxlIHRoZSBWdWVVc2Ugd2F5IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Write%2Ba%2Bcomposable%2Bthe%2BVueUse%2Bway%2Cdescription_~QnVpbGQgdXNlT25saW5lIGFuZCBhIHNtYWxsIGNsaXBib2FyZCBjb21wb3NhYmxlIHdpdGggdGhlIGNvbnZlbnRpb25zIFZ1ZVVzZSB1c2VzLCBjb21wYXJlIHRoZW0gd2l0aCB0aGUgcmVhbCB0aGluZywgYW5kIHB1Ymxpc2ggdGhlbSBhcyBhIHNoYXJlZCBwYWNrYWdlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy93cml0ZS1hLWNvbXBvc2FibGUtdGhlLXZ1ZXVzZS13YXki%2Cs_NeBKNwJbmq257ZWE.png"
  "twitter:image:alt": "Write a composable the VueUse way · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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](https://vueuse.org/guidelines) 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.vue**Breaks***

```
<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.vue**Works***

```
<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 stuck**3 questions from the Write a composable like VueUse quiz.

[**Practice now**](https://vuejs.school/learn/vueuse/write-a-composable-like-vueuse?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Deep dive · 6 min read**VueUse 15's new composables: useWebMCP, useLiveAnnouncer and useTemporalNow**](https://vuejs.school/community/blog/vueuse-15s-new-composables-usewebmcp-useliveannouncer-usetemporalnow) [Release notes · 6 min read**VueUse 15: the breaking changes in plain words**](https://vuejs.school/community/blog/vueuse-15-the-breaking-changes-in-plain-words)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/write-a-composable-the-vueuse-way#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-06-25","description":"Build useOnline and a small clipboard composable with the conventions VueUse uses, compare them with the real thing, and publish them as a shared package.","name":"Write a composable the VueUse way","url":"https://vuejs.school/community/blog/write-a-composable-the-vueuse-way","breadcrumb":{"@id":"https://vuejs.school/community/blog/write-a-composable-the-vueuse-way#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/write-a-composable-the-vueuse-way"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/write-a-composable-the-vueuse-way#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-06-25","description":"Build useOnline and a small clipboard composable with the conventions VueUse uses, compare them with the real thing, and publish them as a shared package.","headline":"Write a composable the VueUse way","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~V3JpdGUgYSBjb21wb3NhYmxlIHRoZSBWdWVVc2Ugd2F5IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Write%2Ba%2Bcomposable%2Bthe%2BVueUse%2Bway%2Cdescription_~QnVpbGQgdXNlT25saW5lIGFuZCBhIHNtYWxsIGNsaXBib2FyZCBjb21wb3NhYmxlIHdpdGggdGhlIGNvbnZlbnRpb25zIFZ1ZVVzZSB1c2VzLCBjb21wYXJlIHRoZW0gd2l0aCB0aGUgcmVhbCB0aGluZywgYW5kIHB1Ymxpc2ggdGhlbSBhcyBhIHNoYXJlZCBwYWNrYWdlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy93cml0ZS1hLWNvbXBvc2FibGUtdGhlLXZ1ZXVzZS13YXki%2Cs_NeBKNwJbmq257ZWE.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/write-a-composable-the-vueuse-way#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/write-a-composable-the-vueuse-way#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/write-a-composable-the-vueuse-way#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Write a composable the VueUse way","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~V3JpdGUgYSBjb21wb3NhYmxlIHRoZSBWdWVVc2Ugd2F5IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Write%2Ba%2Bcomposable%2Bthe%2BVueUse%2Bway%2Cdescription_~QnVpbGQgdXNlT25saW5lIGFuZCBhIHNtYWxsIGNsaXBib2FyZCBjb21wb3NhYmxlIHdpdGggdGhlIGNvbnZlbnRpb25zIFZ1ZVVzZSB1c2VzLCBjb21wYXJlIHRoZW0gd2l0aCB0aGUgcmVhbCB0aGluZywgYW5kIHB1Ymxpc2ggdGhlbSBhcyBhIHNoYXJlZCBwYWNrYWdlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy93cml0ZS1hLWNvbXBvc2FibGUtdGhlLXZ1ZXVzZS13YXki%2Cs_NeBKNwJbmq257ZWE.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~V3JpdGUgYSBjb21wb3NhYmxlIHRoZSBWdWVVc2Ugd2F5IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Write%2Ba%2Bcomposable%2Bthe%2BVueUse%2Bway%2Cdescription_~QnVpbGQgdXNlT25saW5lIGFuZCBhIHNtYWxsIGNsaXBib2FyZCBjb21wb3NhYmxlIHdpdGggdGhlIGNvbnZlbnRpb25zIFZ1ZVVzZSB1c2VzLCBjb21wYXJlIHRoZW0gd2l0aCB0aGUgcmVhbCB0aGluZywgYW5kIHB1Ymxpc2ggdGhlbSBhcyBhIHNoYXJlZCBwYWNrYWdlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy93cml0ZS1hLWNvbXBvc2FibGUtdGhlLXZ1ZXVzZS13YXki%2Cs_NeBKNwJbmq257ZWE.png"}]}
```