Composition API de Vue 3 : ce qu'elle apporte par rapport à l'Options API

Abdoul Kadri ASSOUMANE MAMANE
Abdoul Kadri ASSOUMANE MAMANE Auteur
10 min de lecture
607 vues

Écouter l'article

Composition API de Vue 3 : ce qu'elle apporte par rapport à l'Options API

Ce guide compare la Composition API à l'Options API de Vue 3 : ce que la première change dans l'organisation du code, ce qu'elle ne change pas, et dans quels cas la seconde reste un très bon choix. Les exemples sont des cas d'école, pas le récit d'un projet précis, et le portfolio lui-même n'utilise pas Vue : ses pages sont en Blade avec du JavaScript en modules. Les recommandations citées (ref() par défaut, limites de reactive(), Pinia, absence d'option de temporisation dans watch) ont été relues sur vuejs.org en octobre 2026 ; les extraits n'ont pas été exécutés. Ils sont valables pour Vue 3.2 et plus, version qui a stabilisé <script setup>.

À la sortie de Vue 3, le premier réflexe est de continuer à écrire ses composants comme avant : data(), methods, computed, watch, chacun dans sa section. Ça marche et le pattern est connu, alors pourquoi changer ?

Le vrai problème de l'Options API

Le souci n'est pas l'Options API en soi. Sur un composant simple (un bouton, un champ de formulaire, une card), elle reste parfaitement lisible. Le problème apparaît quand un composant gère plusieurs préoccupations en même temps.

Prenons un composant de recherche hypothétique qui doit :

  • gérer l'état du champ de recherche
  • temporiser (debouncer) les requêtes API
  • afficher un loader pendant le chargement
  • paginer les résultats

Avec l'Options API, ces quatre logiques se retrouvent réparties entre data, methods, computed et watch. Pour comprendre le comportement du debounce, il faut lire plusieurs sections à la fois. Avec la Composition API, chaque logique peut vivre dans son propre bloc, les unes à la suite des autres, ou dans une fonction réutilisable.

Ce que l'Options API fait aussi bien

La Composition API n'est pas supérieure partout :

  • Un composant simple, avec un état et deux méthodes, n'y gagne rien. L'Options API impose une structure claire que beaucoup d'équipes apprécient.
  • La migration n'est pas obligatoire : les deux syntaxes cohabitent dans le même projet, et la documentation officielle présente l'Options API comme un choix légitime.
  • Les composants existants qui fonctionnent n'ont pas à être réécrits. Un projet peut mélanger les deux styles sans problème.
  • Pour une équipe qui connaît l'Options API, la courbe d'apprentissage de la Composition API a un coût réel, à mettre en balance avec le gain attendu.

Ce que <script setup> change au quotidien

La syntaxe <script setup> (stabilisée avec Vue 3.2) rend la Composition API beaucoup plus agréable à écrire. Voici un composant basique pour comparer :

<script setup>
import { ref, computed, onMounted } from 'vue'

const count = ref(0)
const name = ref('Vue')

const doubleCount = computed(() => count.value * 2)

function increment() {
  count.value++
}

onMounted(() => {
  console.log('Component mounted!')
})
</script>

<template>
  <div>
    <p>{{ name }} count: {{ count }} (x2: {{ doubleCount }})</p>
    <button @click="increment">+1</button>
  </div>
</template>

Le code est plus court : pas de export default, pas de return dans setup(), pas de this devant chaque propriété. Tout ce qui est déclaré dans le <script setup> est automatiquement disponible dans le template.

Le .value sur les ref surprend au début. En contrepartie, il rend explicite ce qui est réactif et ce qui ne l'est pas.

Les composables : réutiliser de la logique réactive

C'est le concept qui change le plus la façon de structurer un projet Vue. Un composable est une fonction qui encapsule de la logique réactive réutilisable. L'idée est proche des hooks de React, mais elle s'appuie sur le système de réactivité de Vue.

Voici un exemple simple, un compteur :

// composables/useCounter.js
import { ref, computed } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  const double = computed(() => count.value * 2)

  function increment() {
    count.value++
  }

  function decrement() {
    count.value--
  }

  function reset() {
    count.value = initialValue
  }

  return {
    count,
    double,
    increment,
    decrement,
    reset
  }
}

Et l'utilisation dans un composant :

<script setup>
import { useCounter } from '@/composables/useCounter'

const { count, double, increment } = useCounter(10)
</script>

Trois lignes suffisent côté composant. La logique est importée, testable indépendamment et réutilisable. Des composables courants sont useDarkMode() pour le thème ou useDebounce() pour les champs de recherche : ils tiennent généralement en quelques dizaines de lignes, et les composants restent légers.

Avant les composables, cette logique vivait souvent dans des mixins. Dès qu'on en combine deux dans un composant, les noms de propriétés peuvent entrer en collision et l'origine de chaque valeur devient difficile à retrouver. Avec un composable, tout est explicite : on importe exactement ce dont on a besoin et on nomme soi-même ce qu'on récupère.

ref() ou reactive() : que recommande la documentation

La documentation de Vue recommande d'utiliser ref() par défaut, parce qu'il fonctionne avec tous les types de valeurs (primitives, objets, tableaux) et ne souffre pas des limites de reactive(). reactive() convient à un état local simple, par exemple un petit objet de formulaire.

Ses limites sont à connaître :

  • reactive() ne fonctionne que sur des objets (objets, tableaux, Map, Set), pas sur des primitives.
  • Remplacer l'objet entier (state = reactive({...})) coupe le lien avec la réactivité d'origine, il faut modifier ses propriétés.
  • Déstructurer un objet réactif ou passer une de ses propriétés à une fonction fait perdre la réactivité.
import { reactive, toRefs } from 'vue'

const state = reactive({
  user: {
    name: 'John',
    email: 'john@example.com'
  },
  loading: false
})

// Déstructurer en gardant la réactivité
const { user, loading } = toRefs(state)

Avec ref(), ces pièges n'existent pas, mais il faut écrire .value dans le script. C'est un compromis, pas une solution parfaite : ref() par défaut, reactive() quand on veut regrouper des propriétés liées sans jamais remplacer l'objet.

Watch et WatchEffect : deux outils, deux usages

Les mélanger produit facilement des appels API en double dès le début de la vie du composant. Voici la distinction utile, avec une fonction fetchResults définie pour l'exemple :

import { ref, watch, watchEffect } from 'vue'

const search = ref('')
const results = ref([])
const loading = ref(false)
const error = ref(null)

async function fetchResults(query) {
  loading.value = true
  error.value = null
  try {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
    if (!response.ok) {
      throw new Error(`Erreur HTTP ${response.status}`)
    }
    return await response.json()
  } catch (e) {
    error.value = e.message
    return []
  } finally {
    loading.value = false
  }
}

// watch : se déclenche quand 'search' change (pas à l'initialisation)
watch(search, async (newValue) => {
  results.value = await fetchResults(newValue)
})

// watchEffect : s'exécute immédiatement à la création, puis à chaque
// changement de ses dépendances
watchEffect(() => {
  console.log(`Search: ${search.value}`)
})

watch est explicite : on lui dit quoi observer et il ne se déclenche qu'au changement (sauf avec l'option { immediate: true }). watchEffect suit automatiquement les dépendances réactives lues dans sa fonction, et s'exécute tout de suite à l'initialisation du composant, donc avant son montage. Un appel API placé dans un watchEffect part dès setup, ce qui est parfois voulu et parfois non.

watch n'a pas d'option debounce. Pour temporiser une recherche, il faut le faire soi-même, par exemple avec un petit composable :

// composables/useDebounce.js
import { ref, watch, onScopeDispose } from 'vue'

export function useDebounce(source, delay = 300) {
  const debounced = ref(source.value)
  let timer

  watch(source, (value) => {
    clearTimeout(timer)
    timer = setTimeout(() => {
      debounced.value = value
    }, delay)
  })

  onScopeDispose(() => clearTimeout(timer))

  return debounced
}
const search = ref('')
const debouncedSearch = useDebounce(search, 300)

// La requête ne part que 300 ms après la dernière frappe
watch(debouncedSearch, async (value) => {
  results.value = await fetchResults(value)
})

La bibliothèque VueUse propose des utilitaires équivalents prêts à l'emploi (refDebounced, useDebounceFn) si on préfère ne pas les maintenir soi-même.

En règle générale, watch convient aux effets de bord (appels API, navigation) et watchEffect au logging ou aux synchronisations légères.

Props et Emits avec <script setup>

La syntaxe a changé par rapport à l'Options API :

<script setup>
const props = defineProps({
  title: {
    type: String,
    required: true
  },
  count: {
    type: Number,
    default: 0
  }
})

const emit = defineEmits(['update', 'delete'])

function handleClick() {
  emit('update', props.count + 1)
}
</script>

defineProps et defineEmits sont des macros du compilateur : elles ne s'importent pas. Les types et les valeurs par défaut se déclarent au même endroit. Avec TypeScript, on peut aussi typer les props avec une interface, et la vérification se fait alors à la compilation plutôt qu'à l'exécution.

Provide/Inject : éviter le prop drilling

Cette fonctionnalité existait déjà dans Vue 2, mais la Composition API la rend plus simple à exploiter. Elle sert surtout au thème et aux configurations globales. En TypeScript, on évite les clés en chaîne de caractères au profit d'une InjectionKey, qui donne un typage de bout en bout et évite les collisions de noms :

// keys.ts
import type { InjectionKey, Ref } from 'vue'

export const ThemeKey: InjectionKey<Ref<'light' | 'dark'>> = Symbol('theme')
// Dans le composant parent (App.vue ou un layout)
import { provide, ref } from 'vue'
import { ThemeKey } from './keys'

const theme = ref<'light' | 'dark'>('dark')
provide(ThemeKey, theme)

// Dans n'importe quel composant descendant
import { inject } from 'vue'
import { ThemeKey } from './keys'

const theme = inject(ThemeKey, ref('light')) // valeur par défaut

En JavaScript, le même principe s'applique avec un Symbol exporté depuis un fichier partagé, plutôt qu'une chaîne comme 'theme'.

Mieux vaut le réserver aux besoins vraiment transversaux. Pour la communication parent-enfant classique, les props et les emits restent la bonne solution : sinon on perd en traçabilité.

Pinia pour l'état partagé

Les composables ont une limite : si deux composants appellent useCounter(), chacun reçoit son propre état. Pour un état partagé à l'échelle de l'application (utilisateur connecté, panier, préférences), la solution recommandée par l'équipe Vue est Pinia, qui a succédé à Vuex (toujours fonctionnel, mais sans nouvelles fonctionnalités d'après la documentation). Un store Pinia se définit avec la même logique de ref et computed, et s'utilise depuis n'importe quel composant. Il s'ajoute quand le besoin apparaît, pas par défaut : provide/inject ou un composable suffisent pour des cas plus modestes.

Ce qu'il vaut mieux savoir avant de migrer

La migration n'est pas un big bang. Les deux API cohabitent dans le même projet. On peut migrer les composants un par un, en commençant par les plus complexes, et laisser durablement les composants simples en Options API.

Les devtools Vue gèrent les deux. Le debugging ne se dégrade pas : les Vue DevTools reconnaissent les ref et reactive et les affichent proprement.

<script setup> est d'abord un confort d'écriture. Le code compilé est un peu plus direct (le template est compilé dans la portée de setup, sans objet de contexte intermédiaire), mais le gain de performance reste généralement imperceptible. Les vrais bénéfices sont la concision et une meilleure inférence de types avec TypeScript.

Les composables demandent une convention de nommage. Le préfixe use suivi du nom de la fonctionnalité (useAuth, useArticles, usePagination) évite que le dossier composables/ devienne un bazar.

En résumé

La Composition API change la manière de réutiliser le code dans Vue : les composables remplacent les mixins sans leurs défauts, le regroupement par logique remplace le regroupement par type d'option, et <script setup> réduit le boilerplate.

Cela ne rend pas l'Options API obsolète. Pour un projet existant qui fonctionne, migrer n'est pas nécessaire. Pour un nouveau composant qui mêle plusieurs logiques (filtrage, tri, pagination), la Composition API facilite le découpage en composables séparés. Pour un composant simple, le choix relève surtout des habitudes de l'équipe.

Partager cet article

Envie de ne rien rater ?

Recevez les nouveaux articles directement dans votre boîte mail. Pas de spam, uniquement du contenu technique.

Commentaires (0)

Laisser un commentaire

0 / 2000

Aucun commentaire pour le moment. Soyez le premier à réagir !

Lecture

Articles similaires

Envie d'en discuter ?

Une opportunité d'alternance, un projet ou une question technique : écrivez-moi, je réponds généralement sous 24 à 48 heures.