Back to Blog
·15 min read

Vue 3 Composables: Patterns That Replace Bloated Stores

Vue.jsJavaScriptTypeScript

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 MaybeRefOrGetter for inputs that might be reactive
  • Clean up side effects with onScopeDispose (abort controllers, clear timers)
  • Colocate composables in composables/ for shared logic, or features/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.