Back
Vue 3

Vue 3 Composables: Reuse Logic Like a Pro

Learn how to create Vue 3 composables to reuse logic elegantly. Patterns, best practices, and real-world examples to master the Composition API.

Francisco ZapataWritten by Francisco Zapata
December 15, 202512 min read
Vue 3 Composables: Reuse Logic Like a Pro

Composables are one of the most powerful features of Vue 3 and the Composition API. They allow you to extract and reuse stateful logic across components in a clean, maintainable way. If you are coming from Vue 2, think of them as the natural evolution of mixins—without their limitations.

What Is a Composable?

A composable is a function that leverages Vue's Composition API to encapsulate and reuse stateful logic. Unlike a simple utility function, a composable can use refs, computed properties, watchers, and lifecycle hooks.

// A basic composable
import { ref, onMounted, onUnmounted } from 'vue'

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

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

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

  return { x, y }
}

The convention is to name composables with the use prefix, such as useMousePosition, useFetch, or useLocalStorage. This convention makes them easy to identify in your code.

Composables vs Mixins: Why Switch?

In Vue 2, mixins were the primary way to reuse logic. However, they came with several issues:

Mixin Problems:
  • Name collisions: If two mixins define the same property, one silently overrides the other.
  • Opaque origin: It is hard to tell which mixin a property or method comes from.
  • Implicit coupling: Mixins can depend on component properties without declaring it.
Composable Advantages:
  • No name collisions: You use destructuring and can rename variables easily.
  • Clear origin: You know exactly where each variable comes from.
  • Explicit dependencies: Parameters declare exactly what the composable needs.
// With mixins (Vue 2) - Where does "count" come from?
export default {
  mixins: [counterMixin, analyticsMixin],
  mounted() {
    console.log(this.count) // Which mixin?
  }
}

// With composables (Vue 3) - Clear origin
import { useCounter } from '@/composables/useCounter'
import { useAnalytics } from '@/composables/useAnalytics'

const { count, increment } = useCounter()
const { trackEvent } = useAnalytics()

Common Composable Patterns

1. useCounter - Simple State

The most basic example to understand the pattern:

import { ref, computed } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  const isPositive = computed(() => count.value > 0)

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

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

2. useFetch - HTTP Requests

A more complex composable handling loading states and errors:

import { ref, watchEffect, toValue } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(false)

  async function execute() {
    loading.value = true
    error.value = null
    data.value = null

    try {
      const resolvedUrl = toValue(url)
      const response = await fetch(resolvedUrl)

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }

      data.value = await response.json()
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }

  watchEffect(() => {
    execute()
  })

  return { data, error, loading, refetch: execute }
}

Usage in a component:

<script setup>
import { useFetch } from '@/composables/useFetch'

const { data: users, loading, error } = useFetch('https://api.example.com/users')
</script>

<template>
  <div v-if="loading">Loading...</div>
  <div v-else-if="error">Error: {{ error }}</div>
  <ul v-else>
    <li v-for="user in users" :key="user.id">{{ user.name }}</li>
  </ul>
</template>

3. useLocalStorage - Persistence

import { ref, watch } from 'vue'

export function useLocalStorage(key, defaultValue) {
  const stored = localStorage.getItem(key)
  const data = ref(stored ? JSON.parse(stored) : defaultValue)

  watch(data, (newValue) => {
    localStorage.setItem(key, JSON.stringify(newValue))
  }, { deep: true })

  return data
}

4. useDebounce - Execution Control

import { ref, watch } from 'vue'

export function useDebounce(value, delay = 300) {
  const debouncedValue = ref(value.value)
  let timeout

  watch(value, (newVal) => {
    clearTimeout(timeout)
    timeout = setTimeout(() => {
      debouncedValue.value = newVal
    }, delay)
  })

  return debouncedValue
}

Composing Composables

One of the greatest advantages is that composables can use other composables. This allows you to build complex functionality by combining simple pieces:

import { computed } from 'vue'
import { useFetch } from './useFetch'
import { useDebounce } from './useDebounce'

export function useUserSearch(searchQuery) {
  const debouncedQuery = useDebounce(searchQuery, 500)
  const url = computed(() =>
    `https://api.example.com/users?q=${debouncedQuery.value}`
  )

  const { data, loading, error } = useFetch(url)

  return {
    results: data,
    loading,
    error
  }
}

Best Practices

1. Accept Refs as Parameters

To make your composable more flexible, accept both plain values and refs using toValue():

import { toValue } from 'vue'

export function useFeature(maybeRef) {
  const resolved = toValue(maybeRef) // works with ref or plain value
}

2. Always Return Refs

Always return objects with refs to maintain reactivity when destructuring:

// Correct - maintains reactivity when destructuring
return { count, increment }

// Avoid - loses reactivity
return { count: count.value, increment }

3. Handle Cleanup

If your composable creates side effects (event listeners, intervals, subscriptions), clean them up:

import { onUnmounted } from 'vue'

export function useInterval(callback, interval) {
  const id = setInterval(callback, interval)
  onUnmounted(() => clearInterval(id))
}

4. Be SSR-Aware with Side Effects

If your app uses Server-Side Rendering, avoid accessing the DOM or browser APIs outside of onMounted:

export function useWindowSize() {
  const width = ref(0)
  const height = ref(0)

  onMounted(() => {
    width.value = window.innerWidth
    height.value = window.innerHeight
    window.addEventListener('resize', update)
  })
  // ...
}

5. Document Your Composables

A good composable has a clear interface. Use TypeScript to define return types:

interface UseCounterReturn {
  count: Ref<number>
  increment: () => void
  decrement: () => void
  reset: () => void
}

export function useCounter(initial = 0): UseCounterReturn {
  // implementation...
}

Real-World Example: useAuth

To wrap up, here is a real composable you might use in production for handling authentication:

import { ref, computed, readonly } from 'vue'

const user = ref(null)
const token = ref(localStorage.getItem('auth_token'))

export function useAuth() {
  const isAuthenticated = computed(() => !!token.value)

  async function login(credentials) {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(credentials)
    })

    const data = await response.json()
    token.value = data.token
    user.value = data.user
    localStorage.setItem('auth_token', data.token)
  }

  function logout() {
    token.value = null
    user.value = null
    localStorage.removeItem('auth_token')
  }

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

This composable uses shared state (the refs are outside the function), making it a simple yet effective store.

Conclusion

Composables transform how we structure Vue 3 applications. They enable cleaner, testable, and reusable code. Start by extracting repeated logic from your components into composables and watch your code become more maintainable. The key is keeping them focused on a single responsibility and following community conventions.

Comments (0)

Leave a comment

Be the first to comment