Vue Composables: The Pattern That Replaced Mixins Forever

Mixins are gone. Composables are how Vue 3 shares logic — reusable, testable, and fully typed. Here’s how to write composables properly, from useFetch to useAuth to useDebouncedRef.


Vue 2 mixins were a clever solution to a real problem: sharing stateful logic between components. But they had serious flaws — implicit dependencies, namespace collisions, and no way for TypeScript to understand where a property came from. Vue 3 composables fix all of that, and once you understand how they work, you’ll wonder how you ever got by without them.

A composable is a function that uses Vue’s Composition API to encapsulate and reuse stateful logic. It’s just a function — no magic, no registration, no special syntax. You call it, it returns reactive state and methods, and TypeScript infers the types automatically.

This post goes beyond the basics. We’ll build real composables that you’d actually use in production: useFetch, useAuth, useDebouncedRef, useIntersectionObserver, and more — with proper TypeScript, lifecycle awareness, and cleanup.


Why Mixins Failed (and What They Got Wrong)

To understand why composables are so much better, it helps to be specific about what mixins got wrong. The problems weren’t trivial — they were architectural.

ProblemMixinsComposables
Source of a propertyUnclear — could be any mixinExplicit — comes from the function call
Namespace collisionsSilent merge, hard to debugImpossible — local variables only
TypeScript supportNone — all properties are implicitFull — return types inferred automatically
Composing multipleFragile — merging conflictsTrivial — call multiple functions
Testing in isolationRequires a full componentPlain function — no component needed

The core insight: Composables are just functions. They follow every rule you already know about functions — explicit inputs, explicit outputs, no side effects on global state, testable in isolation. Mixins broke all of those rules.


Anatomy of a Composable

A composable is a function that follows a naming convention (use prefix), uses Vue’s reactive APIs internally, and returns whatever the consumer needs.

typescript

import { ref, computed } from 'vue'

export function useCounter(initial = 0) {
  const count   = ref(initial)
  const doubled = computed(() => count.value * 2)

  function increment() { count.value++ }
  function decrement() { count.value-- }
  function reset()     { count.value = initial }

  return { count, doubled, increment, decrement, reset }
}

vue

<!-- Usage in a component -->
<script setup lang="ts">
import { useCounter } from '@/composables/useCounter'

const { count, doubled, increment, reset } = useCounter(10)
</script>

<template>
  <p>Count: {{ count }} | Doubled: {{ doubled }}</p>
  <button @click="increment">+</button>
  <button @click="reset">Reset</button>
</template>

Three things to notice: the function name starts with use. The reactive state (ref, computed) lives inside the function. And the return value is explicit — the component only gets what you choose to expose.


useFetch: Async Data with Full Lifecycle Control

The canonical composable example — and the one most teams need immediately. A production-grade useFetch handles loading state, errors, abort on unmount, and TypeScript generics.

typescript

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

interface UseFetchReturn<T> {
  data:    Ref<T | null>
  error:   Ref<string | null>
  loading: Ref<boolean>
  refresh: () => void
}

export function useFetch<T>(url: Ref<string> | string): UseFetchReturn<T> {
  const data    = ref<T | null>(null)
  const error   = ref<string | null>(null)
  const loading = ref(false)
  let   trigger = ref(0)

  // watchEffect re-runs when url or trigger changes
  watchEffect(async (onCleanup) => {
    const controller = new AbortController()
    onCleanup(() => controller.abort())  // abort on unmount or re-run

    const endpoint = typeof url === 'string' ? url : url.value
    trigger.value  // touch trigger to create dependency

    loading.value = true
    error.value   = null

    try {
      const res = await fetch(endpoint, { signal: controller.signal })
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      data.value = await res.json() as T
    } catch (err) {
      if ((err as Error).name !== 'AbortError')
        error.value = (err as Error).message
    } finally {
      loading.value = false
    }
  })

  return {
    data,
    error,
    loading,
    refresh: () => trigger.value++,
  }
}

vue

<!-- Usage -->
<script setup lang="ts">
interface Post { id: number; title: string; body: string }

const { data: post, loading, error, refresh } =
  useFetch<Post>('/api/posts/1')
</script>

<template>
  <div v-if="loading">Loading…</div>
  <div v-else-if="error">Error: {{ error }}</div>
  <article v-else>
    <h1>{{ post?.title }}</h1>
    <p>{{ post?.body }}</p>
  </article>
  <button @click="refresh">Reload</button>
</template>

Key patterns: watchEffect with onCleanup for abort controller teardown, a trigger ref to enable manual refresh, and a generic type parameter so TypeScript knows exactly what data contains.


useDebouncedRef: Reactive with a Delay

Debouncing is one of the most common UI needs — search inputs, form validation, resize handlers. Here’s a composable that wraps a ref with a configurable debounce delay.

typescript

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

export function useDebouncedRef<T>(
  value: T,
  delay = 300
): { immediate: Ref<T>; debounced: Ref<T> } {
  const immediate = ref<T>(value) as Ref<T>
  const debounced = ref<T>(value) as Ref<T>

  let timer: ReturnType<typeof setTimeout>

  watch(immediate, (val) => {
    clearTimeout(timer)
    timer = setTimeout(() => { debounced.value = val }, delay)
  })

  return { immediate, debounced }
}

vue

<!-- Usage — search input that debounces before querying -->
<script setup lang="ts">
const { immediate: searchInput, debounced: searchQuery } =
  useDebouncedRef('', 400)

// searchQuery only updates 400ms after the user stops typing
const { data: results } = useFetch(computed(
  () => `/api/search?q=${searchQuery.value}`
))
</script>

<template>
  <input v-model="searchInput" placeholder="Search…" />
</template>

useLocalStorage: Persistent Reactive State

State that survives a page refresh — with reactivity. This composable syncs a ref with localStorage bidirectionally, handling JSON serialisation transparently.

typescript

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

export function useLocalStorage<T>(
  key:          string,
  defaultValue: T
): Ref<T> {
  const stored  = localStorage.getItem(key)
  const initial = stored ? (JSON.parse(stored) as T) : defaultValue
  const state   = ref<T>(initial) as Ref<T>

  // Write to localStorage whenever state changes
  watch(
    state,
    (val) => localStorage.setItem(key, JSON.stringify(val)),
    { deep: true }
  )

  return state
}

typescript

// Usage
const theme      = useLocalStorage<'light' | 'dark'>('theme', 'light')
const sidebarOpen = useLocalStorage('sidebar', true)

// Changing theme.value persists automatically — no manual save
theme.value = 'dark'

Make it a singleton: If multiple components need the same localStorage key, wrap the call in a module-level export — export const useTheme = () => useLocalStorage('theme', 'light'). Vue’s reactivity ensures all consumers update when any one changes.


useIntersectionObserver: Lazy Loading and Scroll Tracking

The Intersection Observer API is the right tool for lazy-loading images, triggering animations on scroll, and implementing infinite scroll — but it needs careful cleanup. A composable is the perfect wrapper.

typescript

// composables/useIntersectionObserver.ts
import { ref, onMounted, onUnmounted, type Ref } from 'vue'

interface UseIntersectionOptions {
  threshold?:  number
  rootMargin?: string
  once?:       boolean  // stop observing after first intersection
}

export function useIntersectionObserver(
  target:  Ref<Element | null>,
  options: UseIntersectionOptions = {}
) {
  const { threshold = 0, rootMargin = '0px', once = false } = options
  const isVisible = ref(false)
  let   observer: IntersectionObserver | null = null

  onMounted(() => {
    if (!target.value) return

    observer = new IntersectionObserver(
      ([entry]) => {
        isVisible.value = entry.isIntersecting
        if (entry.isIntersecting && once) {
          observer?.disconnect()
        }
      },
      { threshold, rootMargin }
    )
    observer.observe(target.value)
  })

  onUnmounted(() => observer?.disconnect())

  return { isVisible }
}

vue

<!-- Usage — animate in when element enters viewport -->
<script setup lang="ts">
import { ref } from 'vue'

const cardRef    = ref<null | HTMLElement>(null)
const { isVisible } = useIntersectionObserver(cardRef, {
  threshold: 0.2,
  once: true
})
</script>

<template>
  <div
    ref="cardRef"
    :class="['card', { 'card--visible': isVisible }]"
  >
    Fade in on scroll
  </div>
</template>

useAuth: Application-Wide Auth State

Authentication state is a classic example of logic that needs to be shared across many components — the nav, the route guard, the profile page, protected API calls. Composables handle this elegantly using a module-level singleton.

typescript

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

interface User {
  id:    string
  name:  string
  email: string
  role:  'admin' | 'user'
}

// Module-level state — shared across ALL components
const user    = ref<User | null>(null)
const loading = ref(false)

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

  async function login(email: string, password: string) {
    loading.value = true
    try {
      const res = await fetch('/api/auth/login', {
        method:  'POST',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify({ email, password }),
      })
      if (!res.ok) throw new Error('Login failed')
      user.value = await res.json()
    } finally {
      loading.value = false
    }
  }

  async function logout() {
    await fetch('/api/auth/logout', { method: 'POST' })
    user.value = null
  }

  async function fetchMe() {
    const res  = await fetch('/api/auth/me')
    user.value = res.ok ? await res.json() : null
  }

  return {
    user:            readonly(user),    // prevent external mutation
    loading:         readonly(loading),
    isAuthenticated,
    isAdmin,
    login,
    logout,
    fetchMe,
  }
}

Why module-level state? When user is declared outside the useAuth function, it’s a singleton — a single ref shared across every component that calls useAuth(). When one component logs in, every other component that’s watching user updates automatically. This is the Vue composable equivalent of a global store, without Pinia or Vuex.


useEventListener: Browser Events Without Memory Leaks

Forgetting to remove event listeners is a classic source of memory leaks in Vue components. This composable makes proper cleanup automatic.

typescript

// composables/useEventListener.ts
import { onMounted, onUnmounted, type Ref } from 'vue'

type EventTarget = Window | Document | HTMLElement | null

export function useEventListener<K extends keyof WindowEventMap>(
  target:   EventTarget | Ref<EventTarget>,
  event:    K,
  handler:  (evt: WindowEventMap[K]) => void,
  options?: AddEventListenerOptions
) {
  const getTarget = () =>
    target && 'value' in target ? target.value : target

  onMounted(()   => getTarget()?.addEventListener(event, handler as EventListener, options))
  onUnmounted(() => getTarget()?.removeEventListener(event, handler as EventListener, options))
}

typescript

// Usage — keyboard shortcut handler
useEventListener(window, 'keydown', (e) => {
  if (e.key === 'Escape')           closeModal()
  if (e.metaKey && e.key === 'k')   openCommandPalette()
})

// Usage — scroll tracker
const scrollY = ref(0)
useEventListener(window, 'scroll', () => {
  scrollY.value = window.scrollY
})

Composable Conventions and Best Practices

1. Always prefix with “use”

The use prefix is a Vue ecosystem convention that signals “this function uses reactive APIs and should be called inside setup() or <script setup>“. It’s also how Vue DevTools and linting rules identify composables.

2. Return refs, not raw values

If you return count.value instead of count, the consumer gets a plain number — not reactive. Always return the ref itself.

typescript

// ✗ Returns a number — reactivity is lost
return { count: count.value }

// ✓ Returns a Ref<number> — reactive everywhere
return { count }

// ✓ Or use toRefs() for reactive objects
const state = reactive({ count: 0, name: '' })
return toRefs(state)  // { count: Ref<number>, name: Ref<string> }

3. Use readonly() for internally managed state

If a composable manages its own state, wrap returned refs in readonly() to prevent consumers from writing to them directly. The useAuth example does this with user and loading.

4. Always clean up side effects

If a composable adds an event listener, creates a timer, opens a WebSocket, or starts an observer — it must clean it up in onUnmounted. The cleanup responsibility is yours.

5. Accept reactive arguments

Design composables to accept both Ref<T> and plain T where possible. The useFetch example accepts Ref<string> | string — this makes it work with both static URLs and reactive ones without forcing the consumer to wrap things in refs unnecessarily.


Testing Composables in Isolation

One of the underrated benefits of composables is how testable they are. Because they’re just functions, you can test them without mounting a component.

typescript

// useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('initialises with the given value', () => {
    const { count } = useCounter(5)
    expect(count.value).toBe(5)
  })

  it('increments correctly', () => {
    const { count, increment } = useCounter(0)
    increment()
    increment()
    expect(count.value).toBe(2)
  })

  it('resets to the initial value', () => {
    const { count, increment, reset } = useCounter(3)
    increment()
    reset()
    expect(count.value).toBe(3)
  })

  it('computes doubled correctly', () => {
    const { count, doubled, increment } = useCounter(4)
    expect(doubled.value).toBe(8)
    increment()
    expect(doubled.value).toBe(10)
  })
})

For composables with lifecycle hooks: wrap the call in withSetup() from @vue/test-utils, or mount a minimal wrapper component. But for composables that only use ref, computed, and watch, you can call them directly in tests — no component needed.


Organising Composables in a Real Project

src/composables/
├── core/                     # generic, reusable utilities
│   ├── useFetch.ts
│   ├── useDebouncedRef.ts
│   ├── useLocalStorage.ts
│   ├── useEventListener.ts
│   └── useIntersectionObserver.ts
│
├── auth/                     # authentication domain
│   └── useAuth.ts
│
├── ui/                       # UI state composables
│   ├── useModal.ts
│   ├── useToast.ts
│   └── useTheme.ts
│
└── index.ts                  # barrel export

Don’t over-extract. Not every piece of component logic needs to be a composable. If logic is only used in one component and isn’t likely to be reused, keep it in <script setup>. Extract to a composable when you find yourself copying the same reactive pattern across two or more components.


Final Thoughts

Composables are the right abstraction for sharing logic in Vue 3 — not because they’re a new feature, but because they follow rules that make code understandable at scale. Explicit inputs. Explicit outputs. No implicit namespace. No mystery properties. TypeScript can see everything.

The patterns in this post — useFetch, useDebouncedRef, useLocalStorage, useIntersectionObserver, useAuth, useEventListener — cover the vast majority of real-world composable use cases. Once you have a library of well-tested composables, building Vue components becomes a matter of composing them together, not re-implementing the same logic in every file.

Start with one. Extract it from a component you’ve already written. Test it. Share it. That’s how the habit builds.

Leave a Reply

Your email address will not be published. Required fields are marked *