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
useFetchne 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 :
cancelledest utilisé pour éviter une race condition : si l’urlchange 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’urlchange — c’est le bon cycle de vie deuseEffect. - 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 relirelocalStorageà chaque rendu.- Le hook retourne un tuple
[value, setValue]commeuseState, 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
valuechange, un nouveausetTimeoutest créé ; l’anciensetTimeoutest effacé parclearTimeoutdans le cleanup. - Ce n’est que quand l’utilisateur cesse de taper dans les
delayMsquedebouncedest 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 :
- Le nom commence par
use— le plugin ESLinteslint-plugin-react-hooksutilise ce préfixe pour vérifier la règle ; sansuse, il ne reconnaîtra pas que c’est un hook. - Appelez les hooks uniquement au niveau supérieur de la fonction, pas dans les
if,for, ou les callbacks. - 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 :
renderHookprend une fonction qui appelle le hook, retournantresult.current— la valeur que le hook retourne au rendu actuel.rerendersimule un re-rendu du composant avec de nouvelles props (comme sivaluechangeait 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: avecuseFetch, s’il n’y a pas de drapeaucancelled, une ancienne requête qui revient peut écraser les nouvelles données — causant un affichage de données incorrect. - Dépendances manquantes : si un
useEffectdans 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 utilisantReact.memoen dessous peut se re-rendre inutilement car la référence change. Vous pouvez utiliseruseMemosi 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 ».