Vue 3 Composables: Reusable Reactive Logic
Build reusable composables in Vue 3 — extract data fetching, form handling, WebSocket connections, infinite scroll, and debounced search into clean, testable, typed composable functions.
What Is a Composable?
A composable is a function that uses Vue's Composition API to encapsulate and reuse stateful logic — think of it as React's custom hooks equivalent, but for Vue.
Rules:
- Name starts with
use(e.g.,useAppointments,useDebouncedSearch) - Can use
ref,computed,watch, lifecycle hooks - Returns reactive state and actions
- Must be called in
setup()or another composable (not in event handlers)
Data Fetching Composable
// src/composables/useFetch.ts
import { ref, watch, type Ref } from 'vue'
interface FetchState<T> {
data: Ref<T | null>
loading: Ref<boolean>
error: Ref<string | null>
execute: () => Promise<void>
reset: () => void
}
export function useFetch<T>(
fetcher: () => Promise<T>,
options: { immediate?: boolean; watch?: Ref<any>[] } = {}
): FetchState<T> {
const data = ref<T | null>(null) as Ref<T | null>
const loading = ref(false)
const error = ref<string | null>(null)
async function execute() {
loading.value = true
error.value = null
try {
data.value = await fetcher()
} catch (e: any) {
error.value = e.message ?? 'Request failed'
data.value = null
} finally {
loading.value = false
}
}
function reset() {
data.value = null
loading.value = false
error.value = null
}
// Re-fetch when watched refs change
if (options.watch?.length) {
watch(options.watch, execute, { immediate: options.immediate ?? true })
} else if (options.immediate !== false) {
execute()
}
return { data, loading, error, execute, reset }
}Usage:
<script setup lang="ts">
import { ref } from 'vue'
import { useFetch } from '@/composables/useFetch'
import { getAppointments } from '@/lib/api'
const clinicId = ref('CLN-001')
const date = ref('')
const { data: appointments, loading, error, execute: refresh } = useFetch(
() => getAppointments(clinicId.value, date.value || undefined),
{ watch: [clinicId, date] }
)
</script>Debounced Search
// src/composables/useDebouncedSearch.ts
import { ref, watch } from 'vue'
export function useDebouncedSearch<T>(
searcher: (query: string) => Promise<T[]>,
delay = 300
) {
const query = ref('')
const results = ref<T[]>([])
const loading = ref(false)
let timer: ReturnType<typeof setTimeout>
watch(query, (newQuery) => {
clearTimeout(timer)
if (!newQuery.trim()) {
results.value = []
return
}
loading.value = true
timer = setTimeout(async () => {
try {
results.value = await searcher(newQuery)
} finally {
loading.value = false
}
}, delay)
})
return { query, results, loading }
}<script setup lang="ts">
import { useDebouncedSearch } from '@/composables/useDebouncedSearch'
import { searchPatients } from '@/lib/api'
const { query, results: patients, loading } = useDebouncedSearch(searchPatients)
</script>
<template>
<input v-model="query" placeholder="Search patients..." />
<ul v-if="patients.length">
<li v-for="p in patients" :key="p.id">{{ p.name }}</li>
</ul>
</template>Pagination Composable
// src/composables/usePagination.ts
import { ref, computed, watch } from 'vue'
export function usePagination<T>(
fetcher: (page: number, size: number) => Promise<{ items: T[]; total: number }>,
pageSize = 20
) {
const page = ref(0)
const items = ref<T[]>([]) as Ref<T[]>
const total = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
const totalPages = computed(() => Math.ceil(total.value / pageSize))
const hasNext = computed(() => page.value < totalPages.value - 1)
const hasPrev = computed(() => page.value > 0)
const currentInfo = computed(() => ({
from: page.value * pageSize + 1,
to: Math.min((page.value + 1) * pageSize, total.value),
total: total.value,
}))
async function loadPage(p: number) {
loading.value = true
error.value = null
try {
const result = await fetcher(p, pageSize)
items.value = result.items
total.value = result.total
page.value = p
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
const next = () => hasNext.value && loadPage(page.value + 1)
const prev = () => hasPrev.value && loadPage(page.value - 1)
const goTo = (p: number) => loadPage(p)
// Load first page on mount
loadPage(0)
return { items, total, loading, error, page, totalPages, hasNext, hasPrev, currentInfo, next, prev, goTo }
}WebSocket Composable
// src/composables/useWebSocket.ts
import { ref, onUnmounted } from 'vue'
type WsStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
export function useWebSocket<T = unknown>(url: string) {
const status = ref<WsStatus>('disconnected')
const lastMessage = ref<T | null>(null)
let ws: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout>
let reconnectAttempts = 0
function connect() {
ws = new WebSocket(url)
status.value = 'connecting'
ws.onopen = () => {
status.value = 'connected'
reconnectAttempts = 0
}
ws.onmessage = (event) => {
try {
lastMessage.value = JSON.parse(event.data) as T
} catch {
lastMessage.value = event.data as unknown as T
}
}
ws.onerror = () => {
status.value = 'error'
}
ws.onclose = () => {
status.value = 'disconnected'
scheduleReconnect()
}
}
function scheduleReconnect() {
const delay = Math.min(1000 * 2 ** reconnectAttempts, 30_000)
reconnectTimer = setTimeout(() => {
reconnectAttempts++
connect()
}, delay)
}
function send(data: unknown) {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data))
}
}
function disconnect() {
clearTimeout(reconnectTimer)
ws?.close()
ws = null
}
connect()
onUnmounted(disconnect)
return { status, lastMessage, send, disconnect }
}Usage for live queue updates:
<script setup lang="ts">
import { watch } from 'vue'
import { useWebSocket } from '@/composables/useWebSocket'
import { useCallStore } from '@/stores/calls'
interface QueueUpdate {
type: 'QUEUE_UPDATE'
queueId: string
contactsWaiting: number
agentsAvailable: number
}
const { status, lastMessage } = useWebSocket<QueueUpdate>(
`wss://api.clinic.com/ws/dashboard`
)
const callStore = useCallStore()
watch(lastMessage, (msg) => {
if (msg?.type === 'QUEUE_UPDATE') {
callStore.updateQueueMetric(msg.queueId, {
contactsWaiting: msg.contactsWaiting,
agentsAvailable: msg.agentsAvailable,
})
}
})
</script>Form Handling Composable
// src/composables/useForm.ts
import { reactive, ref } from 'vue'
type Validator<T> = (value: T) => string | null
export function useForm<T extends Record<string, any>>(
initialValues: T,
validators: Partial<Record<keyof T, Validator<any>>> = {}
) {
const values = reactive<T>({ ...initialValues }) as T
const errors = reactive<Partial<Record<keyof T, string>>>({})
const submitting = ref(false)
const submitted = ref(false)
function validate(): boolean {
let valid = true
for (const [key, validator] of Object.entries(validators)) {
const error = (validator as Validator<any>)(values[key])
if (error) {
errors[key as keyof T] = error
valid = false
} else {
delete errors[key as keyof T]
}
}
return valid
}
function reset() {
Object.assign(values, initialValues)
Object.keys(errors).forEach(k => delete errors[k as keyof T])
submitted.value = false
submitting.value = false
}
async function handleSubmit(onSubmit: (values: T) => Promise<void>) {
submitted.value = true
if (!validate()) return
submitting.value = true
try {
await onSubmit({ ...values })
} finally {
submitting.value = false
}
}
return { values, errors, submitting, submitted, validate, reset, handleSubmit }
}<script setup lang="ts">
import { useForm } from '@/composables/useForm'
import { createAppointment } from '@/lib/api'
const { values, errors, submitting, handleSubmit } = useForm(
{ clinicId: '', patientId: '', dateTime: '', notes: '' },
{
clinicId: v => v ? null : 'Clinic is required',
patientId: v => v ? null : 'Patient is required',
dateTime: v => v ? null : 'Date and time is required',
}
)
const onSubmit = () => handleSubmit(async (data) => {
await createAppointment(data)
emit('created')
})
</script>
<template>
<form @submit.prevent="onSubmit">
<input v-model="values.clinicId" />
<p v-if="errors.clinicId" class="text-red-500">{{ errors.clinicId }}</p>
<button :disabled="submitting" type="submit">
{{ submitting ? 'Saving...' : 'Schedule' }}
</button>
</form>
</template>Composable Best Practices
| Rule | Why |
|------|-----|
| Always prefix with use | Convention — signals it uses Composition API |
| Return refs, not raw values | Keeps reactivity when destructured |
| Clean up side effects in onUnmounted | Prevent memory leaks and zombie listeners |
| Accept Ref params as well as raw | Makes composables work with reactive data |
| Keep composables focused | One responsibility — split large ones |
| Test without mounting a component | Composables are just functions |
Enjoyed this article?
Explore the Frontend Engineering learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.