Back to blog
Frontend Engineeringintermediate

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.

LearnixoApril 16, 20267 min read
Vue.jsComposablesVue 3Composition APITypeScriptFrontendReusable Logic
Share:𝕏

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

TYPESCRIPT
// 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:

VUE
<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

TYPESCRIPT
// 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 }
}
VUE
<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

TYPESCRIPT
// 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

TYPESCRIPT
// 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:

VUE
<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

TYPESCRIPT
// 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 }
}
VUE
<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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.