Comprenez la différence entre ref() et reactive() en Vue 3 : pièges, toRefs, shallowRef, readonly et guide de choix pour débutants.
Le système de réactivité Vue 3
Vue 3 a entièrement réécrit son système de réactivité en utilisant les
Proxies JavaScript (ES6) au lieu de Object.defineProperty
de Vue 2. Ce changement de fond explique pourquoi certaines limitations de Vue 2
(impossible d'ajouter des propriétés réactives après création, tableaux mal détectés)
ont disparu.
Comment fonctionne la réactivité Vue 3
// Vue 3 encapsule vos données dans un Proxy JavaScript
// Le Proxy intercepte les lectures (get) et les écritures (set)
const raw = { count: 0 };
// Vue crée un Proxy autour de votre objet
const proxy = new Proxy(raw, {
get(target, key) {
// Quand on lit une propriété : Vue enregistre le composant comme dépendant
trackDependency(key);
return target[key];
},
set(target, key, value) {
// Quand on écrit : Vue déclenche le re-rendu des composants dépendants
target[key] = value;
triggerUpdate(key);
return true;
},
});
// C'est exactement ce que fait reactive() sous le capot
// ref() fait la même chose, mais encapsule dans { value: proxy }
Les primitives de réactivité disponibles
| Fonction | Utilisation | Accès | Profondeur |
|---|---|---|---|
ref() |
Toute valeur (primitive ou objet) | .value requis |
Profonde |
reactive() |
Objets / tableaux seulement | Direct | Profonde |
shallowRef() |
Objets volumineux (perf) | .value requis |
Superficielle |
shallowReactive() |
Objets volumineux (perf) | Direct | Superficielle |
readonly() |
Données en lecture seule | Direct | Profonde |
computed() |
Valeurs dérivées mémorisées | .value requis |
— |
ref() en profondeur
ref() crée un objet réactif avec une seule propriété value.
C'est la primitive universelle de Vue 3 — elle accepte n'importe quel type : string,
number, boolean, array, object ou même null.
Comportement de ref() avec différents types
<script setup lang="ts">
import { ref, isRef } from 'vue';
// --- Primitives ---
const count = ref(0); // RefImpl<number>
const name = ref('Alice'); // RefImpl<string>
const active = ref(true); // RefImpl<boolean>
const nothing = ref<string | null>(null); // RefImpl<string | null>
// --- Tableaux ---
const items = ref<string[]>([]);
items.value.push('nouveau'); // ✅ Réactif — mutations détectées
items.value = ['reset']; // ✅ Réactif — remplacement complet
// --- Objets ---
const user = ref({ name: 'Bob', age: 30 });
user.value.name = 'Alice'; // ✅ Réactif en profondeur
user.value = { name: 'Charlie', age: 25 }; // ✅ Remplacement complet
// isRef() permet de vérifier si une valeur est une ref
console.log(isRef(count)); // true
console.log(isRef(42)); // false
// unref() retourne .value si c'est une ref, sinon la valeur elle-même
// Utile dans les composables pour accepter ref OU valeur brute
import { unref } from 'vue';
function double(val: number | Ref<number>) {
return unref(val) * 2; // Fonctionne dans les deux cas
}
</script>
Unwrapping automatique dans le template
<!-- Vue "unwrap" automatiquement les refs dans le template -->
<!-- Pas besoin d'écrire count.value dans le HTML -->
<template>
<p>{{ count }}</p> <!-- ✅ Pas de .value -->
<p>{{ user.name }}</p> <!-- ✅ Accès direct aux propriétés -->
<p>{{ items.length }}</p> <!-- ✅ Méthodes tableau disponibles -->
<button @click="count++">+1</button> <!-- ✅ count++ fonctionne dans le template -->
</template>
<!-- ⚠️ L'unwrapping ne fonctionne PAS dans les expressions complexes -->
<!-- Exemple : si items est dans un objet reactive, l'unwrapping échoue -->
ref() avec TypeScript : typage explicite
<script setup lang="ts">
import { ref } from 'vue';
interface Article {
id: number;
title: string;
published: boolean;
}
// Type inféré automatiquement quand la valeur initiale suffit
const count = ref(0); // Inféré : Ref<number>
// Type explicite nécessaire pour null, tableaux vides, unions
const article = ref<Article | null>(null); // Démarre null, sera un Article
const articles = ref<Article[]>([]); // Tableau vide typé
const status = ref<'idle' | 'loading' | 'error'>('idle'); // Union
// Mutation typée — TypeScript vérifie la cohérence
article.value = { id: 1, title: 'Mon article', published: true }; // ✅
articles.value.push({ id: 2, title: 'Second', published: false }); // ✅
// count.value = 'text'; // ❌ Erreur TypeScript
</script>
reactive() en profondeur
reactive() rend un objet entier réactif en le wrappant dans un Proxy.
Contrairement à ref(), il n'y a pas de propriété .value —
vous accédez directement aux propriétés. Mais cette simplicité apparente cache
plusieurs pièges importants.
reactive() : accès direct et réactivité profonde
<script setup lang="ts">
import { reactive } from 'vue';
// reactive() fonctionne sur les objets et tableaux
const state = reactive({
user: {
name: 'Alice',
preferences: {
theme: 'dark', // Réactivité profonde (nested)
language: 'fr',
},
},
items: ['item1', 'item2'],
count: 0,
});
// Mutations directes — pas de .value !
state.count++;
state.user.name = 'Bob';
state.user.preferences.theme = 'light'; // ✅ Profondeur infinie
state.items.push('item3'); // ✅ Méthodes tableau réactives
// Ajout dynamique de propriétés — autorisé en Vue 3 (impossible en Vue 2)
state.newProperty = 'valeur'; // ✅ Réactif automatiquement avec Proxy
</script>
Limitation : reactive() ne peut pas wraper les primitives
<script setup lang="ts">
import { reactive } from 'vue';
// ❌ reactive() NE FONCTIONNE PAS sur les primitives
const count = reactive(0); // ❌ Retourne 0 (pas réactif)
const name = reactive('Bob'); // ❌ Retourne 'Bob' (pas réactif)
const flag = reactive(false); // ❌ Retourne false (pas réactif)
// ✅ reactive() FONCTIONNE sur les objets et tableaux
const state = reactive({ count: 0, name: 'Bob', flag: false });
// ❌ reactive() ne peut pas non plus wrapper une ref
const myRef = ref(0);
const wrapped = reactive(myRef); // ❌ Comportement inattendu — ne pas faire
// ✅ Solution : wraper dans un objet
const stateWithRef = reactive({ value: ref(0) }); // ✅ mais sans intérêt
// Préférez simplement ref(0) directement
</script>
Réassignation : le piège principal de reactive()
<script setup lang="ts">
import { reactive } from 'vue';
const state = reactive({ count: 0, name: 'Alice' });
// ❌ PIÈGE MAJEUR : réassigner state casse la réactivité !
// state est une référence constante vers le Proxy
// La réassigner ne met pas à jour le Proxy — le template ne se rafraîchit pas
state = reactive({ count: 99, name: 'Bob' }); // ❌ Casse la réactivité !
// ✅ Solution 1 : muter les propriétés une par une
state.count = 99;
state.name = 'Bob';
// ✅ Solution 2 : utiliser Object.assign() pour fusionner
Object.assign(state, { count: 99, name: 'Bob' });
// ✅ Solution 3 : passer à ref() si le remplacement complet est nécessaire
const stateRef = ref({ count: 0, name: 'Alice' });
stateRef.value = { count: 99, name: 'Bob' }; // ✅ ref accepte le remplacement
</script>
Pièges courants et erreurs à éviter
Piège 1 : déstructuration de reactive()
<script setup lang="ts">
import { reactive } from 'vue';
const user = reactive({ name: 'Alice', age: 28 });
// ❌ PROBLÈME : déstructuration perd la réactivité
// name et age sont des valeurs primitives copiées, plus connectées au Proxy
const { name, age } = user;
name; // 'Alice' — jamais mis à jour si user.name change !
// ✅ Solution 1 : ne pas déstructurer, accéder via user.name
// ✅ Solution 2 : utiliser toRefs() (voir section suivante)
// ✅ Solution 3 : utiliser ref() dès le départ si la déstructuration est nécessaire
</script>
Piège 2 : passer reactive() en prop
<script setup lang="ts">
import { reactive } from 'vue';
const config = reactive({ theme: 'dark', lang: 'fr' });
// ❌ Passer une propriété primitive extraite casse la réactivité
// <MyComponent :theme="config.theme" />
// → Si config.theme change, le composant enfant ne se met pas à jour
// car il a reçu la valeur primitive 'dark', pas la référence réactive
// ✅ Solution 1 : passer l'objet entier
// <MyComponent :config="config" /> — le composant enfant voit les changements
// ✅ Solution 2 : utiliser toRef() pour extraire une propriété réactive
import { toRef } from 'vue';
const themeRef = toRef(config, 'theme'); // Ref<string> connectée à config.theme
// <MyComponent :theme="themeRef" /> — réactif ✅
</script>
Piège 3 : modifier .value d'un objet reactive (n'est pas nécessaire)
<script setup lang="ts">
import { ref, reactive } from 'vue';
// ❌ Confusion fréquente : essayer d'accéder à .value sur reactive
const state = reactive({ count: 0 });
state.value; // undefined — reactive n'a pas de .value !
// ✅ Avec reactive : accès direct
state.count; // 0 ✅
// ❌ Autre confusion : oublier .value sur ref dans le script
const count = ref(0);
count; // RefImpl — PAS le nombre !
count.value; // 0 ✅
// Règle mnémotechnique :
// reactive → PAS de .value (objet direct)
// ref → .value dans le script, pas dans le template
</script>
Piège 4 : ref() dans reactive() — unwrapping automatique
<script setup lang="ts">
import { ref, reactive } from 'vue';
const count = ref(0);
// Quand une ref est mise dans un reactive, Vue l'unwrap automatiquement
const state = reactive({
count, // La ref count est insérée ici
});
// ✅ Dans ce contexte, state.count vaut directement la valeur (0)
// Pas besoin de state.count.value !
state.count; // 0 (unwrapped automatiquement)
state.count = 5; // Met à jour count.value automatiquement
// L'unwrapping ne se produit PAS pour les tableaux réactifs
const arr = reactive([ref(0), ref(1)]);
arr[0].value; // ⚠️ .value requis pour les refs dans un tableau réactif
</script>
.value avec ref, déstructurer reactive sans
toRefs, ou réassigner un objet reactive au lieu de muter ses propriétés. Mémorisez
ces trois patterns et vous éviterez 90% des problèmes.
toRef() et toRefs() : ponts entre les deux
toRef() et toRefs() sont des utilitaires qui créent des
refs connectées à un objet reactive. Ils permettent la déstructuration sans perdre
la réactivité — un besoin fréquent dans les composables.
toRef() : extraire une propriété réactive
<script setup lang="ts">
import { reactive, toRef } from 'vue';
const state = reactive({
loading: false,
user: { name: 'Alice', age: 28 },
count: 0,
});
// toRef(objet, 'propriété') crée une Ref connectée à la propriété
const loadingRef = toRef(state, 'loading');
const countRef = toRef(state, 'count');
// loadingRef et state.loading sont synchronisés dans les deux sens
loadingRef.value = true; // → state.loading devient true
state.loading = false; // → loadingRef.value devient false
// Utilité principale : passer une propriété reactive en prop ou composable
// sans perdre la réactivité
console.log(loadingRef.value); // false ✅ (synchronisé avec state)
</script>
toRefs() : déstructurer sans perdre la réactivité
<script setup lang="ts">
import { reactive, toRefs } from 'vue';
const state = reactive({
firstName: 'Alice',
lastName: 'Martin',
age: 28,
});
// ❌ Déstructuration classique — perd la réactivité
const { firstName, lastName } = state;
// ✅ toRefs() convertit chaque propriété en Ref individuelle
// Toutes restent connectées à l'objet reactive original
const { firstName: firstRef, lastName: lastRef, age: ageRef } = toRefs(state);
// Modification via la ref → reflétée dans state
firstRef.value = 'Bob'; // state.firstName vaut maintenant 'Bob'
// Modification de state → reflétée dans la ref
state.lastName = 'Dupont'; // lastRef.value vaut maintenant 'Dupont'
</script>
<template>
<!-- Dans le template, les refs sont unwrapped automatiquement -->
<p>{{ firstRef }} {{ lastRef }}</p> <!-- Bob Dupont -->
</template>
Cas d'usage clé : retourner depuis un composable
// composables/useUserProfile.ts
import { reactive, toRefs } from 'vue';
export function useUserProfile() {
const state = reactive({
name: '',
email: '',
loading: false,
error: null as string | null,
});
const fetchProfile = async () => {
state.loading = true;
try {
const res = await fetch('/api/profile');
const data = await res.json();
state.name = data.name;
state.email = data.email;
} catch (e) {
state.error = 'Erreur de chargement';
} finally {
state.loading = false;
}
};
// ✅ toRefs() permet aux consommateurs de déstructurer sans perdre la réactivité
return {
...toRefs(state), // Expose name, email, loading, error comme des refs
fetchProfile,
};
}
// Dans le composant :
// const { name, email, loading, error, fetchProfile } = useUserProfile();
// name.value et email.value restent réactifs ✅
shallowRef et shallowReactive
Les versions "shallow" (superficielles) de ref et reactive ne trackent la réactivité qu'au premier niveau. Elles sont utiles pour optimiser les performances avec des structures de données volumineuses ou des objets immuables.
shallowRef() : réactivité superficielle
<script setup lang="ts">
import { shallowRef, triggerRef } from 'vue';
// shallowRef : seul le remplacement de .value déclenche le re-rendu
// Les mutations internes à l'objet ne sont PAS trackées
const bigList = shallowRef([
{ id: 1, name: 'Item 1', data: { /* beaucoup de données */ } },
// ... 10 000 éléments
]);
// ❌ Mutation interne NON réactive
bigList.value[0].name = 'Modifié'; // Le template NE se met PAS à jour
// ✅ Remplacement complet — réactif
bigList.value = [...bigList.value]; // Force le re-rendu
// ✅ triggerRef() force une mise à jour manuelle après mutation interne
bigList.value[0].name = 'Modifié';
triggerRef(bigList); // Déclenche le re-rendu manuellement
// Cas d'usage : listes non mutables (remplacées entièrement après fetch)
const fetchList = async () => {
bigList.value = await fetch('/api/items').then(r => r.json());
// Remplacement complet → shallowRef suffit, performances optimales
};
</script>
shallowReactive() : premier niveau seulement
<script setup lang="ts">
import { shallowReactive } from 'vue';
const state = shallowReactive({
count: 0, // ✅ Réactif (premier niveau)
user: {
name: 'Alice', // ❌ NON réactif (niveau imbriqué)
age: 28,
},
});
state.count++; // ✅ Déclenche le re-rendu
state.user.name = 'Bob'; // ❌ Pas de re-rendu (niveau imbriqué non tracké)
state.user = { name: 'Bob', age: 30 }; // ✅ Déclenche le re-rendu (premier niveau)
// Quand utiliser shallowReactive ?
// → Objets dont les propriétés de premier niveau changent souvent
// mais dont les objets imbriqués sont remplacés entièrement (pas mutés)
// → Optimisation pour de grosses structures de données
</script>
readonly() et computed() réactifs
readonly() : données en lecture seule
<script setup lang="ts">
import { reactive, ref, readonly } from 'vue';
const mutableState = reactive({ count: 0, name: 'Alice' });
// readonly() crée une version non-mutable du reactive ou ref
// Utile pour protéger des données partagées via provide/inject
const readonlyState = readonly(mutableState);
readonlyState.count++; // ❌ Warning Vue : "Set operation failed: target is readonly"
mutableState.count++; // ✅ Source originale encore mutable → readonlyState se met à jour
// Pattern classique avec provide/inject :
// Le parent mute via mutableState
// Les enfants injectent readonlyState — ils voient les changements sans pouvoir muter
// readonly() fonctionne aussi avec les refs
const count = ref(0);
const readCount = readonly(count);
readCount.value++; // ❌ Warning — lecture seule
count.value++; // ✅ Source originale — readCount se met à jour
</script>
computed() : valeurs réactives dérivées
<script setup lang="ts">
import { ref, reactive, computed } from 'vue';
const items = ref([
{ id: 1, name: 'Pomme', price: 1.5, category: 'fruit', active: true },
{ id: 2, name: 'Pain', price: 2.0, category: 'bread', active: true },
{ id: 3, name: 'Banane', price: 0.9, category: 'fruit', active: false },
{ id: 4, name: 'Croissant', price: 1.2, category: 'bread', active: true },
]);
const filter = ref('all'); // 'all' | 'fruit' | 'bread'
const cart = ref<number[]>([]); // IDs des items dans le panier
// computed : filtrée + mémorisée — recalculée uniquement si items ou filter change
const filteredItems = computed(() =>
filter.value === 'all'
? items.value.filter(i => i.active)
: items.value.filter(i => i.active && i.category === filter.value)
);
// computed chaîné — dépend de filteredItems (lui-même computed)
const totalPrice = computed(() =>
filteredItems.value.reduce((sum, item) => sum + item.price, 0)
);
// computed writable — utile pour les champs de formulaire transformés
const filterUppercase = computed({
get: () => filter.value.toUpperCase(),
set: (val: string) => { filter.value = val.toLowerCase(); },
});
</script>
<template>
<div>
<select v-model="filter">
<option value="all">Tout</option>
<option value="fruit">Fruits</option>
<option value="bread">Pains</option>
</select>
<p>{{ filteredItems.length }} articles — Total : {{ totalPrice.toFixed(2) }} €</p>
</div>
</template>
Guide de choix : ref ou reactive ?
C'est LA question que tous les développeurs Vue 3 se posent. Il n'existe pas de réponse universelle, mais voici les critères et conventions qui font consensus dans la communauté Vue.
Critères de choix
// ✅ Choisissez ref() quand :
// 1. Valeur primitive
const count = ref(0);
const message = ref('');
const isOpen = ref(false);
// 2. Valeur nullable (peut être null au départ)
const user = ref<User | null>(null);
const article = ref<Article | null>(null);
// 3. Tableau (remplacement complet fréquent après fetch)
const items = ref<Item[]>([]);
// items.value = await fetch... → remplacement complet ✅
// 4. Le composable doit être déstructurable
// Retourner des refs depuis les composables
export function useFetch() {
const data = ref(null);
const loading = ref(false);
return { data, loading }; // ✅ Déstructuration réactive
}
// ✅ Choisissez reactive() quand :
// 1. Formulaire (mutations fréquentes, objet cohérent)
const loginForm = reactive({
email: '',
password: '',
rememberMe: false,
});
// 2. État local d'UI complexe (plusieurs propriétés liées)
const uiState = reactive({
sidebarOpen: false,
modalOpen: false,
activeTab: 'home',
scrollPosition: 0,
});
// 3. Objet métier avec sous-objets mutés ensemble
const pagination = reactive({
page: 1,
perPage: 20,
total: 0,
get totalPages() { return Math.ceil(this.total / this.perPage); }
});
Convention "ref partout" (approche moderne)
// De nombreuses équipes Vue 3 adoptent ref() pour TOUT
// Avantage : règle simple, cohérence totale dans le code
<script setup lang="ts">
import { ref, computed } from 'vue';
// Tout en ref — y compris les objets
const form = ref({
email: '',
password: '',
});
const user = ref({
name: 'Alice',
profile: {
bio: '',
avatar: '',
},
});
// Mutations via .value
form.value.email = 'alice@example.com';
user.value.profile.bio = 'Développeuse Vue 3';
// Remplacement facile si nécessaire
form.value = { email: '', password: '' }; // Reset du formulaire
</script>
ref() pour tout. Vous éviterez les pièges de reactive (déstructuration,
réassignation) et la règle est simple à retenir : toujours .value dans
le script. Adoptez reactive pour les formulaires une fois à l'aise avec le système.
Checklist réactivité Vue 3
ref()
- Utiliser pour primitives, valeurs nullables et tableaux
- Toujours
.valuedans le script (jamais dans le template) - Typer explicitement les valeurs nullable :
ref<User | null>(null) - Utiliser
isRef()pour vérifier dans les composables - Utiliser
unref()dans les fonctions qui acceptent ref OU valeur
reactive()
- Ne jamais déstructurer sans toRefs()
- Ne jamais réassigner — muter les propriétés avec Object.assign()
- Ne pas wraper les primitives (utiliser ref à la place)
- Utiliser pour les formulaires et états d'UI groupés
- Retourner avec toRefs() depuis les composables
Performances
- Utiliser shallowRef/shallowReactive uniquement après avoir mesuré
- Encapsuler les calculs dérivés dans computed() (pas dans le template)
- Protéger les données partagées avec readonly()
- Nettoyer les watchers avec
const stop = watch(...); stop()
.value ?
(2) avez-vous déstructuré un reactive sans toRefs ? (3) avez-vous réassigné un
reactive au lieu de muter ? Ces trois causes couvrent la quasi-totalité des bugs
de réactivité Vue 3.
La maîtrise de ref() et reactive() est le socle de tout
développement Vue 3 efficace. Ces deux primitives semblent simples en surface, mais
recèlent des subtilités qui trippent régulièrement même les développeurs expérimentés.
La clé est de comprendre le modèle mental : ref encapsule dans un wrapper avec
.value, reactive transforme un objet entier en Proxy. Une fois ce
modèle ancré, les comportements apparemment étranges deviennent logiques.