Maîtrisez les composables Vue 3 pour réutiliser la logique entre composants : useFetch, useForm, useLocalStorage et composition avancée.
Pourquoi les composables ?
Imaginez que vous avez besoin de la même logique de chargement de données dans cinq composants différents. Avec l'Options API de Vue 2, la solution était les mixins — mais elles posaient des problèmes sérieux : conflits de noms, origine des propriétés opaque, impossible à typer correctement avec TypeScript.
La Composition API de Vue 3 résout tout cela avec les composables : des fonctions ordinaires qui utilisent les APIs Vue (ref, computed, watch…) pour encapsuler et partager de la logique avec état entre plusieurs composants.
Mixins vs Composables
// ❌ Mixin Vue 2 — source des problèmes
export const fetchMixin = {
data() {
return { data: null, loading: false }; // Conflit si un autre mixin a aussi "data"
},
methods: {
async fetchData(url) { /* ... */ }
},
// D'où viennent "data" et "loading" dans le composant ? Mystère !
};
// ✅ Composable Vue 3 — explicite, typé, composable
export function useFetch(url: string) {
const data = ref(null);
const loading = ref(false);
// On sait exactement ce que ça retourne — pas de magie
return { data, loading };
}
// Dans le composant :
const { data, loading } = useFetch('/api/items');
// On voit d'où viennent data et loading — de useFetch ✅
Avantages des composables
| Critère | Mixins (Vue 2) | Composables (Vue 3) |
|---|---|---|
| Origine des propriétés | Opaque (magie) | Explicite (destructuring) |
| Conflits de noms | Fréquents | Impossibles (renommage possible) |
| TypeScript | Très difficile | Natif, inférence complète |
| Composition | Limitée | Naturelle (composables dans composables) |
| Testabilité | Faible | Facile (fonctions pures) |
| Tree-shaking | Non | Oui (imports nommés) |
use (useCounter, useFetch, useForm…). Cette convention est partagée
avec les hooks React et permet d'identifier immédiatement la nature d'une fonction.
Anatomie d'un composable
Un composable est simplement une fonction TypeScript qui commence par use,
utilise les APIs de réactivité Vue, et retourne des refs ou des fonctions. Voici
sa structure type et les règles à respecter.
// composables/useCounter.ts — Composable minimal bien structuré
import { ref, computed, onMounted, onUnmounted } from 'vue';
// Paramètres optionnels avec valeurs par défaut typées
interface UseCounterOptions {
initialValue?: number;
min?: number;
max?: number;
step?: number;
}
export function useCounter(options: UseCounterOptions = {}) {
// 1. Destructuration des options avec valeurs par défaut
const { initialValue = 0, min = -Infinity, max = Infinity, step = 1 } = options;
// 2. État réactif local au composable
const count = ref(initialValue);
// 3. Valeurs dérivées (computed)
const isMin = computed(() => count.value <= min);
const isMax = computed(() => count.value >= max);
// 4. Actions (fonctions pures qui mutent l'état)
const increment = () => {
count.value = Math.min(count.value + step, max);
};
const decrement = () => {
count.value = Math.max(count.value - step, min);
};
const reset = () => {
count.value = initialValue;
};
const setValue = (value: number) => {
count.value = Math.max(min, Math.min(max, value));
};
// 5. Retour explicite — ce que le composant peut utiliser
// Convention : retourner des refs (pas des valeurs brutes)
return {
count, // Ref<number> — mutable
isMin, // ComputedRef<boolean> — lecture seule
isMax, // ComputedRef<boolean> — lecture seule
increment, // () => void
decrement, // () => void
reset, // () => void
setValue, // (value: number) => void
};
}
<!-- Utilisation dans un composant -->
<script setup lang="ts">
import { useCounter } from '@/composables/useCounter';
// Déstructuration — on voit exactement ce qu'on utilise
const { count, isMin, isMax, increment, decrement, reset } = useCounter({
initialValue: 5,
min: 0,
max: 10,
step: 2,
});
// Renommage possible si conflit de noms dans le composant
const { count: volume } = useCounter({ initialValue: 50, min: 0, max: 100 });
</script>
<template>
<div>
<button @click="decrement" :disabled="isMin">-</button>
<span>{{ count }}</span>
<button @click="increment" :disabled="isMax">+</button>
<button @click="reset">Reset</button>
</div>
</template>
<script setup> ou dans une fonction setup(),
jamais dans une fonction ordinaire, un event handler ou une condition — exactement
comme les hooks React.
useApiFetch : requêtes HTTP réutilisables
Le composable de fetch est le cas d'usage le plus courant. Une version robuste gère le chargement, les erreurs, l'annulation des requêtes et le re-fetch automatique.
// composables/useApiFetch.ts
import { ref, watch, onUnmounted } from 'vue';
interface FetchState<T> {
data: Ref<T | null>;
loading: Ref<boolean>;
error: Ref<string | null>;
refetch: () => Promise<void>;
abort: () => void;
}
export function useApiFetch<T>(
url: string | Ref<string>, // Accepte une string OU une ref (URL dynamique)
options: RequestInit = {}
): FetchState<T> {
const data = ref<T | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
// AbortController pour annuler la requête si le composant est détruit
let controller: AbortController | null = null;
const fetchData = async () => {
// Annule la requête précédente si elle est encore en cours
controller?.abort();
controller = new AbortController();
loading.value = true;
error.value = null;
try {
const resolvedUrl = typeof url === 'string' ? url : url.value;
const res = await fetch(resolvedUrl, {
...options,
signal: controller.signal, // Lie la requête à notre controller
});
if (!res.ok) throw new Error(`Erreur ${res.status} : ${res.statusText}`);
data.value = await res.json() as T;
} catch (err) {
// Ignore les erreurs d'annulation (normales lors du démontage)
if ((err as Error).name !== 'AbortError') {
error.value = (err as Error).message;
}
} finally {
loading.value = false;
}
};
// Si url est une ref, re-fetche automatiquement quand elle change
if (typeof url !== 'string') {
watch(url, fetchData, { immediate: true });
} else {
fetchData(); // Fetch initial pour les URLs statiques
}
// Nettoyage : annule la requête si le composant est démontés
onUnmounted(() => controller?.abort());
return {
data,
loading,
error,
refetch: fetchData,
abort: () => controller?.abort(),
};
}
<!-- Utilisation avec URL statique -->
<script setup lang="ts">
import { useApiFetch } from '@/composables/useApiFetch';
interface User { id: number; name: string; email: string; }
// Fetch automatique au montage du composant
const { data: users, loading, error, refetch } = useApiFetch<User[]>('/api/users');
</script>
<template>
<div>
<button @click="refetch" :disabled="loading">Actualiser</button>
<p v-if="loading">Chargement...</p>
<p v-else-if="error" role="alert">{{ error }}</p>
<ul v-else>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
</div>
</template>
<!-- Utilisation avec URL dynamique (re-fetch auto quand userId change) -->
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useApiFetch } from '@/composables/useApiFetch';
const userId = ref(1);
const userUrl = computed(() => `/api/users/${userId.value}`);
// Quand userId change → userUrl change → fetch automatique
const { data: user, loading } = useApiFetch(userUrl);
</script>
useForm : validation de formulaire
La gestion des formulaires est répétitive par nature : état des champs, validation,
messages d'erreur, état de soumission. Un composable useForm centralise
tout cela de façon réutilisable.
// composables/useForm.ts
import { reactive, computed } from 'vue';
type ValidationRule<T> = (value: T) => string | null;
interface FieldConfig<T> {
initialValue: T;
rules?: ValidationRule<T>[];
}
type FormConfig<T extends Record<string, unknown>> = {
[K in keyof T]: FieldConfig<T[K]>;
};
export function useForm<T extends Record<string, unknown>>(config: FormConfig<T>) {
// Construit l'état initial à partir de la config
const fields = reactive(
Object.fromEntries(
Object.entries(config).map(([key, field]) => [key, field.initialValue])
)
) as T;
// Stocke les erreurs par champ
const errors = reactive<Partial<Record<keyof T, string>>>({});
const touched = reactive<Partial<Record<keyof T, boolean>>>({});
// Valide un champ individuel
const validateField = (key: keyof T): boolean => {
const fieldConfig = config[key];
if (!fieldConfig.rules) return true;
for (const rule of fieldConfig.rules) {
const errorMsg = rule(fields[key]);
if (errorMsg) {
errors[key] = errorMsg;
return false;
}
}
delete errors[key]; // Efface l'erreur si le champ est valide
return true;
};
// Marque un champ comme "touché" (pour afficher les erreurs)
const touch = (key: keyof T) => {
touched[key] = true;
validateField(key);
};
// Valide tout le formulaire
const validate = (): boolean => {
let valid = true;
for (const key of Object.keys(config) as (keyof T)[]) {
touched[key] = true;
if (!validateField(key)) valid = false;
}
return valid;
};
// Reset du formulaire aux valeurs initiales
const reset = () => {
for (const [key, field] of Object.entries(config)) {
(fields as Record<string, unknown>)[key] = field.initialValue;
delete (errors as Record<string, unknown>)[key];
delete (touched as Record<string, unknown>)[key];
}
};
const isValid = computed(() => Object.keys(errors).length === 0);
return { fields, errors, touched, touch, validate, reset, isValid };
}
<!-- Formulaire d'inscription utilisant useForm -->
<script setup lang="ts">
import { useForm } from '@/composables/useForm';
// Règles de validation réutilisables
const required = (label: string) => (v: string) =>
v.trim() ? null : `${label} est requis`;
const minLength = (n: number) => (v: string) =>
v.length >= n ? null : `Minimum ${n} caractères`;
const isEmail = (v: string) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? null : 'Email invalide';
// Config du formulaire avec règles de validation
const { fields, errors, touched, touch, validate, reset, isValid } = useForm({
email: {
initialValue: '',
rules: [required('Email'), isEmail],
},
password: {
initialValue: '',
rules: [required('Mot de passe'), minLength(8)],
},
name: {
initialValue: '',
rules: [required('Nom'), minLength(2)],
},
});
const handleSubmit = async () => {
if (!validate()) return; // Stoppe si formulaire invalide
// Soumission...
await registerUser(fields);
reset();
};
</script>
<template>
<form @submit.prevent="handleSubmit" novalidate>
<div class="field">
<label for="name">Nom</label>
<input
id="name"
v-model="fields.name"
@blur="touch('name')"
:class="{ 'is-invalid': touched.name && errors.name }"
/>
<span v-if="touched.name && errors.name" role="alert">{{ errors.name }}</span>
</div>
<div class="field">
<label for="email">Email</label>
<input
id="email"
type="email"
v-model="fields.email"
@blur="touch('email')"
:class="{ 'is-invalid': touched.email && errors.email }"
/>
<span v-if="touched.email && errors.email" role="alert">{{ errors.email }}</span>
</div>
<button type="submit" :disabled="!isValid">S'inscrire</button>
</form>
</template>
Composables d'accès au DOM
Les composables sont aussi très utiles pour abstraire les interactions avec le DOM (scroll, resize, intersection observer, media queries) et nettoyer les event listeners automatiquement au démontage.
useWindowSize : dimensions de la fenêtre
// composables/useWindowSize.ts
import { ref, onMounted, onUnmounted } from 'vue';
export function useWindowSize() {
const width = ref(window.innerWidth);
const height = ref(window.innerHeight);
// Gestionnaire de l'événement resize
const handler = () => {
width.value = window.innerWidth;
height.value = window.innerHeight;
};
// Ajoute le listener au montage
onMounted(() => window.addEventListener('resize', handler));
// ✅ Nettoyage obligatoire — évite les fuites mémoire
onUnmounted(() => window.removeEventListener('resize', handler));
return { width, height };
}
useIntersectionObserver : visibilité dans le viewport
// composables/useIntersectionObserver.ts
import { ref, onMounted, onUnmounted } from 'vue';
// Utile pour : lazy loading d'images, animations au scroll, infinite scroll
export function useIntersectionObserver(threshold = 0.1) {
const target = ref<HTMLElement | null>(null); // Ref du template
const isVisible = ref(false);
let observer: IntersectionObserver | null = null;
onMounted(() => {
if (!target.value) return;
observer = new IntersectionObserver(
([entry]) => {
isVisible.value = entry.isIntersecting;
},
{ threshold }
);
observer.observe(target.value);
});
onUnmounted(() => observer?.disconnect());
return { target, isVisible };
}
<!-- Utilisation pour une animation au scroll -->
<script setup lang="ts">
import { useIntersectionObserver } from '@/composables/useIntersectionObserver';
const { target, isVisible } = useIntersectionObserver(0.3);
</script>
<template>
<!-- ref="target" connecte l'élément DOM au composable -->
<section
ref="target"
:class="{ 'fade-in': isVisible }"
class="animated-section"
>
<h2>Visible dans le viewport : {{ isVisible }}</h2>
</section>
</template>
useLocalStorage : persistance automatique
// composables/useLocalStorage.ts
import { ref, watch } from 'vue';
export function useLocalStorage<T>(key: string, defaultValue: T) {
// Initialise depuis localStorage si disponible
const readFromStorage = (): T => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : defaultValue;
} catch {
return defaultValue; // Fallback si JSON invalide
}
};
const value = ref<T>(readFromStorage());
// Synchronise vers localStorage à chaque changement
watch(value, (newValue) => {
try {
localStorage.setItem(key, JSON.stringify(newValue));
} catch {
console.warn(`useLocalStorage: impossible de sauver la clé "${key}"`);
}
}, { deep: true }); // deep: true pour les objets imbriqués
// Fonction pour supprimer la clé
const remove = () => {
localStorage.removeItem(key);
value.value = defaultValue;
};
return { value, remove };
}
// Utilisation :
// const { value: theme } = useLocalStorage('theme', 'light');
// const { value: cart, remove: clearCart } = useLocalStorage('cart', []);
Gestion avancée de l'asynchrone
useAsync : wrapper pour toute opération async
// composables/useAsync.ts
import { ref } from 'vue';
// Composable générique pour encapsuler n'importe quelle opération async
export function useAsync<T, Args extends unknown[]>(
fn: (...args: Args) => Promise<T>
) {
const data = ref<T | null>(null);
const loading = ref(false);
const error = ref<Error | null>(null);
// execute() appelle la fonction async et gère les états
const execute = async (...args: Args): Promise<T | null> => {
loading.value = true;
error.value = null;
try {
data.value = await fn(...args);
return data.value;
} catch (err) {
error.value = err instanceof Error ? err : new Error(String(err));
return null;
} finally {
loading.value = false;
}
};
return { data, loading, error, execute };
}
<!-- Utilisation pour une action de soumission -->
<script setup lang="ts">
import { useAsync } from '@/composables/useAsync';
// Fonctions API
const createPost = (title: string, body: string) =>
fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, body }),
}).then(r => r.json());
const deletePost = (id: number) =>
fetch(`/api/posts/${id}`, { method: 'DELETE' }).then(r => r.json());
// Chaque opération a son propre état indépendant
const { loading: creating, error: createError, execute: create } = useAsync(createPost);
const { loading: deleting, error: deleteError, execute: del } = useAsync(deletePost);
const handleCreate = async () => {
const newPost = await create('Mon titre', 'Mon contenu');
if (newPost) console.log('Créé :', newPost);
};
</script>
<template>
<button @click="handleCreate" :disabled="creating">
{{ creating ? 'Création...' : 'Créer un post' }}
</button>
<p v-if="createError" role="alert">{{ createError.message }}</p>
</template>
Composer des composables entre eux
La vraie puissance des composables apparaît quand on les compose : un composable peut en utiliser d'autres, créant ainsi des abstractions de haut niveau à partir de briques de bas niveau.
// composables/usePaginatedFetch.ts
// Composable de haut niveau qui compose useApiFetch + logique pagination
import { ref, computed, watch } from 'vue';
import { useApiFetch } from './useApiFetch';
interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
perPage: number;
}
export function usePaginatedFetch<T>(baseUrl: string, perPage = 20) {
const page = ref(1);
const searchQuery = ref('');
// URL construite dynamiquement selon l'état courant
const url = computed(() => {
const params = new URLSearchParams({
page: page.value.toString(),
perPage: perPage.toString(),
...(searchQuery.value ? { q: searchQuery.value } : {}),
});
return `${baseUrl}?${params}`;
});
// Réutilise useApiFetch — qui gère déjà loading, error, abort
const { data, loading, error } = useApiFetch<PaginatedResponse<T>>(url);
// Valeurs dérivées depuis la réponse
const items = computed(() => data.value?.items ?? []);
const total = computed(() => data.value?.total ?? 0);
const totalPages = computed(() => Math.ceil(total.value / perPage));
const hasPrev = computed(() => page.value > 1);
const hasNext = computed(() => page.value < totalPages.value);
// Navigation
const prevPage = () => { if (hasPrev.value) page.value--; };
const nextPage = () => { if (hasNext.value) page.value++; };
const goToPage = (n: number) => {
page.value = Math.max(1, Math.min(n, totalPages.value));
};
// Reset à la page 1 quand la recherche change
watch(searchQuery, () => { page.value = 1; });
return {
items, loading, error, total,
page, totalPages, hasPrev, hasNext,
searchQuery, prevPage, nextPage, goToPage,
};
}
<!-- Composant de liste paginée ultra-simple grâce aux composables composés -->
<script setup lang="ts">
import { usePaginatedFetch } from '@/composables/usePaginatedFetch';
interface Article { id: number; title: string; date: string; }
const {
items: articles, loading, error, total,
page, totalPages, hasPrev, hasNext,
searchQuery, prevPage, nextPage,
} = usePaginatedFetch<Article>('/api/articles', 10);
</script>
<template>
<div>
<input v-model="searchQuery" placeholder="Rechercher..." />
<p>{{ total }} articles — page {{ page }}/{{ totalPages }}</p>
<p v-if="loading">Chargement...</p>
<ul v-else>
<li v-for="article in articles" :key="article.id">{{ article.title }}</li>
</ul>
<button @click="prevPage" :disabled="!hasPrev">Précédent</button>
<button @click="nextPage" :disabled="!hasNext">Suivant</button>
</div>
</template>
Conventions et qualité
Organisation des fichiers
// Structure recommandée pour les composables
src/
├── composables/
│ ├── useCounter.ts // Logique simple (état local)
│ ├── useApiFetch.ts // Logique réseau générique
│ ├── usePaginatedFetch.ts // Composition de useApiFetch
│ ├── useForm.ts // Logique formulaire générique
│ ├── useWindowSize.ts // DOM / Browser APIs
│ ├── useLocalStorage.ts // Persistance
│ └── useAuth.ts // Logique métier spécifique
└── components/
└── UserList.vue // Utilise les composables
Règles de qualité des composables
// ✅ BONNE PRATIQUE : retourner des refs (pas des valeurs brutes)
export function useCounter() {
const count = ref(0);
return { count }; // ✅ Ref — reactive dans le composant
}
// ❌ MAUVAISE PRATIQUE : retourner une valeur brute
export function useBadCounter() {
const count = ref(0);
return { count: count.value }; // ❌ Snapshot figé — non réactif !
}
// ✅ BONNE PRATIQUE : nettoyage des ressources avec onUnmounted
export function useEventListener(event: string, handler: EventListener) {
onMounted(() => window.addEventListener(event, handler));
onUnmounted(() => window.removeEventListener(event, handler)); // ✅ Nettoyage
}
// ✅ BONNE PRATIQUE : accepter ref ou valeur brute via MaybeRef
import type { MaybeRef } from 'vue';
export function useDouble(value: MaybeRef<number>) {
return computed(() => unref(value) * 2); // unref() gère les deux cas
}
// useDouble(5) ✅
// useDouble(myRef) ✅
// ✅ BONNE PRATIQUE : tester les composables de façon isolée
// Les composables sont des fonctions — faciles à tester avec Vitest
import { renderHook, act } from '@testing-library/vue'; // ou @vue/test-utils
import { useCounter } from '@/composables/useCounter';
describe('useCounter', () => {
it('initialise à 0 par défaut', () => {
const { result } = renderHook(() => useCounter());
expect(result.count.value).toBe(0);
});
it('incrémente le compteur', async () => {
const { count, increment } = useCounter();
increment();
await nextTick();
expect(count.value).toBe(1);
});
});
Checklist composables Vue 3
Structure et conventions
- Nom commençant par
use(useCounter, useFetch…) - Fichier TypeScript dans
src/composables/ - Appel uniquement dans
<script setup>ousetup() - Retourner des refs, jamais des valeurs brutes
- Documenter les paramètres et le retour avec des types TypeScript
Réactivité et cycle de vie
- Nettoyer les ressources dans
onUnmounted(listeners, timers, fetch) - Utiliser
MaybeRefpour accepter ref ou valeur brute en paramètre - Utiliser
toRefs(reactive({...}))pour permettre la déstructuration - Tester les watchers avec des URLs de type ref pour le re-fetch automatique
Qualité et maintenabilité
- Un composable = une responsabilité (Single Responsibility Principle)
- Composer les composables complexes à partir de composables simples
- Tester les composables de façon isolée (sans composant)
- Éviter les effets de bord non contrôlés (mutations globales)
- Exporter une interface clairement définie (ce qui est public)
Les composables sont la fonctionnalité qui distingue vraiment Vue 3 de Vue 2. Ils permettent de construire des applications frontend comme des assemblages de briques logiques réutilisables, testables et maintenables — sans les problèmes des mixins. Commencez par extraire la logique répétée de vos composants actuels : chaque pattern de fetch, de formulaire ou de gestion DOM est un candidat idéal.