Vue 3 Composables: Patterns That Replace Bloated Stores
Before Vue 3, shared logic meant mixins (naming collisions, opaque dependencies) or Vuex modules (ceremony for simple CRUD). Composables changed that. They are plain functions that use the Composition API and can be composed together.
At Sociair, composables handle most feature logic. Pinia stays for truly global state—auth, theme, notifications. Everything else lives in composables colocated with the feature.
The anatomy of a composable
A composable is any function whose name starts with use and returns reactive state plus methods:
// composables/useCounter.ts
export function useCounter(initial = 0) {
const count = ref(initial)
function increment() {
count.value++
}
function decrement() {
count.value--
}
function reset() {
count.value = initial
}
return { count, increment, decrement, reset }
}
<script setup lang="ts">
const { count, increment } = useCounter(10)
</script>
Each component call gets its own reactive scope. No shared state unless you intentionally create it outside the function.
Fetch composable with loading and error states
This pattern covers 80% of API interactions in a Laravel + Vue SPA:
// composables/useFetch.ts
interface UseFetchOptions<T> {
immediate?: boolean
transform?: (data: unknown) => T
}
export function useFetch<T>(url: MaybeRefOrGetter<string>, options: UseFetchOptions<T> = {}) {
const data = ref<T | null>(null) as Ref<T | null>
const error = ref<Error | null>(null)
const loading = ref(false)
async function execute() {
loading.value = true
error.value = null
try {
const response = await $fetch<unknown>(toValue(url), {
credentials: 'include',
})
data.value = options.transform
? options.transform(response)
: (response as T)
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e))
} finally {
loading.value = false
}
}
if (options.immediate !== false) {
execute()
}
return { data, error, loading, execute, refresh: execute }
}
Usage in a component:
<script setup lang="ts">
interface User {
id: number
name: string
email: string
}
const userId = computed(() => route.params.id as string)
const { data: user, loading, error, refresh } = useFetch<User>(
() => `/api/users/${userId.value}`,
)
</script>
<template>
<div v-if="loading">Loading...</div>
<div v-else-if="error">{{ error.message }}</div>
<UserProfile v-else-if="user" :user="user" @updated="refresh" />
</template>
Form composable with validation
Pair composables with Laravel Form Requests on the backend:
// composables/useForm.ts
interface FormErrors {
[field: string]: string[]
}
export function useForm<T extends Record<string, unknown>>(initial: T) {
const fields = reactive({ ...initial }) as T
const errors = ref<FormErrors>({})
const processing = ref(false)
function reset() {
Object.assign(fields, initial)
errors.value = {}
}
async function submit(url: string, method: 'POST' | 'PUT' | 'PATCH' = 'POST') {
processing.value = true
errors.value = {}
try {
return await $fetch(url, {
method,
body: fields,
credentials: 'include',
})
} catch (e: unknown) {
if (isFetchError(e) && e.statusCode === 422) {
errors.value = e.data.errors ?? {}
}
throw e
} finally {
processing.value = false
}
}
function hasError(field: keyof T) {
return field in errors.value
}
function errorFor(field: keyof T) {
return errors.value[field as string]?.[0] ?? ''
}
return { fields, errors, processing, reset, submit, hasError, errorFor }
}
<script setup lang="ts">
const { fields, submit, processing, errorFor, hasError } = useForm({
name: '',
email: '',
role: 'editor',
})
async function handleSubmit() {
await submit('/api/users', 'POST')
navigateTo('/users')
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<input v-model="fields.name" :class="{ 'border-red-500': hasError('name') }" />
<p v-if="hasError('name')" class="text-red-500">{{ errorFor('name') }}</p>
<input v-model="fields.email" type="email" />
<p v-if="hasError('email')" class="text-red-500">{{ errorFor('email') }}</p>
<button type="submit" :disabled="processing">
{{ processing ? 'Saving...' : 'Create User' }}
</button>
</form>
</template>
Pagination composable
List pages share the same URL-synced pagination logic:
// composables/usePagination.ts
export function usePagination(defaultPerPage = 15) {
const route = useRoute()
const router = useRouter()
const page = computed({
get: () => Number(route.query.page ?? 1),
set: (value) => router.push({ query: { ...route.query, page: value } }),
})
const perPage = computed({
get: () => Number(route.query.per_page ?? defaultPerPage),
set: (value) => router.push({ query: { ...route.query, per_page: value, page: 1 } }),
})
function goToPage(p: number) {
page.value = p
}
return { page, perPage, goToPage }
}
Combine with useFetch for a complete list view:
const { page, perPage } = usePagination()
const query = computed(() => `/api/users?page=${page.value}&per_page=${perPage.value}`)
const { data, loading } = useFetch<PaginatedUsers>(() => query.value)
Composing composables together
Composables can call other composables—this is where the real power shows:
export function useUserList() {
const { page, perPage, goToPage } = usePagination()
const search = ref('')
const url = computed(() => {
const params = new URLSearchParams({
page: String(page.value),
per_page: String(perPage.value),
search: search.value,
})
return `/api/users?${params}`
})
const { data, loading, error, refresh } = useFetch<PaginatedUsers>(() => url.value)
watch(search, debounce(() => {
goToPage(1)
refresh()
}, 300))
return { data, loading, error, search, page, goToPage, refresh }
}
When to use Pinia instead
Use Pinia when:
- Multiple unrelated components need the same state simultaneously
- State must survive route navigation (current user, cart, UI preferences)
- You need devtools time-travel debugging for complex state transitions
Use composables when:
- Logic is feature-scoped (a single form, a modal, a data table)
- State should reset when the component unmounts
- You want easy unit testing without mocking a store
Testing composables
Use @vue/test-utils with withSetup:
import { withSetup } from '@/test/utils'
it('increments the counter', () => {
const { count, increment } = withSetup(() => useCounter(0))
increment()
expect(count.value).toBe(1)
})
Rules I follow in production
- One composable per concern—don’t build a god composable
- Return refs and methods, not reactive objects you mutate from outside
- Accept
MaybeRefOrGetterfor inputs that might be reactive - Clean up side effects with
onScopeDispose(abort controllers, clear timers) - Colocate composables in
composables/for shared logic, orfeatures/users/composables/for feature-specific logic
Composables are the reason I rarely add new Pinia stores. They keep Vue components thin, logic testable, and patterns consistent across a Laravel API backend.