Modern Vue development demands reusable, maintainable code patterns. Composables are Vue 3's answer to sharing stateful logic across components elegantly. This guide walks through building your first composable and mastering production-ready patterns used by VueUse and enterprise applications worldwide. By mastering composables, you'll write cleaner code that's easier to test, maintain, and scale across your Vue projects. Our team of Vue.js development experts specializes in architecting composable-based solutions that scale with your business needs.
Key concepts covered in this guide
Composition API Foundations
Understand ref, reactive, computed, and watch primitives for building composables
Your First Composable
Build a useCounter composable step by step with production-ready patterns
Async State Management
Handle loading, success, and error states cleanly with useAsyncState
SSR Safety
Guard browser APIs for server-side rendering to prevent runtime errors
Cleanup Patterns
Prevent memory leaks with proper cleanup using onUnmounted hooks
Production Best Practices
Apply patterns from VueUse codebase for enterprise-grade composables
What Are Vue Composables?
Composables are JavaScript functions that leverage Vue 3's Composition API to encapsulate and reuse stateful logic. A composable can manage reactive state, computed properties, watchers, and lifecycle hooks, then expose them as a clean API for components to consume.
The naming convention matters: composable functions start with "use" (useCounter, useFetch, useWindowSize) making them instantly recognizable as reusable logic. This pattern has become so widespread that VueUse, the de facto standard library, provides over 200 production-ready composables for common tasks.
Composables vs Mixins: A Clear Advantage
Mixin-based code sharing suffered from several fundamental problems. When a component used multiple mixins, properties from different mixins merged into a single object, making it unclear which property came from where. Naming collisions were common, and refactoring became risky since you couldn't easily tell what dependencies a mixin created.
Composables solve these issues entirely. Each composable explicitly returns an object with named properties, so components always know exactly what they're receiving. TypeScript inference works naturally, providing autocomplete for all returned properties. Dependencies are clear--any ref or computed used inside a composable is part of its public API, not hidden inside.
// Mixin approach (Vue 2) - problematic
// Hidden properties, unclear origin
export default {
mixins: [userMixin, apiMixin, analyticsMixin]
// Which property came from where?
}
// Composable approach (Vue 3) - clear and explicit
const { user, isAuthenticated } = useAuth()
const { fetchData, isLoading } = useApi()
const { track, identify } = useAnalytics()
// Crystal clear what each function returns
For teams building modern Vue applications, composables provide the architectural foundation for scalable codebases. When you're ready to implement composable-based architecture at scale, our web development services can help you build maintainable Vue applications that grow with your business.
The Composition API Foundation
Before building composables, you need to understand the building blocks of Vue's reactivity system. These primitives form the foundation of every composable you'll create.
ref vs shallowRef: Choosing Reactivity Depth
The choice between ref and shallowRef significantly impacts your composable's behavior and performance. Use ref when you need deep reactivity--changes to nested properties trigger updates. Use shallowRef when you only replace the entire value, avoiding the performance overhead of deep proxying.
// ref - deep reactivity, triggers on nested changes
const form = ref({
name: '',
email: '',
preferences: { theme: 'light' }
})
form.value.name = 'New Name' // Triggers update
form.value.preferences.theme = 'dark' // Triggers update
// shallowRef - shallow reactivity, only .value replacement triggers
const formData = shallowRef({
name: '',
email: ''
})
formData.value = { name: 'New', email: '[email protected]' } // Triggers update
formData.value.name = 'Changed' // Does NOT trigger update
This distinction matters enormously for performance. Large objects that you replace wholesale rather than mutate benefit from shallowRef's lighter touch. For composables managing API responses or form data that gets entirely replaced, shallowRef is often the better choice. For teams focused on Vue.js performance optimization, understanding this distinction is crucial for building fast, responsive applications.
reactive and computed: Derived State Management
The reactive function creates a reactive proxy of an object, while computed creates read-only derived values. These work together to build composable state machines.
import { reactive, computed } from 'vue'
interface TodoState {
items: { id: number; text: string; done: boolean }[]
filter: 'all' | 'active' | 'completed'
}
export function useTodos() {
const state = reactive<TodoState>({
items: [],
filter: 'all'
})
const filteredItems = computed(() => {
if (state.filter === 'all') return state.items
if (state.filter === 'active') return state.items.filter(i => !i.done)
return state.items.filter(i => i.done)
})
const activeCount = computed(() => state.items.filter(i => !i.done).length)
return {
state,
filteredItems,
activeCount
}
}
watch and watchEffect: Reacting to Changes
Both watch and watchEffect track dependencies and run code when those dependencies change. The difference is in explicitness--watch requires specifying the source, while watchEffect runs immediately and tracks dependencies automatically.
Building Your First Composable: useCounter
Let's build a useCounter composable that demonstrates the core patterns. This simple example introduces concepts you'll use in every composable.
The Basic Implementation
// composables/useCounter.ts
import { ref } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const increment = () => count.value++
const decrement = () => count.value--
const reset = () => { count.value = initialValue }
return { count, increment, decrement, reset }
}
Using the Composable in Components
<script setup lang="ts">
import { useCounter } from '@/composables/useCounter'
const { count, increment, decrement, reset } = useCounter(10)
</script>
<template>
<div class="counter">
<p>Count: {{ count }}</p>
<button @click="decrement">-</button>
<button @click="reset">Reset</button>
<button @click="increment">+</button>
</div>
</template>
Each component calling useCounter gets its own independent count state. Composables don't create shared global state--they provide reusable logic that components instantiate independently.
Adding Options for Flexibility
Production composables often accept options that customize behavior. Here's an enhanced useCounter with configuration:
export interface UseCounterOptions {
min?: number
max?: number
step?: number
}
export function useCounter(
initialValue = 0,
options: UseCounterOptions = {}
): { count: Ref<number>; increment: () => void; decrement: () => void; reset: () => void; set: (value: number) => void } {
const { min = -Infinity, max = Infinity, step = 1 } = options
const count = ref(initialValue)
const increment = () => { count.value = Math.min(count.value + step, max) }
const decrement = () => { count.value = Math.max(count.value - step, min) }
const reset = () => { count.value = initialValue }
const set = (value: number) => { count.value = Math.min(Math.max(value, min), max) }
return { count, increment, decrement, reset, set }
}
TypeScript interfaces make your composable self-documenting and provide excellent developer experience through autocomplete. For teams adopting TypeScript in Vue projects, this level of type safety is essential for maintainable codebases.
Async State Management with Composables
Real applications need to handle async operations--API calls, database queries, file reads. Composables excel at encapsulating this complexity with clean loading, success, and error states.
Designing useAsyncState
A robust async composable needs to track multiple states simultaneously:
// composables/useAsyncState.ts
import { shallowRef } from 'vue'
import type { Ref } from 'vue'
export interface AsyncState<T> {
data: Ref<T | null>
error: Ref<Error | null>
isPending: Ref<boolean>
isResolved: Ref<boolean>
isRejected: Ref<boolean>
}
export function useAsyncState<T>(promise: Promise<T>): AsyncState<T> {
const data = shallowRef<T | null>(null)
const error = shallowRef<Error | null>(null)
const isPending = shallowRef(false)
const isResolved = shallowRef(false)
const isRejected = shallowRef(false)
isPending.value = true
promise
.then((result) => {
data.value = result
isPending.value = false
isResolved.value = true
})
.catch((err) => {
error.value = err
isPending.value = false
isRejected.value = true
})
return { data, error, isPending, isResolved, isRejected }
}
Using shallowRef avoids deep reactivity overhead for async data that gets entirely replaced.
Complete Fetch Wrapper Example
For real applications, wrap the fetch API to handle HTTP errors properly (fetch doesn't throw on 4xx/5xx responses):
// utils/fetchWrapper.ts
export async function fetchJson<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(url, { ...options, headers: { 'Content-Type': 'application/json', ...options.headers } })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json() as Promise<T>
}
Usage in Components
<script setup lang="ts">
import { ref, unref } from 'vue'
import { fetchJson } from '@/utils/fetchWrapper'
import { useAsyncState } from '@/composables/useAsyncState'
interface User { id: number; name: string; email: string }
const userId = ref(1)
async function loadUser() {
return fetchJson<User>(`https://api.example.com/users/${unref(userId)}`)
}
const { data: user, isPending, error, isRejected } = useAsyncState(loadUser())
</script>
<template>
<div v-if="isPending">Loading...</div>
<div v-else-if="isRejected" class="error">{{ error?.message }}</div>
<div v-else-if="user">{{ user.name }}</div>
</template>
The composable cleanly separates async concerns from UI concerns. Your template only needs to check isPending, isRejected, and data--no complex conditional logic scattered throughout. When building [robust Vue applications](/services/web-development/), proper async state management is essential for a smooth user experience.
SSR Safety: Handling Browser APIs
Server-side rendering introduces a critical consideration: browser APIs like window, document, and localStorage don't exist on the server. Composables accessing these APIs must guard against SSR errors.
The SSR Challenge
When running in a Node.js server environment, accessing browser globals will throw ReferenceErrors. Your composables must check the environment before accessing these APIs.
Safe Browser API Access Pattern
// composables/useWindowSize.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useWindowSize() {
const size = ref({
width: typeof window !== 'undefined' ? window.innerWidth : 0,
height: typeof window !== 'undefined' ? window.innerHeight : 0
})
const updateSize = () => {
if (typeof window !== 'undefined') {
size.value.width = window.innerWidth
size.value.height = window.innerHeight
}
}
onMounted(() => {
if (typeof window !== 'undefined') {
window.addEventListener('resize', updateSize)
updateSize()
}
})
onUnmounted(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('resize', updateSize)
}
})
return size
}
The key pattern is checking typeof window !== 'undefined' before accessing browser APIs. This simple guard prevents SSR crashes while maintaining full functionality on the client. For Vue applications with SSR requirements, this pattern is essential. Our team has extensive experience building SSR-ready Vue applications that perform well on both server and client environments.
Cleanup and Memory Management
Composables that register listeners, timers, or subscriptions must clean up when components unmount. Failing to do so causes memory leaks that degrade application performance over time.
Event Listener Cleanup
// composables/useEventListener.ts
import { onUnmounted } from 'vue'
export function useEventListener<T extends EventTarget>(
target: T,
event: string,
handler: (event: Event) => void
) {
target.addEventListener(event, handler)
onUnmounted(() => {
target.removeEventListener(event, handler)
})
}
Timer Management Pattern
// composables/useInterval.ts
import { ref, onUnmounted } from 'vue'
export function useInterval(callback: () => void, delay: number) {
const isRunning = ref(false)
let intervalId: ReturnType<typeof setInterval> | null = null
const start = () => {
if (!isRunning.value) {
isRunning.value = true
intervalId = setInterval(callback, delay)
}
}
const stop = () => {
if (intervalId) {
clearInterval(intervalId)
intervalId = null
}
isRunning.value = false
}
onUnmounted(() => stop())
return { isRunning, start, stop }
}
Usage Example
<script setup>
import { useInterval } from '@/composables/useInterval'
const { isRunning, start, stop } = useInterval(() => {
console.log('Tick:', new Date().toISOString())
}, 1000)
</script>
<template>
<p>Status: {{ isRunning ? 'Running' : 'Stopped' }}</p>
<button @click="start">Start</button>
<button @click="stop">Stop</button>
</template>
Always pair resource acquisition with cleanup. The onUnmounted hook ensures your composable doesn't leave dangling references when components are destroyed. Proper memory management is critical for maintaining high-performance Vue applications that stay responsive over time.
Best Practices for Production Composables
Naming Conventions
Follow consistent naming that the Vue ecosystem expects:
| Prefix | Use Case | Examples |
|---|---|---|
use | Standard composables | useMouse, useStorage, useFetch |
create | Factory functions | createSharedState, createEventHook |
on | Event listeners | onClickOutside, onKeyPress |
try | Safe operations | tryOnMounted, tryOnCleanup |
TypeScript Best Practices
- Define explicit return types - Help TypeScript infer component usage
- Use generics for flexible input types - Make composables work with various data types
- Export interfaces for options - Allow extension and customization
export interface UseStorageOptions<T> {
defaultValue: T
serializer?: { read: (raw: string) => T; write: (value: T) => string }
}
Project Structure
Organize composables for scalability:
src/
├── composables/
│ ├── useCounter.ts
│ ├── useFetch.ts
│ ├── useStorage.ts
│ └── utils/
│ ├── ssr.ts
│ └── types.ts
└── components/
Performance Tips
- Use
shallowReffor async data that gets entirely replaced - Use
refonly when you need deep reactivity - Consider
readonlyto prevent external modification of internal state - Avoid over-refactoring--keep composables focused on single responsibilities
For enterprise Vue.js development, these patterns ensure maintainable codebases that scale with your team. By following these best practices, you'll build composable-based architectures that are performant, type-safe, and easy to maintain long-term.
Frequently Asked Questions
What's the difference between composables and mixins?
Composables provide explicit return values with clear property origins, while mixins merge properties making debugging difficult. Composables offer better TypeScript inference and eliminate naming collision issues that plague mixin-based codebases.
When should I create a composable?
Extract logic into a composable when you need the same stateful logic in 2+ components. Don't create composables for one-time logic--keep it simple until reuse becomes necessary. Premature abstraction adds complexity without benefit.
How do composables affect performance?
Composables have minimal runtime overhead. The key performance consideration is choosing `shallowRef` over `ref` when you don't need deep reactivity, avoiding unnecessary proxy creation for large objects that get replaced wholesale.
Do composables share state between components?
No. Each component calling a composable gets its own independent state instance. To share state, create a composable that returns the same ref across calls (singleton pattern) or use a state management solution like Pinia.
What is VueUse and should I use it?
VueUse is the de facto standard library with 200+ production-ready composables. Yes, use it for common patterns like useMouse, useStorage, useFetch--then build custom composables for domain-specific logic. It's well-maintained and thoroughly tested.
Sources
- Vue.js Official Documentation - Composables - Official guide on composables pattern
- VueUse - Collection of Vue Composables - De facto standard library for Vue composables
- Alex Opalic - Vue Composables Style Guide - Production-quality patterns from VueUse codebase
- Michael Thiessen - 13 Vue Composables Tips - Practical tips for writing better composables
- freeCodeCamp - How Vue Composables Work - Beginner-friendly explanation with examples