Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Vue 3.5+ Composition API reference with progressive sub-file loading for components, composables, reactivity, and testing.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
references/composables.md
1# Vue Composables23Reusable functions encapsulating stateful logic using Composition API.45## Core Rules671. **VueUse first** - check [vueuse.org](https://vueuse.org) before writing custom82. **No async composables** - lose lifecycle context when awaited in other composables93. **Top-level only** - never call in event handlers, conditionals, or loops104. **readonly() exports** - protect internal state from external mutation115. **useState() for SSR** - use Nuxt's `useState()` not global refs1213## Quick Reference1415| Pattern | Example |16| --------- | ------------------------------------------------ |17| Naming | `useAuth`, `useCounter`, `useDebounce` |18| State | `const count = ref(0)` |19| Computed | `const double = computed(() => count.value * 2)` |20| Lifecycle | `onMounted(() => ...)`, `onUnmounted(() => ...)` |21| Return | `return { count, increment }` |2223## Structure2425```ts26// composables/useCounter.ts27import { readonly, ref } from 'vue'2829export function useCounter(initialValue = 0) {30const count = ref(initialValue)3132function increment() { count.value++ }33function decrement() { count.value-- }34function reset() { count.value = initialValue }3536return {37count: readonly(count), // readonly if shouldn't be mutated38increment,39decrement,40reset,41}42}43```4445## Naming4647**Always prefix with `use`:** `useAuth`, `useLocalStorage`, `useDebounce`4849**File = function:** `useAuth.ts` exports `useAuth`5051## Best Practices5253**Do:**5455- Return object with named properties (destructuring-friendly)56- Accept options object for configuration57- Use `readonly()` for state that shouldn't mutate58- Handle cleanup (`onUnmounted`, `onScopeDispose`)59- Add JSDoc for complex functions6061## Lifecycle6263Hooks execute in component context:6465```ts66export function useEventListener(target: EventTarget, event: string, handler: Function) {67onMounted(() => target.addEventListener(event, handler))68onUnmounted(() => target.removeEventListener(event, handler))69}70```7172**Watcher cleanup (Vue 3.5+):**7374```ts75import { watch, onWatcherCleanup } from 'vue'7677export function usePolling(url: Ref<string>) {78watch(url, (newUrl) => {79const interval = setInterval(() => {80fetch(newUrl).then(/* ... */)81}, 1000)8283// Cleanup when watcher re-runs or stops84onWatcherCleanup(() => {85clearInterval(interval)86})87})88}89```9091**Benefits of `onWatcherCleanup()`:**9293- Cleaner than returning cleanup functions94- Works with async watchers95- Can be called multiple times in same watcher9697## Async Pattern9899```ts100export function useAsyncData<T>(fetcher: () => Promise<T>) {101const data = ref<T | null>(null)102const error = ref<Error | null>(null)103const loading = ref(false)104105async function execute() {106loading.value = true107error.value = null108try {109data.value = await fetcher()110}111catch (e) {112error.value = e as Error113}114finally {115loading.value = false116}117}118119execute()120return { data, error, loading, refetch: execute }121}122```123124**Data fetching:** Prefer Pinia Colada queries over custom composables.125126## VueUse127128> For VueUse composable reference, use the `vueuse` skill.129130Check VueUse before writing custom composables - most patterns already implemented.131132> **For Nuxt-specific composables** (useFetch, useRequestURL): see `nuxt` skill nuxt-composables.md133134## Advanced Patterns135136### Singleton Composable137138Share state across all components using the same composable:139140```ts141import { createSharedComposable } from '@vueuse/core'142143function useMapControlsBase() {144const mapInstance = ref<Map | null>(null)145const flyTo = (coords: [number, number]) => mapInstance.value?.flyTo(coords)146return { mapInstance, flyTo }147}148149export const useMapControls = createSharedComposable(useMapControlsBase)150```151152### Cancellable Fetch with AbortController153154```ts155export function useSearch() {156let abortController: AbortController | null = null157158watch(query, async (newQuery) => {159abortController?.abort()160abortController = new AbortController()161162try {163const data = await $fetch('/api/search', {164query: { q: newQuery },165signal: abortController.signal,166})167}168catch (e) {169if (e.name !== 'AbortError')170throw e171}172})173}174```175176### Step-Based State Machine177178```ts179export function useSendFlow() {180const step = ref<'input' | 'confirm' | 'success'>('input')181const amount = ref('')182183const next = () => {184if (step.value === 'input')185step.value = 'confirm'186else if (step.value === 'confirm')187step.value = 'success'188}189190return { step, amount, next }191}192```193194### Client-Only Guards195196```ts197export function useUserLocation() {198const location = ref<GeolocationPosition | null>(null)199200if (import.meta.client) {201navigator.geolocation.getCurrentPosition(pos => location.value = pos)202}203204return { location }205}206```207208### Custom Element Composables (Vue 3.5+)209210For custom element components, use built-in helpers:211212```ts213import { useHost, useShadowRoot } from 'vue'214215export function useCustomElement() {216const host = useHost() // Host element reference217const shadowRoot = useShadowRoot() // Shadow DOM root218219onMounted(() => {220console.log('Host:', host)221console.log('Shadow:', shadowRoot)222})223224return { host, shadowRoot }225}226```227228**Available in:**229230- Components using `<script setup>` in custom elements231- Access via `this.$host` in Options API232233### Auto-Save with Debounce234235```ts236export function useAutoSave(content: Ref<string>) {237const hasChanges = ref(false)238239const save = useDebounceFn(async () => {240if (!hasChanges.value)241return242await $fetch('/api/save', { method: 'POST', body: { content: content.value } })243hasChanges.value = false244}, 1000)245246watch(content, () => {247hasChanges.value = true248save()249})250251return { hasChanges }252}253```254255### Tagged Logger256257```ts258import { consola } from 'consola'259260export function useSearch() {261const logger = consola.withTag('search')262263watch(query, (q) => {264logger.info('Query changed:', q)265})266}267```268269## Reactivity Gotchas270271### Ref Unwrapping in Reactive272273Refs auto-unwrap in `reactive()` objects but **NOT** in arrays, Maps, or Sets:274275```ts276// ✅ Object - auto unwraps277const state = reactive({ count: ref(0) })278state.count++ // No .value needed279280// ❌ Array - NO unwrapping281const arr = reactive([ref(1)])282arr[0].value // Need .value!283284// ❌ Map/Set - NO unwrapping285const map = reactive(new Map([['key', ref(1)]]))286map.get('key').value // Need .value!287```288289### watchEffect Conditional Tracking290291Dependencies inside conditional branches are **not tracked** when condition is false:292293```ts294// ❌ Wrong - dep not tracked when condition false295watchEffect(() => {296if (condition.value) {297console.log(dep.value) // Only tracked when condition=true298}299})300301// ✅ Correct - use explicit watch for conditional deps302watch([condition, dep], ([cond, d]) => {303if (cond) console.log(d)304})305```306307### Cleanup Patterns308309**For keep-alive components** - use `onDeactivated`:310311```ts312export function usePolling() {313let interval: NodeJS.Timeout314315onMounted(() => { interval = setInterval(poll, 5000) })316onUnmounted(() => clearInterval(interval))317onDeactivated(() => clearInterval(interval)) // Pause when deactivated318onActivated(() => { interval = setInterval(poll, 5000) }) // Resume319}320```321322**For scope-aware cleanup** - use `tryOnScopeDispose` from VueUse:323324```ts325import { tryOnScopeDispose } from '@vueuse/core'326327export function useEventSource(url: string) {328const source = new EventSource(url)329330// Cleans up when effect scope disposes (component unmount, watcher stop)331tryOnScopeDispose(() => source.close())332333return { source }334}335```336337## Common Mistakes338339**Not using `readonly()` for internal state:**340341```ts342// ❌ Wrong - exposes mutable ref343return { count }344345// ✅ Correct - prevents external mutation346return { count: readonly(count) }347```348349**Missing cleanup:**350351```ts352// ❌ Wrong - listener never removed353onMounted(() => target.addEventListener('click', handler))354355// ✅ Correct - cleanup on unmount356onMounted(() => target.addEventListener('click', handler))357onUnmounted(() => target.removeEventListener('click', handler))358```359