Maîtrisez la Composition API Vue 3 avec script setup, ref, reactive, computed, watch, lifecycle hooks, props typées et premiers composables.
Options API vs Composition API
Vue 3 introduit la Composition API comme alternative à l'Options API de Vue 2. Les deux fonctionnent en Vue 3, mais la Composition API résout des problèmes fondamentaux que l'Options API ne pouvait pas adresser correctement : réutilisation de la logique entre composants, organisation par fonctionnalité, et support TypeScript natif.
La même fonctionnalité, deux styles
<!-- ❌ Options API (Vue 2 / Vue 3 compatible) -->
<!-- La logique est fragmentée par TYPE (data, methods, computed...) -->
<script>
export default {
data() {
return {
count: 0, // données réactives
user: null,
};
},
computed: {
doubleCount() { // calcul dérivé
return this.count * 2;
},
},
methods: {
increment() { // logique métier
this.count++;
},
fetchUser() {
// fetch...
},
},
mounted() { // lifecycle
this.fetchUser();
},
};
</script>
<!-- ✅ Composition API (Vue 3 recommandé) -->
<!-- La logique est organisée par FONCTIONNALITÉ -->
<script setup>
import { ref, computed, onMounted } from 'vue';
// --- Logique compteur (regroupée) ---
const count = ref(0);
const doubleCount = computed(() => count.value * 2);
const increment = () => count.value++;
// --- Logique utilisateur (regroupée) ---
const user = ref(null);
const fetchUser = async () => {
user.value = await fetch('/api/user').then(r => r.json());
};
onMounted(fetchUser);
</script>
Pourquoi la Composition API gagne ?
| Critère | Options API | Composition API |
|---|---|---|
| Réutilisation logique | Mixins (conflits de noms) | Composables (explicite, typé) |
| TypeScript | Partiel, this mal typé | Natif, inférence complète |
| Organisation du code | Par type (data/methods/…) | Par fonctionnalité |
| Tree-shaking | Limité | Excellent (imports nommés) |
| Courbe d'apprentissage | Douce pour débutants | Légèrement plus haute |
<script setup> pour tout nouveau projet. L'Options API reste supportée
et ne sera jamais supprimée, mais la Composition API est la direction de l'écosystème.
Le script setup : syntaxe moderne
<script setup> est la syntaxe syntaxique sugar introduite en Vue 3.2.
Elle réduit le boilerplate et expose automatiquement toutes les variables au template
sans avoir besoin de les retourner manuellement.
Structure d'un composant avec script setup
<!-- components/UserCard.vue -->
<template>
<!-- Le template accède directement aux variables du script setup -->
<div class="card">
<h2>{{ fullName }}</h2>
<p>Age : {{ age }}</p>
<button @click="incrementAge">Vieillir</button>
</div>
</template>
<script setup lang="ts">
// lang="ts" active TypeScript dans le composant
import { ref, computed } from 'vue';
// Toutes les variables sont automatiquement exposées au template
// Pas besoin de return {} !
const firstName = ref('Alice');
const lastName = ref('Martin');
const age = ref(28);
// computed() crée une valeur dérivée — recalculée quand ses dépendances changent
const fullName = computed(() => `${firstName.value} ${lastName.value}`);
// Fonction normale — aussi exposée automatiquement
const incrementAge = () => age.value++;
</script>
Comparaison avec l'ancienne syntaxe setup()
<!-- Ancienne syntaxe Vue 3 sans <script setup> -->
<script lang="ts">
import { ref, computed, defineComponent } from 'vue';
export default defineComponent({
setup() {
const age = ref(28);
const doubleAge = computed(() => age.value * 2);
// OBLIGATOIRE : retourner ce que le template doit voir
return { age, doubleAge };
},
});
</script>
<!-- Nouvelle syntaxe <script setup> — beaucoup plus concis -->
<script setup lang="ts">
import { ref, computed } from 'vue';
const age = ref(28);
const doubleAge = computed(() => age.value * 2);
// Pas de return ! Tout est exposé automatiquement
</script>
lang="ts" à votre
<script setup> pour bénéficier de TypeScript. Volar (l'extension VS Code
officielle Vue) fournit l'auto-complétion et la vérification de types dans les templates.
Réactivité de base : ref et reactive
Le système de réactivité de Vue 3 est construit sur deux primitives fondamentales :
ref() pour les valeurs simples et reactive() pour les objets.
Comprendre leur différence et quand utiliser l'un plutôt que l'autre est crucial.
ref() : valeurs primitives et simples
<script setup lang="ts">
import { ref } from 'vue';
// ref() encapsule n'importe quelle valeur dans un objet réactif
const count = ref(0); // number
const name = ref('Alice'); // string
const active = ref(false); // boolean
const tags = ref<string[]>([]); // array
// ⚠️ Dans le script : accès via .value OBLIGATOIRE
console.log(count.value); // 0
count.value++; // mutation
count.value = 10; // remplacement
// ✅ Dans le template : .value est automatique, pas besoin de l'écrire
// <p>{{ count }}</p> fonctionne directement
</script>
<template>
<!-- Vue unwrap automatiquement ref dans le template -->
<p>{{ count }} — {{ name }}</p>
<button @click="count++">+1</button>
</template>
reactive() : objets complexes
<script setup lang="ts">
import { reactive } from 'vue';
// reactive() rend un objet entier réactif (en profondeur)
// Avantage : pas de .value — accès direct aux propriétés
const user = reactive({
name: 'Alice',
age: 28,
address: {
city: 'Paris',
zip: '75001',
},
});
// Mutation directe — pas de .value !
user.name = 'Bob';
user.age++;
user.address.city = 'Lyon'; // Réactivité en profondeur ✅
// ⚠️ PIÈGE : déstructuration casse la réactivité !
const { name, age } = user; // ❌ name et age ne sont plus réactifs !
// ✅ Solution : utiliser toRefs() pour déstructurer de façon sécurisée
import { toRefs } from 'vue';
const { name: nameRef, age: ageRef } = toRefs(user); // Réactifs ✅
</script>
ref vs reactive : guide de choix
// ✅ Utilisez ref() pour :
const loading = ref(false); // Primitives (string, number, boolean)
const items = ref([]); // Tableaux (remplacement complet facile)
const userData = ref(null); // Valeurs nullable (null → objet)
// ✅ Utilisez reactive() pour :
const form = reactive({ // Formulaires (mutations fréquentes)
email: '',
password: '',
remember: false,
});
const state = reactive({ // État local complexe d'un composant
loading: false,
error: null,
data: [],
});
// 🏆 Conseil pratique : utilisez ref() par défaut
// Les équipes Vue expérimentées préfèrent ref() partout
// pour la cohérence (toujours .value en dehors du template)
.value dans le
script. Vue affiche silencieusement la valeur Proxy au lieu de la vraie
valeur. Si votre affichage montre [object Object] ou semble figé,
vérifiez vos .value.
computed() et watch()
computed() : valeurs dérivées mémorisées
computed() crée une valeur réactive dérivée d'autres valeurs réactives.
Elle est mémorisée : recalculée uniquement quand ses dépendances changent.
<script setup lang="ts">
import { ref, computed } from 'vue';
const firstName = ref('Alice');
const lastName = ref('Martin');
const price = ref(100);
const taxRate = ref(0.2);
// computed simple — lecture seule
const fullName = computed(() => `${firstName.value} ${lastName.value}`);
// computed avec calcul — recalculé seulement si price ou taxRate change
const priceWithTax = computed(() => price.value * (1 + taxRate.value));
// computed modifiable (get + set)
const fullNameWritable = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (newValue: string) => {
const [first, ...rest] = newValue.split(' ');
firstName.value = first;
lastName.value = rest.join(' ');
},
});
// Utilisation du computed writable :
// fullNameWritable.value = 'Bob Dupont';
// → firstName.value === 'Bob', lastName.value === 'Dupont'
</script>
watch() : réagir aux changements
<script setup lang="ts">
import { ref, watch, watchEffect } from 'vue';
const searchQuery = ref('');
const userId = ref(1);
// watch(source, callback) — réagit quand searchQuery change
watch(searchQuery, (newValue, oldValue) => {
console.log(`Recherche: "${oldValue}" → "${newValue}"`);
// Déclencher un appel API, filtrer une liste, etc.
});
// Watcher avec options
watch(searchQuery, async (query) => {
if (query.length < 2) return; // Ignore les requêtes trop courtes
const results = await searchAPI(query);
// Mettre à jour les résultats...
}, {
debounce: 300, // Attend 300ms après la dernière frappe (Vue 3.4+)
immediate: false, // Ne pas déclencher au montage
});
// watch plusieurs sources à la fois
watch([userId, searchQuery], ([newId, newQuery]) => {
console.log(`User ${newId} cherche "${newQuery}"`);
});
// watchEffect : détecte automatiquement les dépendances
// Équivalent à watch mais sans lister explicitement les sources
watchEffect(() => {
// Toutes les refs lues ici deviennent des dépendances automatiquement
console.log(`User ${userId.value} cherche "${searchQuery.value}"`);
});
</script>
Quand utiliser computed vs watch
// ✅ Utilisez computed() pour :
// Dériver une valeur affichée dans le template
const filteredItems = computed(() =>
items.value.filter(item => item.active)
);
// ✅ Utilisez watch() pour :
// Déclencher des effets de bord (API calls, localStorage, analytics)
watch(userId, async (id) => {
userData.value = await fetchUser(id); // Effet de bord
});
// ❌ Ne pas faire ça — modifier le state dans computed
const badComputed = computed(() => {
sideEffectRef.value = 'changed'; // ❌ Interdit dans computed !
return someValue.value * 2;
});
watchEffect est idéal pour les
effets simples où les dépendances sont évidentes. watch est préférable
quand vous avez besoin de l'ancienne valeur, de déclencher au montage de façon
contrôlée, ou de regarder des sources spécifiques.
Lifecycle hooks dans la Composition API
Vue 3 expose les lifecycle hooks comme des fonctions importables. Leur comportement
est identique à l'Options API, mais ils s'intègrent naturellement dans
<script setup>.
Correspondance Options API → Composition API
| Options API | Composition API | Moment d'exécution |
|---|---|---|
| beforeCreate / created | Le corps de setup() | Avant le rendu |
| beforeMount | onBeforeMount | Avant insertion dans le DOM |
| mounted | onMounted | Après insertion dans le DOM |
| beforeUpdate | onBeforeUpdate | Avant re-rendu réactif |
| updated | onUpdated | Après re-rendu réactif |
| beforeUnmount | onBeforeUnmount | Avant destruction |
| unmounted | onUnmounted | Après destruction (cleanup) |
Exemple complet avec lifecycle
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, onUpdated } from 'vue';
const users = ref([]);
const loading = ref(false);
const timer = ref<number | null>(null);
// onMounted : appelé quand le composant est dans le DOM
// C'est ici que vous faites les appels API, initialisez des libs JS...
onMounted(async () => {
loading.value = true;
try {
const res = await fetch('/api/users');
users.value = await res.json();
} finally {
loading.value = false;
}
// Démarre un timer (ex: polling toutes les 30s)
timer.value = setInterval(refreshData, 30_000);
});
// onBeforeUnmount : nettoyage OBLIGATOIRE avant destruction
// Évite les fuites mémoire et les erreurs "component unmounted"
onBeforeUnmount(() => {
if (timer.value) {
clearInterval(timer.value); // ✅ Nettoyage du timer
}
// Aussi : supprimer event listeners, annuler fetch, déconnecter WebSocket...
});
const refreshData = async () => {
const res = await fetch('/api/users');
users.value = await res.json();
};
</script>
Props et emits typés
Dans <script setup>, les props et emits se déclarent avec
defineProps et defineEmits — deux macros compilées qui
bénéficient d'une inférence TypeScript complète.
Props avec TypeScript
<!-- components/ArticleCard.vue -->
<script setup lang="ts">
// Déclaration des props avec types TypeScript
interface Props {
title: string;
author: string;
date: string;
tags?: string[]; // Optionnel
featured?: boolean; // Optionnel avec valeur par défaut
}
// defineProps infère les types depuis l'interface
const props = defineProps<Props>();
// withDefaults() pour les valeurs par défaut des props optionnelles
const propsWithDefaults = withDefaults(defineProps<Props>(), {
tags: () => [], // Fonctions pour les tableaux/objets (évite le partage)
featured: false,
});
</script>
<template>
<article :class="{ 'featured': featured }">
<h2>{{ title }}</h2>
<p>Par {{ author }} — {{ date }}</p>
<div v-if="tags.length">
<span v-for="tag in tags" :key="tag">{{ tag }}</span>
</div>
</article>
</template>
Emits typés
<!-- components/SearchInput.vue -->
<script setup lang="ts">
import { ref } from 'vue';
// defineEmits déclare les événements que le composant peut émettre
// avec les types des arguments pour chaque événement
const emit = defineEmits<{
search: [query: string]; // Événement "search" avec un string
clear: []; // Événement "clear" sans argument
filterChange: [filter: string, value: string]; // 2 arguments
}>();
const query = ref('');
const handleSearch = () => {
if (query.value.trim()) {
emit('search', query.value); // TypeScript vérifie que c'est bien un string
}
};
const handleClear = () => {
query.value = '';
emit('clear'); // Pas d'argument — TypeScript le sait
};
</script>
<template>
<div class="search-wrapper">
<input v-model="query" @keyup.enter="handleSearch" />
<button @click="handleSearch">Chercher</button>
<button @click="handleClear">Effacer</button>
</div>
</template>
<!-- Utilisation dans le parent -->
<SearchInput
@search="onSearch"
@clear="onClear"
/>
<script setup lang="ts">
// TypeScript sait que onSearch reçoit un string (inféré depuis defineEmits)
const onSearch = (query: string) => {
console.log('Recherche :', query);
};
const onClear = () => {
console.log('Recherche effacée');
};
</script>
provide / inject : partage de données
provide et inject permettent de partager des données entre
un composant parent et ses descendants, quelle que soit la profondeur — sans prop drilling.
C'est l'alternative Vue au Context API de React.
<!-- components/ThemeProvider.vue — Composant parent qui fournit les données -->
<script setup lang="ts">
import { ref, provide, readonly } from 'vue';
// Clé unique pour éviter les conflits de noms
// Utiliser Symbol() en production pour encore plus de sécurité
const THEME_KEY = 'theme';
const isDark = ref(false);
const toggleTheme = () => (isDark.value = !isDark.value);
// provide(clé, valeur) — disponible pour tous les descendants
// readonly() empêche les descendants de muter directement
provide(THEME_KEY, {
isDark: readonly(isDark), // Lecture seule pour les enfants
toggleTheme, // Fonction pour muter (contrôlé par le parent)
});
</script>
<template>
<div :class="{ 'dark-mode': isDark }">
<slot />
</div>
</template>
<!-- components/ThemeToggleButton.vue — Descendant qui injecte les données -->
<script setup lang="ts">
import { inject } from 'vue';
// inject(clé, valeurParDéfaut) — récupère la valeur fournie par un ancêtre
const theme = inject('theme');
// ⚠️ inject peut retourner undefined si aucun parent n'a fourni la valeur
// Toujours prévoir un fallback ou vérifier avant utilisation
if (!theme) {
throw new Error('ThemeToggleButton doit être dans un ThemeProvider');
}
</script>
<template>
<button @click="theme.toggleTheme">
{{ theme.isDark ? '☀️ Mode clair' : '🌙 Mode sombre' }}
</button>
</template>
Pattern avec InjectionKey (TypeScript)
<!-- composables/useTheme.ts — Clé typée pour la sécurité TypeScript -->
import { InjectionKey, Ref } from 'vue';
// Interface du contexte partagé
interface ThemeContext {
isDark: Readonly<Ref<boolean>>;
toggleTheme: () => void;
}
// InjectionKey garantit que provide et inject utilisent le même type
export const THEME_KEY: InjectionKey<ThemeContext> = Symbol('theme');
// Dans le parent :
// provide(THEME_KEY, { isDark: readonly(isDark), toggleTheme });
// Dans l'enfant :
// const theme = inject(THEME_KEY); // TypeScript connaît le type !
Premiers composables réutilisables
Un composable est une fonction qui utilise la Composition API pour encapsuler et réutiliser de la logique avec état entre plusieurs composants. C'est la réponse de Vue aux mixins (Options API) et aux hooks personnalisés de React.
Composable useFetch : requêtes API réutilisables
// composables/useFetch.ts
import { ref, watchEffect } from 'vue';
// Convention : les composables commencent par "use"
export function useFetch<T>(url: string) {
const data = ref<T | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);
// watchEffect re-exécute quand url change (si c'est une ref)
watchEffect(async () => {
loading.value = true;
error.value = null;
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
data.value = await res.json();
} catch (err) {
error.value = (err as Error).message;
} finally {
loading.value = false;
}
});
return { data, loading, error }; // Refs exposées au composant
}
<!-- components/UserProfile.vue — Utilise le composable -->
<script setup lang="ts">
import { useFetch } from '@/composables/useFetch';
interface User { id: number; name: string; email: string; }
// Réutilisation simple — la logique de fetch est abstraite
const { data: user, loading, error } = useFetch<User>('/api/user/1');
</script>
<template>
<div>
<p v-if="loading">Chargement...</p>
<p v-else-if="error" role="alert">Erreur : {{ error }}</p>
<div v-else-if="user">
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
</div>
</div>
</template>
Composable useLocalStorage
// composables/useLocalStorage.ts
import { ref, watch } from 'vue';
// Synchronise une ref avec localStorage automatiquement
export function useLocalStorage<T>(key: string, defaultValue: T) {
// Initialise depuis localStorage ou la valeur par défaut
const stored = localStorage.getItem(key);
const value = ref<T>(stored ? JSON.parse(stored) : defaultValue);
// Persiste dans localStorage à chaque changement de la ref
watch(value, (newValue) => {
localStorage.setItem(key, JSON.stringify(newValue));
}, { deep: true }); // deep: true pour les objets/tableaux
return value;
}
// Utilisation :
// const theme = useLocalStorage('theme', 'light');
// const cart = useLocalStorage('cart', []);
// Toute modification de theme.value ou cart.value est automatiquement sauvée
Checklist Composition API
Syntaxe et réactivité
- Utiliser
<script setup lang="ts">dans tous les composants - Importer ref, reactive, computed depuis 'vue'
- Toujours accéder via
.valuedans le script (pas dans le template) - Préférer ref() pour les primitives, reactive() pour les formulaires
- Ne jamais déstructurer reactive() sans toRefs()
Props, emits et lifecycle
- Typer les props avec defineProps<Interface>()
- Déclarer les emits avec defineEmits et leurs types
- Utiliser withDefaults() pour les valeurs par défaut des props
- Nettoyer les ressources dans onBeforeUnmount (timers, listeners)
- Faire les appels API dans onMounted, pas dans le corps de setup
Organisation et réutilisation
- Regrouper la logique par fonctionnalité (pas par type)
- Extraire la logique répétée dans des composables (useXxx.ts)
- Utiliser provide/inject pour les données partagées localement
- Nommer les composables avec le préfixe "use"
- Retourner des refs depuis les composables (pas des valeurs brutes)
La Composition API est bien plus qu'une nouvelle syntaxe — c'est une façon de penser différemment la structure des composants Vue. En organisant le code par fonctionnalité et en extrayant la logique dans des composables, vous obtenez des composants plus petits, plus testables et plus faciles à maintenir. La courbe d'apprentissage est légèrement plus haute qu'avec l'Options API, mais l'investissement se rentabilise rapidement dès que les composants commencent à grossir.