Vue.js Composition API: Advanced Patterns and Reusable Composables


Vue 3’s Composition API changed the game. But most developers stop at basic examples — a useFetch here, a useLocalStorage there. Advanced patterns, those that make your composables truly reusable and robust in production, remain underexplored.

This guide goes beyond introductory tutorials.

Beyond basic composables

Error and retry pattern

A production composable must handle errors and retries:

// composables/useAsyncAction.ts
export function useAsyncAction<T>(action: () => Promise<T>) {
  const data = ref<T | null>(null)
  const error = ref<Error | null>(null)
  const isLoading = ref(false)
  const retryCount = ref(0)

  async function execute(maxRetries = 3) {
    isLoading.value = true
    error.value = null

    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      try {
        data.value = await action()
        retryCount.value = attempt
        return data.value
      } catch (e) {
        if (attempt === maxRetries) {
          error.value = e as Error
        } else {
          await new Promise(r => setTimeout(r, 2 ** attempt * 1000))
        }
      }
    }
    isLoading.value = false
  }

  return { data, error, isLoading, retryCount, execute }
}

This composable handles loading state, errors, and exponential backoff for retries. It’s used in any component without duplication.

Async pattern with Suspense

Vue 3 natively supports <Suspense>. Combined with async composables, it drastically simplifies loading state:

<!-- AsyncUserProfile.vue -->
<script setup lang="ts">
const user = await useUser(props.userId) // Component is "suspended" until resolution
</script>

<template>
  <div>{{ user.name }}</div>
</template>
<!-- Parent.vue -->
<template>
  <Suspense>
    <AsyncUserProfile :userId="42" />
    <template #fallback>
      <UserProfileSkeleton />
    </template>
  </Suspense>
</template>

The same concept exists in React with Next.js Server Components, but Vue’s implementation is more explicit and works entirely client-side.

Dependency injection with provide/inject

provide/inject is Vue’s native DI system. Underused, it elegantly solves prop drilling and enables inversion of control.

// composables/useNotifications.ts
const NotificationKey: InjectionKey<NotificationService> = Symbol('notifications')

export function provideNotifications() {
  const notifications = ref<Notification[]>([])

  function notify(message: string, type: 'success' | 'error' = 'success') {
    const id = crypto.randomUUID()
    notifications.value.push({ id, message, type })
    setTimeout(() => dismiss(id), 5000)
  }

  function dismiss(id: string) {
    notifications.value = notifications.value.filter(n => n.id !== id)
  }

  const service = { notifications: readonly(notifications), notify, dismiss }
  provide(NotificationKey, service)
  return service
}

export function useNotifications() {
  const service = inject(NotificationKey)
  if (!service) throw new Error('provideNotifications() must be called in a parent component')
  return service
}

provideNotifications() is called once in the root layout. useNotifications() is used anywhere in the component tree. No global store, no prop drilling, and it’s testable via a provide mock.

Advanced state management with Pinia

Store factories

Static Pinia stores (one store = one global state) don’t always fit. Store factories create dynamic instances:

// stores/useEntityStore.ts
export function createEntityStore<T extends { id: string }>(name: string) {
  return defineStore(`entity-${name}`, () => {
    const entities = ref<Map<string, T>>(new Map())
    const loading = ref(false)

    const list = computed(() => [...entities.value.values()])
    const getById = (id: string) => entities.value.get(id)

    async function fetchAll(fetcher: () => Promise<T[]>) {
      loading.value = true
      const items = await fetcher()
      items.forEach(item => entities.value.set(item.id, item))
      loading.value = false
    }

    return { entities, loading, list, getById, fetchAll }
  })
}

// Usage
const useProductStore = createEntityStore<Product>('products')
const useOrderStore = createEntityStore<Order>('orders')

Pinia plugins

Pinia plugins intercept mutations and add cross-cutting behaviors:

// plugins/persistPlugin.ts
export function piniaPersistedState({ store }: PiniaPluginContext) {
  const saved = localStorage.getItem(store.$id)
  if (saved) store.$patch(JSON.parse(saved))

  store.$subscribe((_, state) => {
    localStorage.setItem(store.$id, JSON.stringify(state))
  })
}

One plugin, one config line, and all your stores are automatically persisted. No boilerplate in each store.

Refactoring: from Options API to Composition API

Migration is progressive. Start with the most complex components — where mixins pile up and this gets lost.

Before (Options API + mixins):

<script>
import { paginationMixin } from '@/mixins/pagination'
import { searchMixin } from '@/mixins/search'

export default {
  mixins: [paginationMixin, searchMixin],
  data() {
    return { filters: {} } // Possible conflict with mixins
  },
}
</script>

After (Composition API + composables):

<script setup lang="ts">
const { page, perPage, nextPage, prevPage } = usePagination()
const { query, results, search } = useSearch()
const filters = ref({})
</script>

Each composable is explicit, typed, and without naming conflict risk. Code is longer in lines but infinitely more readable and maintainable.

Testing composables

Composables are tested in isolation with @vue/test-utils:

import { withSetup } from '@/test-utils'

test('useAsyncAction retries on failure', async () => {
  let attempts = 0
  const action = async () => {
    attempts++
    if (attempts < 3) throw new Error('fail')
    return 'success'
  }

  const [result] = withSetup(() => useAsyncAction(action))
  await result.execute(3)

  expect(result.data.value).toBe('success')
  expect(result.retryCount.value).toBe(2)
})

For tests that go beyond unit level and cover complete user journeys, E2E tests with Playwright perfectly complement this approach.

Composable performance directly impacts user metrics — a poorly optimized composable can degrade INP. To measure this impact, see my guide on web performance and Core Web Vitals.

In summary

Vue 3’s Composition API goes far beyond simple composables. With error/retry patterns, dependency injection via provide/inject, Pinia store factories, and a clear migration strategy, you can build robust and maintainable Vue applications.

The key: treat your composables as public APIs — with clear contracts, explicit error handling, and dedicated tests.

KD

Kevin De Vaubree

Senior Full-Stack Developer