Custom Hook dans React : Extraire la Logique Réutilisable

1. Aperçu

Quand plusieurs composants dans une application React ont besoin de la même logique — appeler une API, lire localStorage, debouncer une entrée — vous finissez facilement par copier-coller des state et des useEffect dans tous les sens. Cet article vous montre comment encapsuler cette logique dans un custom hook : une fonction commençant par use, qui utilise les hooks existants (useState, useEffect, useRef…) pour la réutilisation. Après cet article, vous saurez écrire useFetch, useLocalStorage, useDebounce et comment les tester avec renderHook.

2. Le Problème : la logique répétée entre composants

Supposons que vous ayez deux composants qui doivent tous deux appeler une API et gérer loading/error :

function CustomerList() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/customers')
      .then((res) => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  // ...render
}

function OrderList() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/orders')
      .then((res) => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  // ...render
}

CustomerList et OrderList contiennent pratiquement la même logique, juste avec des URL différentes. Si demain vous voulez ajouter une retry ou annuler la requête, il faudra le corriger partout — c’est le signe que vous avez besoin d’un custom hook.

3. Qu’est-ce qu’un custom hook

Un custom hook est une fonction JavaScript ordinaire, dont le nom commence par use, qui appelle d’autres hooks React à l’intérieur. Il ne retourne pas JSX — il retourne des state et des fonctions, comme un « mini store » attaché au composant qui l’appelle.

Remarque : À chaque fois qu’un composant appelle un custom hook, React crée une instance d’état séparée. Deux composants utilisant useFetch ne partagent pas l’état entre eux — ils ne partagent que la logique.

4. Écrire votre premier custom hook : useFetch

// useFetch.js
import { useState, useEffect } from 'react';

export function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    setError(null);

    fetch(url)
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((json) => {
        if (!cancelled) setData(json);
      })
      .catch((err) => {
        if (!cancelled) setError(err);
      })
      .finally(() => {
        if (!cancelled) setLoading(false);
      });

    return () => {
      cancelled = true;
    };
  }, [url]);

  return { data, loading, error };
}

Explications :

  • cancelled est utilisé pour éviter une race condition : si l’url change avant que l’ancienne requête soit terminée, l’ancien résultat n’est pas stocké dans l’état.
  • La fonction de nettoyage (return () => { cancelled = true }) s’exécute quand le composant est démonté ou quand l’url change — c’est le bon cycle de vie de useEffect.
  • Le hook retourne un objet { data, loading, error } pour faciliter la déstructuration à l’endroit où il est appelé.

Le composant est maintenant plus concis :

function CustomerList() {
  const { data, loading, error } = useFetch('/api/customers');

  if (loading) return <p>Chargement...</p>;
  if (error) return <p>Erreur : {error.message}</p>;
  return <ul>{data.map((c) => <li key={c.id}>{c.name}</li>)}</ul>;
}

function OrderList() {
  const { data, loading, error } = useFetch('/api/orders');
  // logique de rendu similaire, pas de duplication de logique fetch
}

5. Deuxième exemple : useLocalStorage

La logique pour synchroniser l’état avec localStorage est aussi très souvent copiée-collée :

// useLocalStorage.js
import { useState, useEffect } from 'react';

export function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = window.localStorage.getItem(key);
    return stored !== null ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    window.localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

Explications :

  • useState(() => ...) utilise un initializer lazy — la fonction ne s’exécute qu’une fois au montage, sans relire localStorage à chaque rendu.
  • Le hook retourne un tuple [value, setValue] comme useState, ce qui aide les utilisateurs à l’utiliser immédiatement.

Utilisation dans un composant :

function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Thème actuel : {theme}
    </button>
  );
}

5.1. Quand l’état dépend de la valeur précédente

Si vous avez besoin de mettre à jour en fonction de la valeur précédente (par exemple, un compteur), utilisez une mise à jour fonctionnelle pour éviter les fermetures obsolètes :

setValue((prev) => prev + 1); // plus sûr que setValue(value + 1)

6. Troisième exemple : useDebounce

Debouncer une entrée de recherche est un problème très courant :

// useDebounce.js
import { useState, useEffect } from 'react';

export function useDebounce(value, delayMs = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(timer);
  }, [value, delayMs]);

  return debounced;
}

Explications :

  • Chaque fois que value change, un nouveau setTimeout est créé ; l’ancien setTimeout est effacé par clearTimeout dans le cleanup.
  • Ce n’est que quand l’utilisateur cesse de taper dans les delayMs que debounced est mis à jour.
function SearchBox() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 500);

  useEffect(() => {
    if (debouncedQuery) {
      fetch(`/api/search?q=${debouncedQuery}`);
    }
  }, [debouncedQuery]);

  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

7. Règles de nommage et Rules of Hooks

Les custom hooks doivent respecter les Rules of Hooks comme les hooks intégrés :

  1. Le nom commence par use — le plugin ESLint eslint-plugin-react-hooks utilise ce préfixe pour vérifier la règle ; sans use, il ne reconnaîtra pas que c’est un hook.
  2. Appelez les hooks uniquement au niveau supérieur de la fonction, pas dans les if, for, ou les callbacks.
  3. Appelez les hooks uniquement dans les composants de fonction ou dans d’autres custom hooks, pas dans les fonctions ordinaires.
npm install eslint-plugin-react-hooks --save-dev
{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

Activer exhaustive-deps vous aide à ne pas oublier les dépendances dans le useEffect de votre custom hook — la cause la plus courante de bugs « stale data ».

8. Tester les custom hooks

Comme un custom hook ne retourne pas JSX, vous ne pouvez pas le render comme un composant. La bibliothèque @testing-library/react fournit renderHook pour appeler un hook dans un environnement de test.

<!-- Ne s'applique pas aux projets React ; installer la dépendance via npm, voir ci-dessous -->
npm install --save-dev vitest @testing-library/react
// useDebounce.test.jsx
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useDebounce } from './useDebounce';

describe('useDebounce', () => {
  it('met à jour la valeur uniquement après la fin du délai', () => {
    vi.useFakeTimers();
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      { initialProps: { value: 'a', delay: 300 } }
    );

    expect(result.current).toBe('a');

    rerender({ value: 'ab', delay: 300 });
    expect(result.current).toBe('a'); // pas encore changé

    act(() => {
      vi.advanceTimersByTime(300);
    });

    expect(result.current).toBe('ab'); // debounce terminé
    vi.useRealTimers();
  });
});

Explications :

  • renderHook prend une fonction qui appelle le hook, retournant result.current — la valeur que le hook retourne au rendu actuel.
  • rerender simule un re-rendu du composant avec de nouvelles props (comme si value changeait quand l’utilisateur tape).
  • vi.useFakeTimers() + act(() => vi.advanceTimersByTime(...)) vous permet de contrôler le temps sans attendre réellement, les tests s’exécutent rapidement et de manière stable.
✓ useDebounce > met à jour la valeur uniquement après la fin du délai

9. Erreurs courantes et quand ne pas les utiliser

Erreurs courantes :

  • Oublier le cleanup dans useEffect : avec useFetch, s’il n’y a pas de drapeau cancelled, une ancienne requête qui revient peut écraser les nouvelles données — causant un affichage de données incorrect.
  • Dépendances manquantes : si un useEffect dans le hook utilise une variable extérieure mais ne la déclare pas dans le tableau de dépendances, le hook utilisera la valeur « ancienne » (stale closure).
  • Retourner un nouvel objet à chaque rendu sans nécessité : si le hook retourne { data, loading, error } comme objet littéral chaque fois, un composant utilisant React.memo en dessous peut se re-rendre inutilement car la référence change. Vous pouvez utiliser useMemo si vous détectez un problème réel.

Quand NE PAS écrire un custom hook :

  • Une logique n’est utilisée qu’à un seul endroit et n’a aucun potentiel de réutilisation — l’extraire ne rend le code que plus difficile à suivre (vous devez sauter dans un autre fichier pour comprendre).
  • Une logique est entièrement une fonction pure (pas de calculs liés à l’état/effet) — écrivez une fonction JS ordinaire, vous n’avez pas besoin de hook.
  • Quand vous avez besoin de partager un état (pas seulement la logique) entre plusieurs composants à différents endroits de l’arborescence — les custom hooks ne peuvent pas faire cela, vous avez besoin de Context ou d’une bibliothèque de gestion d’état (Zustand, Redux…).

Remarque : Les custom hooks résolvent le problème de duplication de logique, pas le problème de partage d’état entre les composants. Ne confondez pas ces deux problèmes.

10. Conclusion

Les custom hooks sont un outil pour extraire la logique avec état en dehors des composants, ce qui rend le code plus concis, facile à tester et réutilisable. Vous n’avez besoin de retenir que : nommez-le en commençant par use, respectez les Rules of Hooks, et testez-le avec renderHook plutôt que render. Avec useFetch, useLocalStorage, useDebounce ci-dessus, vous avez une base pour écrire d’autres hooks comme useMediaQuery, useForm dans l’esprit « écrire une fois, utiliser partout ».

reactcustom-hookjavascriptfrontendtesting