Vue 3 Composables: Reusable Logic Without the Bloat
One of the biggest improvements in Vue 3 was the Composition API, and composables are the natural result of taking it seriously. If you've been using Vue 2 mixins to share logic between components, composables are the cleaner, more explicit replacement — and once you get the pattern, you'll use it everywhere.
In this post, I'll cover what composables are, how they compare to mixins, and walk through three practical examples: form handling, data fetching, and shared state.
What Is a Composable?
A composable is just a function that uses Vue's Composition API (ref, computed, watch, onMounted, etc.) and returns reactive state or methods. By convention, composable files live in a composables/ directory and are prefixed with use.
// composables/useCounter.js
import { ref } from 'vue'
export function useCounter(initial = 0) {
const count = ref(initial)
function increment() {
count.value++
}
function decrement() {
count.value--
}
return { count, increment, decrement }
}
Use it in any component:
<script setup>
import { useCounter } from '@/composables/useCounter'
const { count, increment, decrement } = useCounter(10)
</script>
<template>
<div>
<button @click="decrement">-</button>
<span>{{ count }}</span>
<button @click="increment">+</button>
</div>
</template>
The logic is self-contained, clearly named, and not tangled up in the component.
Why Not Mixins?
Mixins had three major problems:
- Unclear source of truth — if a component uses three mixins and you see
this.isLoading, which mixin does that come from? You have to dig. - Namespace collisions — two mixins with a
dataproperty named the same thing silently conflict. - No type support — mixins and TypeScript don't get along well.
Composables solve all three. Everything is explicitly destructured from the composable call, so you always know where a value comes from:
const { isLoading, data, error } = useFetch('/api/users')
const { form, submit, errors } = useForm({ name: '', email: '' })
No ambiguity, no collisions, and TypeScript works beautifully.
Practical Example 1: useForm
Form handling is probably the most common thing you'll pull into a composable. Tracking field values, validation errors, and submit state is boilerplate that shouldn't live in every component.
// composables/useForm.js
import { reactive, ref } from 'vue'
export function useForm(initialValues) {
const form = reactive({ ...initialValues })
const errors = reactive({})
const loading = ref(false)
function reset() {
Object.keys(initialValues).forEach(key => {
form[key] = initialValues[key]
})
Object.keys(errors).forEach(key => delete errors[key])
}
async function submit(url, options = {}) {
loading.value = true
Object.keys(errors).forEach(key => delete errors[key])
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
...options,
})
const data = await response.json()
if (! response.ok && data.errors) {
Object.assign(errors, data.errors)
return null
}
return data
} finally {
loading.value = false
}
}
return { form, errors, loading, reset, submit }
}
In a component:
<script setup>
import { useForm } from '@/composables/useForm'
const { form, errors, loading, submit, reset } = useForm({
name: '',
email: '',
})
async function handleSubmit() {
const result = await submit('/api/users')
if (result) {
reset()
}
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div>
<input v-model="form.name" placeholder="Name" />
<span v-if="errors.name">{{ errors.name[0] }}</span>
</div>
<div>
<input v-model="form.email" placeholder="Email" type="email" />
<span v-if="errors.email">{{ errors.email[0] }}</span>
</div>
<button type="submit" :disabled="loading">
{{ loading ? 'Saving...' : 'Save' }}
</button>
</form>
</template>
The component is clean — all the plumbing is inside the composable.
Practical Example 2: useFetch
A data fetching composable handles loading state, errors, and the async lifecycle so you don't repeat it across every component that hits an API.
// composables/useFetch.js
import { ref, watchEffect, toValue } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)
async function fetchData() {
const resolvedUrl = toValue(url)
if (! resolvedUrl) return
loading.value = true
error.value = null
data.value = null
try {
const response = await fetch(resolvedUrl)
if (! response.ok) {
throw new Error(`HTTP error: ${response.status}`)
}
data.value = await response.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
watchEffect(fetchData)
return { data, error, loading, refresh: fetchData }
}
Using toValue() on the URL means you can pass either a plain string or a ref — if it's a ref, the composable automatically re-fetches when the URL changes:
<script setup>
import { ref } from 'vue'
import { useFetch } from '@/composables/useFetch'
const userId = ref(1)
const { data: user, loading, error } = useFetch(() => `/api/users/${userId.value}`)
</script>
<template>
<div v-if="loading">Loading...</div>
<div v-else-if="error">{{ error }}</div>
<div v-else-if="user">
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
</div>
<button @click="userId++">Next user</button>
</template>
Change userId and the fetch fires automatically — no manual watchers in the component.
Practical Example 3: Shared State Across Components
Composables can hold shared state when you create the reactive values outside of the function and return the same references each call. This is a lightweight alternative to Pinia for simple cases.
// composables/useAuth.js
import { ref, computed } from 'vue'
// Declared outside the function — shared across all callers
const user = ref(null)
const token = ref(localStorage.getItem('token'))
export function useAuth() {
const isAuthenticated = computed(() => !! token.value)
async function login(credentials) {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
})
const data = await response.json()
if (response.ok) {
token.value = data.token
user.value = data.user
localStorage.setItem('token', data.token)
}
return response.ok
}
function logout() {
token.value = null
user.value = null
localStorage.removeItem('token')
}
return { user, token, isAuthenticated, login, logout }
}
Any component calling useAuth() gets the same user and token refs. Update them in one place and every component that uses them reacts.
<!-- Navbar.vue -->
<script setup>
import { useAuth } from '@/composables/useAuth'
const { user, isAuthenticated, logout } = useAuth()
</script>
<template>
<nav>
<span v-if="isAuthenticated">Hello, {{ user.name }}</span>
<button v-if="isAuthenticated" @click="logout">Log out</button>
</nav>
</template>
Organising Composables
A few conventions that keep things tidy as you add more:
- One composable per file, named after the composable function:
useForm.js,useAuth.js - Keep composables focused — if a composable is growing a lot, consider splitting it
- If a composable is component-specific (only used in one place), consider keeping it in the component file itself and only extracting when it's reused
- TypeScript users: export an interface for the composable's return type so callers get full autocomplete
Final Thoughts
Composables make Vue 3 genuinely pleasant to work with at scale. Once you start thinking in composables, you'll find yourself pulling logic out of components naturally — and the components that remain are mostly just templates stitching together reactive values.
Start with something small like useToggle or useDebounce, get comfortable with the pattern, and work your way up to shared-state composables. It's one of those things that's hard to unsee once you've done it.