Skip to main content
Technology & EngineeringVue217 lines

Composables

Building reusable custom composables in Vue 3 with proper typing, lifecycle management, and state sharing

Quick Summary18 lines
You are an expert in designing and implementing custom Vue 3 composables that encapsulate reusable stateful logic.

## Key Points

- **Encapsulation** — a composable owns its own reactive state, watchers, and lifecycle hooks
- **Return contract** — composables return a well-typed object of refs, computeds, and functions
- **Lifecycle binding** — composables called inside `setup()` / `<script setup>` are tied to the component's lifecycle
- **Shared vs. scoped state** — state declared inside the function is per-component; state declared outside is shared (singleton)
1. **Name with `use` prefix** — `useMouse`, `useFetch`, `useAuth`. This signals the function uses Composition API internals.
2. **Accept `MaybeRefOrGetter` for inputs** — use `toValue()` to unwrap, allowing callers to pass plain values, refs, or getters.
3. **Return refs, not reactive** — returning individual refs lets callers destructure without losing reactivity. Wrap mutable state with `readonly()` when consumers should not mutate.
4. **Handle cleanup** — register `onUnmounted` or use `onCleanup` in `watchEffect` for any subscriptions, timers, or event listeners.
5. **Keep composables focused** — one concern per composable. Compose multiple composables together rather than building monoliths.
6. **Co-locate with usage** — place composables in a `composables/` directory. If only one component uses it, co-locate next to that component.
7. **Type the return value** — define an explicit return interface for complex composables so consumers have clear documentation.
- **Unintended shared state** — declaring refs outside the function makes them singletons. This is a feature when intended (e.g., auth) but a bug when each component should have its own copy.
skilldb get vue-skills/ComposablesFull skill: 217 lines
Paste into your CLAUDE.md or agent config

Custom Composables — Vue.js

You are an expert in designing and implementing custom Vue 3 composables that encapsulate reusable stateful logic.

Core Philosophy

Composables are the fundamental unit of logic reuse in Vue 3, replacing mixins and renderless components with something far more transparent. The guiding principle is encapsulation with explicit contracts: a composable owns its reactive state, watchers, and lifecycle hooks internally, while exposing a clear, typed return object that consumers can destructure and use without understanding the internals. This mirrors the Unix philosophy of small, focused tools that compose well together.

Thinking in composables means thinking in terms of concerns, not components. A component might need mouse tracking, data fetching, and debounced search — each of these is a discrete concern that deserves its own composable. When you extract logic into a composable, you make it testable in isolation, reusable across components, and readable at the call site. The composition happens naturally in <script setup>, where calling multiple use* functions reads like a declaration of what the component needs.

State ownership is the design decision that matters most. Declaring reactive state inside the composable function gives each caller its own copy; declaring it outside creates a singleton shared across the application. This is not a default to accept blindly — it is an architectural choice that should be made intentionally for each composable based on whether the state represents per-instance data or global application state.

Anti-Patterns

  • The Kitchen-Sink Composable — cramming unrelated concerns into a single useApp() or useUtils() composable that returns dozens of values. This defeats the purpose of composables and makes dependency tracking opaque. Split by concern.

  • Async Top-Level Await in Composables — using await at the top level of a composable causes lifecycle hooks registered after the await to lose their component instance binding. Always register all lifecycle hooks synchronously before any await.

  • Leaking Internal Refs — returning mutable refs that consumers should not modify. Wrap shared state with readonly() and provide explicit mutation methods to maintain encapsulation and prevent hidden coupling between components.

  • Composable Calls Outside Setup — invoking composables in utility functions, event handlers, or setTimeout callbacks. Lifecycle hooks inside the composable silently fail because there is no active component instance. Composables must be called synchronously during setup().

  • Implicit Singleton Bugs — declaring refs outside the function for convenience without realizing every component that calls the composable shares the same state. Unless global sharing is the explicit goal, always declare state inside the function body.

Overview

Composables are functions that leverage Vue's Composition API to encapsulate and reuse stateful logic across components. By convention they are named use* and return reactive state, computed values, and methods. They are the primary code-reuse mechanism in Vue 3, replacing mixins and renderless components.

Core Concepts

  • Encapsulation — a composable owns its own reactive state, watchers, and lifecycle hooks
  • Return contract — composables return a well-typed object of refs, computeds, and functions
  • Lifecycle binding — composables called inside setup() / <script setup> are tied to the component's lifecycle
  • Shared vs. scoped state — state declared inside the function is per-component; state declared outside is shared (singleton)

Implementation Patterns

Basic Composable

// composables/useMouse.ts
import { ref, onMounted, onUnmounted } from 'vue'

export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  function update(event: MouseEvent) {
    x.value = event.pageX
    y.value = event.pageY
  }

  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  return { x, y }
}

Async Data Fetching Composable

// composables/useFetch.ts
import { ref, watchEffect, toValue, type MaybeRefOrGetter } from 'vue'

interface UseFetchReturn<T> {
  data: Ref<T | null>
  error: Ref<Error | null>
  isLoading: Ref<boolean>
  refresh: () => Promise<void>
}

export function useFetch<T = unknown>(
  url: MaybeRefOrGetter<string>
): UseFetchReturn<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref<Error | null>(null)
  const isLoading = ref(false)

  async function doFetch() {
    isLoading.value = true
    error.value = null
    try {
      const res = await fetch(toValue(url))
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      data.value = await res.json()
    } catch (e) {
      error.value = e instanceof Error ? e : new Error(String(e))
    } finally {
      isLoading.value = false
    }
  }

  watchEffect(() => {
    // toValue(url) inside doFetch is tracked here
    doFetch()
  })

  return { data, error, isLoading, refresh: doFetch }
}

Composable Accepting Reactive Input

// composables/useDebounce.ts
import { ref, watch, type Ref } from 'vue'

export function useDebounce<T>(source: Ref<T>, delay = 300): Ref<T> {
  const debounced = ref(source.value) as Ref<T>

  let timeout: ReturnType<typeof setTimeout>
  watch(source, (val) => {
    clearTimeout(timeout)
    timeout = setTimeout(() => {
      debounced.value = val
    }, delay)
  })

  return debounced
}

Shared Singleton State

// composables/useAuth.ts
import { ref, computed, readonly } from 'vue'

interface User {
  id: string
  name: string
  role: string
}

// Declared outside — shared across all components that call useAuth()
const currentUser = ref<User | null>(null)
const token = ref<string | null>(null)

export function useAuth() {
  const isAuthenticated = computed(() => !!token.value)
  const isAdmin = computed(() => currentUser.value?.role === 'admin')

  async function login(email: string, password: string) {
    const res = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    })
    const data = await res.json()
    token.value = data.token
    currentUser.value = data.user
  }

  function logout() {
    token.value = null
    currentUser.value = null
  }

  return {
    user: readonly(currentUser),
    isAuthenticated,
    isAdmin,
    login,
    logout,
  }
}

Composable with Cleanup

// composables/useEventSource.ts
import { ref, onUnmounted } from 'vue'

export function useEventSource(url: string) {
  const data = ref<string>('')
  const status = ref<'connecting' | 'open' | 'closed'>('connecting')

  const es = new EventSource(url)

  es.onopen = () => { status.value = 'open' }
  es.onmessage = (event) => { data.value = event.data }
  es.onerror = () => {
    status.value = 'closed'
    es.close()
  }

  onUnmounted(() => es.close())

  return { data, status }
}

Best Practices

  1. Name with use prefixuseMouse, useFetch, useAuth. This signals the function uses Composition API internals.
  2. Accept MaybeRefOrGetter for inputs — use toValue() to unwrap, allowing callers to pass plain values, refs, or getters.
  3. Return refs, not reactive — returning individual refs lets callers destructure without losing reactivity. Wrap mutable state with readonly() when consumers should not mutate.
  4. Handle cleanup — register onUnmounted or use onCleanup in watchEffect for any subscriptions, timers, or event listeners.
  5. Keep composables focused — one concern per composable. Compose multiple composables together rather than building monoliths.
  6. Co-locate with usage — place composables in a composables/ directory. If only one component uses it, co-locate next to that component.
  7. Type the return value — define an explicit return interface for complex composables so consumers have clear documentation.

Common Pitfalls

  • Calling composables outside of setup() — lifecycle hooks inside composables only work when called during component setup. Calling useMouse() in a plain utility function silently skips onMounted/onUnmounted.
  • Unintended shared state — declaring refs outside the function makes them singletons. This is a feature when intended (e.g., auth) but a bug when each component should have its own copy.
  • Forgetting to track reactive inputs — if the composable receives a ref but reads .value only once (not inside a watch or computed), it will not react to changes.
  • Returning raw reactive objects — if you return reactive({...}) the caller cannot destructure it. Return individual refs or use toRefs().
  • Async composables losing context — composables that await at the top level lose the component instance. Register all lifecycle hooks before any await.

Install this skill directly: skilldb add vue-skills

Get CLI access →