Vue.js Composition API : Patterns Avancés et Composables Réutilisables


La Composition API de Vue 3 a changé la donne. Mais la majorité des développeurs s’arrêtent aux exemples basiques — un useFetch par-ci, un useLocalStorage par-là. Les patterns avancés, ceux qui rendent vos composables vraiment réutilisables et robustes en production, restent sous-exploités.

Ce guide va au-delà des tutoriels d’introduction.

Au-delà des composables basiques

Pattern erreur et retry

Un composable de production doit gérer les erreurs et les retentatives :

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

Ce composable gère le loading state, les erreurs, et un backoff exponentiel pour les retentatives. Il s’utilise dans n’importe quel composant sans duplication.

Pattern async avec Suspense

Vue 3 supporte <Suspense> nativement. Combiné avec des composables async, ça simplifie drastiquement le loading state :

<!-- AsyncUserProfile.vue -->
<script setup lang="ts">
const user = await useUser(props.userId) // Le composant est "suspendu" jusqu'à résolution
</script>

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

Le même concept existe chez React avec les Server Components de Next.js, mais l’implémentation Vue est plus explicite et fonctionne entièrement côté client.

Injection de dépendances avec provide/inject

provide/inject est le système de DI natif de Vue. Sous-utilisé, il résout élégamment le prop drilling et permet l’inversion de contrôle.

// 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() s’appelle une fois dans le layout racine. useNotifications() s’utilise partout dans l’arbre de composants. Pas de store global, pas de prop drilling, et c’est testable via un mock du provide.

Gestion d’état avancée avec Pinia

Store factories

Les stores Pinia statiques (un store = un état global) ne conviennent pas toujours. Les store factories créent des instances dynamiques :

// 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')

Plugins Pinia

Les plugins Pinia interceptent les mutations et ajoutent des comportements transversaux :

// 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))
  })
}

Un plugin, une ligne de configuration, et tous vos stores sont persistés automatiquement. Pas de boilerplate dans chaque store.

Refactoring : de Options API à Composition API

La migration est progressive. Commencez par les composants les plus complexes — ceux où les mixins s’empilent et les this se perdent.

Avant (Options API + mixins) :

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

export default {
  mixins: [paginationMixin, searchMixin],
  data() {
    return { filters: {} } // Conflit possible avec les mixins
  },
}
</script>

Après (Composition API + composables) :

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

Chaque composable est explicite, typé, et sans risque de conflit de noms. Le code est plus long en lignes mais infiniment plus lisible et maintenable.

Tester les composables

Les composables se testent de manière isolée avec @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)
})

Pour les tests qui vont au-delà de l’unitaire et couvrent des parcours utilisateur complets, les tests E2E avec Playwright complètent parfaitement cette approche.

Les performances des composables impactent directement les métriques utilisateur — un composable mal optimisé peut dégrader l’INP. Pour mesurer cet impact, consultez mon guide sur la performance web et les Core Web Vitals.

En résumé

La Composition API de Vue 3 va bien au-delà des composables simples. Avec les patterns erreur/retry, l’injection de dépendances via provide/inject, les store factories Pinia, et une stratégie de migration claire, vous pouvez construire des applications Vue robustes et maintenables.

La clé : traiter vos composables comme des APIs publiques — avec des contrats clairs, une gestion d’erreur explicite, et des tests dédiés.

KD

Kevin De Vaubree

Développeur Full-Stack Senior