Vue 3 Composables : réutiliser la logique

Front-end 07/04/2026 12:00:00 angularforall.com
Vue 3 Composables Composition Api Useapifetch Réutilisabilité
Vue 3 Composables : réutiliser la logique

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)
Convention : Les composables se nomment toujours avec le préfixe 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>
Règle importante : Les composables doivent être appelés dans <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>
Principe de composition : Construisez des composables simples et ciblés, puis composez-les pour créer des abstractions plus puissantes. Un composable complexe est souvent le signe qu'il doit être découpé en plusieurs composables plus simples.

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> ou setup()
  • 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 MaybeRef pour 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)
Bibliothèques de composables prêts à l'emploi : VueUse est la bibliothèque de référence — plus de 200 composables utilitaires (useScroll, useDark, useGeolocation, useWebSocket…) tous bien testés et documentés. Inspectez leur code source pour apprendre les meilleures pratiques.

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.

Partager