frontend / vuejs
Composables réutilisables
Explication
Ce que vous allez apprendre
- Comprendre ce qui distingue un composable d'une simple fonction utilitaire
- Créer un composable robuste pour des appels réseau, avec annulation propre des requêtes obsolètes
- Retarder la mise à jour d'une valeur réactive avec un composable de debounce
- Synchroniser un état réactif avec le stockage local du navigateur
- Reconnaître la convention de nommage
useXxxet pourquoi elle facilite la lecture du code
Dans quel contexte ?
Trois pages différentes d'une application ont besoin de charger des données depuis une API, avec la même logique de gestion du chargement, des erreurs, et de l'annulation d'une requête devenue obsolète. Dupliquer cette logique trois fois serait source de bugs si l'une des copies dérive du comportement des autres. Un composable useFetch centralise cette logique une bonne fois pour toutes, réutilisable partout.
D'abord, qu'est-ce qui distingue vraiment un composable d'une fonction classique
Un composable est une fonction qui peut utiliser les APIs de réactivité de Vue (ref, computed, watch) et les hooks de cycle de vie (onMounted, onUnmounted), retournant généralement un état réactif et des fonctions pour agir dessus. Chaque appel à un composable crée sa propre instance d'état indépendante — deux composants qui appellent useCompteur() obtiennent chacun leur propre compteur, sans interférence.
Prérequis
Cette leçon suppose une bonne maîtrise de ref, watch/watchEffect et des hooks de cycle de vie (leçons 3, 6 et 7) : un composable combine ces trois briques.
Ensuite, un composable robuste doit gérer les cas limites
useFetch illustre une implémentation soignée : il utilise watchEffect pour refaire automatiquement la requête si l'URL change, ET utilise onCleanup pour annuler la requête précédente via un AbortController — sans quoi une réponse tardive d'une ancienne requête pourrait écraser le résultat d'une requête plus récente, exactement le type de "race condition" évoqué en leçon 6.
| Bonne pratique | Pourquoi |
|---|---|
Annuler la requête précédente (AbortController) | Évite qu'une réponse tardive écrase un résultat plus récent |
Nettoyer au démontage (onUnmounted/onCleanup) | Évite une mise à jour d'état sur un composant détruit |
Retourner donnees/erreur/enChargement séparément | Le composant appelant gère l'affichage selon l'état exact |
Il reste un besoin très fréquent : retarder une réaction à la saisie
Un champ de recherche qui déclenche une requête réseau à CHAQUE frappe surchargerait inutilement le serveur. useDebounce retarde la propagation d'une valeur réactive d'un délai configurable, en annulant le minuteur précédent à chaque nouvelle frappe — la valeur "debouncée" ne se met à jour que si l'utilisateur arrête de taper pendant la durée du délai.
Piège fréquent
Oublier clearTimeout(timer) avant de programmer un nouveau setTimeout dans un composable de debounce accumule silencieusement des minuteurs en attente, chacun capable de déclencher la mise à jour à un moment inattendu. Toujours annuler le minuteur précédent avant d'en programmer un nouveau.
Maintenant, un composable qui persiste au-delà du rechargement de la page
useLocalStorage synchronise automatiquement une valeur réactive avec le localStorage du navigateur : chaque modification de la valeur déclenche une sauvegarde, et la valeur initiale est relue depuis le stockage au démarrage. Une préférence de thème ou de langue est un excellent candidat pour ce composable.
Astuce
toValue() (Vue 3.3+) uniformise l'entrée d'un composable pour accepter indifféremment une valeur brute, une ref, ou une fonction getter — ce qui rend un composable comme useFetch(() => \/api/produits/${id.value}`)capable de refaire la requête automatiquement siid` change, sans logique supplémentaire à écrire.
Maintenant que la réutilisation de logique est maîtrisée, la prochaine leçon aborde comment gérer élégamment l'attente de données asynchrones directement au niveau du template, avec <Suspense>.
Commandes & code
Composables réutilisables
Un composable est une fonction qui encapsule et partage de la logique réactive avec état.
// composables/useFetch.js — abstraction robuste des appels réseau avec cycle de vie complet
import { ref, watchEffect, toValue } from 'vue'
// toValue() accepte indifféremment une valeur brute, une ref, ou une fonction -> API flexible
export function useFetch(url) {
const donnees = ref(null)
const erreur = ref(null)
const enChargement = ref(true)
watchEffect(async (onCleanup) => {
donnees.value = null
erreur.value = null
enChargement.value = true
const controleur = new AbortController()
// onCleanup s'exécute avant chaque nouvelle exécution ET au démontage : annule la requête obsolète
onCleanup(() => controleur.abort())
try {
const reponse = await fetch(toValue(url), { signal: controleur.signal })
if (!reponse.ok) throw new Error(`HTTP ${reponse.status}`)
donnees.value = await reponse.json()
} catch (e) {
if (e.name !== 'AbortError') erreur.value = e
} finally {
enChargement.value = false
}
})
return { donnees, erreur, enChargement }
}
// Utilisation : `useFetch(() => \`/api/produits/${id.value}\`)` refait la requête si id change// composables/useDebounce.js — retarde la mise à jour d'une valeur réactive
import { ref, watch } from 'vue'
export function useDebounce(source, delaiMs = 300) {
const valeurDebounced = ref(source.value)
let timer = null
watch(source, (nouvelleValeur) => {
clearTimeout(timer)
timer = setTimeout(() => {
valeurDebounced.value = nouvelleValeur
}, delaiMs)
})
return valeurDebounced
}
// Utilisation :
// const recherche = ref('')
// const rechercheDebounced = useDebounce(recherche, 400)
// watch(rechercheDebounced, (q) => lancerRecherche(q))// composables/useLocalStorage.js — état réactif synchronisé avec localStorage
import { ref, watch } from 'vue'
export function useLocalStorage(cle, valeurParDefaut) {
const donneesStockees = localStorage.getItem(cle)
const valeur = ref(donneesStockees ? JSON.parse(donneesStockees) : valeurParDefaut)
watch(valeur, (nv) => {
localStorage.setItem(cle, JSON.stringify(nv))
}, { deep: true })
return valeur
}
// const preferences = useLocalStorage('preferences', { theme: 'clair', langue: 'fr' })
// preferences.value.theme = 'sombre' -> automatiquement persisté// composables/useMousePosition.js — état lié à un événement global, avec cleanup automatique
import { ref, onMounted, onUnmounted } from 'vue'
export function useMousePosition() {
const x = ref(0)
const y = ref(0)
function maj(event) {
x.value = event.clientX
y.value = event.clientY
}
onMounted(() => window.addEventListener('mousemove', maj))
onUnmounted(() => window.removeEventListener('mousemove', maj))
return { x, y }
}// composables/usePagination.js — logique métier pure, réutilisable sur n'importe quelle liste
import { ref, computed } from 'vue'
export function usePagination(liste, taillePage = 10) {
const pageCourante = ref(1)
const nombrePages = computed(() => Math.max(1, Math.ceil(liste.value.length / taillePage)))
const elementsPage = computed(() => {
const debut = (pageCourante.value - 1) * taillePage
return liste.value.slice(debut, debut + taillePage)
})
function pageSuivante() {
if (pageCourante.value < nombrePages.value) pageCourante.value++
}
function pagePrecedente() {
if (pageCourante.value > 1) pageCourante.value--
}
return { pageCourante, nombrePages, elementsPage, pageSuivante, pagePrecedente }
}Résumé
- Convention de nommage
useXxx, comme les hooks React, pour une reconnaissance immédiate. - Un composable peut retourner des refs/computed réactives ET gérer son propre cycle de vie (mount/unmount).
toValue()(Vue 3.3+) uniformise l'entrée d'un composable : valeur brute, ref, ou getter.- Toujours nettoyer les abonnements/timers créés (
onUnmounted,onCleanupdewatchEffect).
Exercices pratiques
Mission : une recherche produit qui affiche de vieux résultats
Objectif : Diagnostiquer un composable useFetch sans annulation, puis le sécuriser et le combiner à un debounce sur la saisie.
Contexte
Un champ de recherche produit utilise un composable maison simplifié : function useFetch(url) { const donnees = ref(null); watchEffect(async () => { const r = await fetch(toValue(url)); donnees.value = await r.json() }); return { donnees } }, sans AbortController ni onCleanup. En tapant vite "ordinateur", l'utilisateur voit parfois s'afficher les résultats pour "ordi" APRÈS ceux pour "ordinateur", alors que "ordinateur" a été tapé en dernier.