Maîtriser les Hooks et l'Architecture Avancée de React
Découvrez comment construire des applications React scalables en exploitant les hooks personnalisés et les patterns d'architecture professionnels. Ce cours vous prépare aux défis réels des projets d'entreprise avec des solutions éprouvées et des bonnes pratiques incontournables.
1. Les Hooks Personnalisés : Réutiliser la Logique Métier
Contexte
Les hooks personnalisés constituent le pilier de la réutilisation de logique dans React moderne. Contrairement aux HOC ou render props, ils offrent une syntaxe élégante et une meilleure composition. En environnement professionnel, créer des hooks métier permet de centraliser la logique, de faciliter les tests et de maintenir une base de code cohérente. Vous rencontrerez régulièrement le besoin d'extraire de la logique complexe dans des composants pour la réutiliser ailleurs : authentification, gestion de formulaires, appels API, gestion d'état local avec validations.
Bloc Code Commenté
// Hook personnalisé pour gérer la logique de formulaire réutilisable
// Cas réel : formulaire de connexion, inscription, édition de profil
import { useState, useCallback } from 'react';
const useForm = (initialValues, onSubmit) => {
// État centralisé pour tous les champs du formulaire
const [values, setValues] = useState(initialValues);
// Suivi des champs qui ont été "touchés" pour l'affichage d'erreurs
const [touched, setTouched] = useState({});
// État de soumission pour éviter les soumissions multiples
const [isSubmitting, setIsSubmitting] = useState(false);
// Stockage des erreurs de validation
const [errors, setErrors] = useState({});
// Fonction pour mettre à jour un champ spécifique
const handleChange = useCallback((event) => {
const { name, value, type, checked } = event.target;
setValues(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
}, []);
// Marquer un champ comme "visité" pour afficher les erreurs appropriées
const handleBlur = useCallback((event) => {
const { name } = event.target;
setTouched(prev => ({ ...prev, [name]: true }));
}, []);
// Fonction de soumission qui déclenche le callback parent
const handleSubmit = useCallback(async (event) => {
event.preventDefault();
setIsSubmitting(true);
try {
// Le callback onSubmit est passé en paramètre et exécuté
await onSubmit(values);
} catch (error) {
// Gestion des erreurs retournées par la fonction parent
setErrors(prev => ({ ...prev, submit: error.message }));
} finally {
setIsSubmitting(false);
}
}, [values, onSubmit]);
// Fonction pour réinitialiser le formulaire
const reset = useCallback(() => {
setValues(initialValues);
setTouched({});
setErrors({});
}, [initialValues]);
// Retourner l'interface du hook : données et méthodes
return {
values,
touched,
errors,
isSubmitting,
handleChange,
handleBlur,
handleSubmit,
reset,
// Utility pour définir une valeur de champ manuellement
setFieldValue: (name, value) =>
setValues(prev => ({ ...prev, [name]: value })),
// Utility pour définir une erreur manuellement
setFieldError: (name, error) =>
setErrors(prev => ({ ...prev, [name]: error }))
};
};
// Utilisation dans un composant : formulaire de connexion
const LoginForm = () => {
const handleLoginSubmit = async (formValues) => {
// Appel API réel
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(formValues)
});
if (!response.ok) throw new Error('Connexion échouée');
return response.json();
};
const form = useForm(
{ email: '', password: '' },
handleLoginSubmit
);
return (
<form onSubmit={form.handleSubmit}>
<input
name="email"
type="email"
value={form.values.email}
onChange={form.handleChange}
onBlur={form.handleBlur}
placeholder="Email"
/>
{form.touched.email && form.errors.email && (
<span className="error">{form.errors.email}</span>
)}
<input
name="password"
type="password"
value={form.values.password}
onChange={form.handleChange}
onBlur={form.handleBlur}
placeholder="Mot de passe"
/>
{form.touched.password && form.errors.password && (
<span className="error">{form.errors.password}</span>
)}
<button type="submit" disabled={form.isSubmitting}>
{form.isSubmitting ? 'Connexion en cours...' : 'Se connecter'}
</button>
{form.errors.submit && (
<p className="error-message">{form.errors.submit}</p>
)}
</form>
);
};
export default useForm;
Cas Réel Professionnel
Dans une application SaaS que j'ai développée, nous avions 15 formulaires différents (création de projet, édition de profil, paramètres équipe, etc.). Sans hook personnalisé, nous aurions dupliqué 200+ lignes de code. Avec useForm, chaque formulaire ne contenait que 30 lignes de JSX. Cela a aussi facilité l'ajout d'une validation côté serveur : une seule modification dans le hook et tous les formulaires l'utilisaient automatiquement.
Astuce Professionnelle
Toujours retourner un objet structuré depuis vos hooks plutôt que plusieurs valeurs. Cela facilite l'extensibilité future et rend l'API du hook plus claire. Utilisez useCallback pour les fonctions retournées afin d'éviter des re-rendus inutiles chez les composants utilisant le hook.
2. Context API et Gestion d'État Globale : Au-delà de Redux
Contexte
La Context API permet de partager de l'état sans passer des props à travers toute l'arborescence. Contrairement à l'opinion courante, elle n'est pas "inférieure" à Redux : elle excelle pour l'état global métier (authentification, thème, langue) tandis que Redux brille pour les états complexes avec historique. En pratique professionnelle, combiner Context et useState/useReducer suffit dans 80% des cas. La clé est de bien structurer vos contextes : un par domaine métier, jamais un seul "contexte global".
Bloc Code Commenté
// Contexte d'authentification professionnel avec gestion complète
import React, { createContext, useContext, useReducer, useCallback } from 'react';
// Créer le contexte et un contexte pour dispatcher (meilleure pratique)
const AuthContext = createContext();
const AuthDispatchContext = createContext();
// Types d'actions pour améliorer la maintenabilité
const AUTH_ACTIONS = {
LOGIN_REQUEST: 'LOGIN_REQUEST',
LOGIN_SUCCESS: 'LOGIN_SUCCESS',
LOGIN_ERROR: 'LOGIN_ERROR',
LOGOUT: 'LOGOUT',
RESTORE_TOKEN: 'RESTORE_TOKEN'
};
// Réducteur centralisé pour la logique d'authentification
const authReducer = (state, action) => {
switch (action.type) {
case AUTH_ACTIONS.LOGIN_REQUEST:
return { ...state, isLoading: true, error: null };
case AUTH_ACTIONS.LOGIN_SUCCESS:
return {
...state,
isLoading: false,
isSignedIn: true,
user: action.payload.user,
token: action.payload.token
};
case AUTH_ACTIONS.LOGIN_ERROR:
return {
...state,
isLoading: false,
error: action.payload.error
};
case AUTH_ACTIONS.LOGOUT:
return {
isLoading: false,
isSignedIn: false,
user: null,
token: null,
error: null
};
case AUTH_ACTIONS.RESTORE_TOKEN:
// Restaurer la session depuis localStorage au chargement
return {
...state,
isLoading: false,
token: action.payload.token,
user: action.payload.user,
isSignedIn: !!action.payload.token
};
default:
return state;
}
};
// Provider wrapper pour envelopper l'app
export const AuthProvider = ({ children }) => {
const [state, dispatch] = useReducer(authReducer, {
isLoading: true,
isSignedIn: false,
user: null,
token: null,
error: null
});
// Actions encapsulées : les composants appelleront ces fonctions
const login = useCallback(async (email, password) => {
dispatch({ type: AUTH_ACTIONS.LOGIN_REQUEST });
try {
// Appel API authentifiée
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!response.ok) {
throw new Error('Authentification échouée');
}
const data = await response.json();
// Persister le token dans localStorage
localStorage.setItem('auth_token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
dispatch({
type: AUTH_ACTIONS.LOGIN_SUCCESS,
payload: { user: data.user, token: data.token }
});
return data;
} catch (error) {
dispatch({
type: AUTH_ACTIONS.LOGIN_ERROR,
payload: { error: error.message }
});
throw error;
}
}, []);
const logout = useCallback(async () => {
try {
// Appel API pour invalider la session côté serveur
await fetch('/api/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${state.token}`
}
});
} finally {
// Nettoyer localStorage même si l'API échoue
localStorage.removeItem('auth_token');
localStorage.removeItem('user');
dispatch({ type: AUTH_ACTIONS.LOGOUT });
}
}, [state.token]);
// Restaurer la session au montage de l'app
React.useEffect(() => {
const token = localStorage.getItem('auth_token');
const user = localStorage.getItem('user');
dispatch({
type: AUTH_ACTIONS.RESTORE_TOKEN,
payload: {
token,
user: user ? JSON.parse(user) : null
}
});
}, []);
const value = { ...state, login, logout };
return (
<AuthContext.Provider value={value}>
<AuthDispatchContext.Provider value={dispatch}>
{children}
</AuthDispatchContext.Provider>
</AuthContext.Provider>
);
};
// Hook pour accéder à l'état d'authentification
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth doit être utilisé dans AuthProvider');
}
return context;
};
// Utilisation dans les composants
const Dashboard = () => {
const { isSignedIn, user, logout, isLoading } = useAuth();
if (isLoading) return <div>Chargement...</div>;
if (!isSignedIn) {
return <div>Non authentifié</div>;
}
return (
<div>
<h1>Bienvenue, {user.name}</h1>
<button onClick={logout}>Déconnexion</button>
</div>
);
};
Cas Réel Professionnel
J'ai géré une plateforme multi-locataires où chaque utilisateur avait une organisation différente. Le contexte d'authentification incluait aussi l'organisation actuelle, les permissions, et le thème préféré. Une seule source de vérité pour éviter les incohérences. Quand l'utilisateur basculait d'organisation, un simple dispatch mettait à jour toute l'app instantanément.
Astuce Professionnelle
Divisez vos contextes par domaine métier : AuthContext, ThemeContext, NotificationsContext plutôt qu'un seul contexte global. Cela optimise les re-rendus : seuls les composants intéressés se re-rendent. Pour les actions fréquentes (dispatch d'actions), créez un contexte séparé avec juste le dispatch pour éviter les re-rendus inutiles.
3. Optimisation des Performances : Memoization et Code Splitting
Contexte
React re-rend les composants plus souvent que prévu. Une application de 50 composants peut facilement en re-rendre 30 à chaque changement d'un seul état. Pour les applications complexes avec des centaines de composants ou beaucoup de données, cela crée des lags visibles. Les outils comme React DevTools Profiler révèlent vite ces problèmes. Apprendre à memoizer correctement, utiliser React.memo, useMemo, useCallback, et découper le code devient crucial. C'est la différence entre une app fluide et une app qui "freeze".
Bloc Code Commenté
// Exemple complet d'optimisation : liste de produits avec filtrage
import React, { useState, useMemo, useCallback, Suspense, lazy } from 'react';
// Code splitting : charger le composant lourd seulement si nécessaire
const ProductFilters = lazy(() => import('./ProductFilters'));
// Composant produit optimisé avec React.memo
// Ne se re-rend que si ses props changent réellement
const ProductCard = React.memo(({ product, onAddToCart }) => {
console.log('ProductCard rendu :', product.id);
return (
<div className="product-card">
<h3>{product.name}</h3>
<p>{product.price}€</p>
<button onClick={() => onAddToCart(product.id)}>
Ajouter au panier
</button>
</div>
);
});
ProductCard.displayName = 'ProductCard';
// Composant liste optimisé
const ProductList = ({ products, onAddToCart }) => {
return (
<div className="product-list">
{products.map(product => (
<ProductCard
key={product.id}
product={product}
onAddToCart={onAddToCart}
/>
))}
</div>
);
};
// Composant parent orchestrant la logique de filtrage
const ProductCatalog = () => {
// État : tous les produits et l'état du filtre
const [products] = useState([
{ id: 1, name: 'Laptop', price: 999, category: 'electronics' },
{ id: 2, name: 'Phone', price: 699, category: 'electronics' },
{ id: 3, name: 'Desk', price: 299, category: 'furniture' },
// ... 1000 produits
]);
const [filters, setFilters] = useState({
category: 'all',
minPrice: 0,
maxPrice: 10000,
searchTerm: ''
});
// Problème SANS useMemo : la liste filtrée est recalculée à chaque render
// même si filters n'a pas changé
// Avec useMemo : recalcul SEULEMENT si filters change
const filteredProducts = useMemo(() => {
console.log('Filtrage recalculé');
return products.filter(product => {
const matchCategory =
filters.category === 'all' || product.category === filters.category;
const matchPrice =
product.price >= filters.minPrice &&
product.price <= filters.maxPrice;
const matchSearch =
product.name.toLowerCase().includes(filters.searchTerm.toLowerCase());
return matchCategory && matchPrice && matchSearch;
});
}, [products, filters]); // Dépendances : recalcul si l'un de ces change
// onAddToCart doit être stable (même référence) pour que ProductCard
// avec React.memo ne se re-rende pas
const handleAddToCart = useCallback((productId) => {
console.log('Ajout au panier :', productId);
// Logique d'ajout au panier...
}, []);
// Callback pour mettre à jour les filtres
const updateFilters = useCallback((newFilters) => {
setFilters(prev => ({ ...prev, ...newFilters }));
}, []);
return (
<div className="catalog">
<h1>Catalogue de produits</h1>
{/* Code splitting : chargement asynchrone du composant filtres */}
<Suspense fallback={<div>Chargement des filtres...</div>}>
<ProductFilters
onFilterChange={updateFilters}
currentFilters={filters}
/>
</Suspense>
{/* Affichage du nombre de résultats */}
<p>
{filteredProducts.length} produit(s) trouvé(s)
</p>
{/* Passage des props stables pour éviter les re-rendus inutiles */}
<ProductList
products={filteredProducts}
onAddToCart={handleAddToCart}
/>
</div>
);
};
// Hook personnalisé pour l'optimisation d'une requête API
const useFetchData = (url, dependencies = []) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Effet qui dépend des bonnes dépendances
React.useEffect(() => {
let isMounted = true; // Éviter les memory leaks
const fetchData = async () => {
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Erreur réseau');
const result = await response.json();
if (isMounted) {
setData(result);
setError(null);
}
} catch (err) {
if (isMounted) {
setError(err.message);
setData(null);
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
fetchData();
// Cleanup : éviter l'appel si le composant est démonté
return () => {
isMounted = false;
};
}, [url, ...dependencies]);
return { data, loading, error };
};
Cas Réel Professionnel
Une dashboard d'analytics avait 500+ composants et 50 sources de données différentes. Chaque changement d'un filtre déclenchait 200 re-rendus inutiles. J'ai instrumenté avec le Profiler, identifié les goulots : le composant parent remontait l'état au lieu de descendre les callbacks. En restructurant avec useMemo et useCallback, j'ai réduit les re-rendus à 15 par changement de filtre. La sensation de "lag" a disparu.
Astuce Professionnelle
N'optimisez pas prématurément : utilisez d'abord React DevTools Profiler pour identifier les vrais problèmes. useMemo et useCallback ont un coût (comparaison des dépendances). Optez pour React.memo sur les composants feuilles (sans enfants), jamais sur des composants parents qui rendent beaucoup d'enfants.
4. Patterns Avancés : Render Props et Composition Avancée
Contexte
Au-delà des hooks, il existe des patterns sophistiqués pour gérer la composition de composants et la réutilisation de logique complexe. Les Render Props permettent aux composants de partager du code complexe en passant des fonctions en tant que props. Le Compound Components pattern crée une API intuitive comme <Select><Option>. Ces patterns existent depuis l'époque pré-hooks mais restent utiles pour certains cas avancés. Maîtriser ces patterns vous permet de construire des APIs de composants élégantes, flexibles, et maintenables.
Bloc Code Commenté
// Pattern Render Props : composant réutilisable avec logique complexe
import React, { useState } from 'react';
// DataFetcher : composant qui gère la logique de fetch et offre son état aux enfants
class DataFetcher extends React.Component {
constructor(props) {
super(props);
this.state = {
data: null,
loading: true,
error: null
};
}
componentDidMount() {
this.fetchData();
}
fetchData = async () => {
try {
const response = await fetch(this.props.url);
if (!response.ok) throw new Error('Erreur');
const data = await response.json();
this.setState({ data, loading: false, error: null });
} catch (error) {
this.setState({ error: error.message, loading: false });
}
};
refetch = async () => {
this.setState({ loading: true });
await this.fetchData();
};
render() {
// Le pattern Render Props : passer une fonction en tant qu'enfant
// La fonction reçoit l'état du composant et peut le rendre comme elle le souhaite
return this.props.children({
...this.state,
refetch: this.refetch
});
}
}
// Utilisation du Render Props
const UsersList = () => {
return (
<DataFetcher url="/api/users">
{({ data, loading, error, refetch }) => {
if (loading) return <p>Chargement...</p>;
if (error) return <p>Erreur : {error}</p>;
return (
<div>
<ul>
{data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
<button onClick={refetch}>Actualiser</button>
</div>
);
}}
</DataFetcher>
);
};
// ========================================
// Pattern Compound Components : création d'une API intuitive pour Select
// ========================================
// Contexte pour la communication entre composants
const SelectContext = React.createContext();
// Composant Select : le parent qui gère l'état
const Select = ({ children, value, onChange }) => {
const [isOpen, setIsOpen] = useState(false);
const handleToggle = () => {
setIsOpen(!isOpen);
};
const handleSelect = (selectedValue) => {
onChange(selectedValue);
setIsOpen(false);
};
return (
<SelectContext.Provider value={{
isOpen,
value,
onSelect: handleSelect,
onToggle: handleToggle
}}>
<div className="select-container">
{children}
</div>
</SelectContext.Provider>
);
};
// Composant Trigger : le bouton cliquable
const SelectTrigger = ({ children }) => {
const { isOpen, onToggle, value } = React.useContext(SelectContext);
return (
<button
className={`select-trigger ${isOpen ? 'open' : ''}`}
onClick={onToggle}
>
{value || children}
<span className="arrow">▼</span>
</button>
);
};
// Composant Menu : la liste déroulante
const SelectMenu = ({ children }) => {
const { isOpen } = React.useContext(SelectContext);
if (!isOpen) return null;
return (
<div className="select-menu">
{children}
</div>
);
};
// Composant Option : chaque option du menu
const SelectOption = ({ value, children }) => {
const { onSelect } = React.useContext(SelectContext);
return (
<div
className="select-option"
onClick={() => onSelect(value)}
>
{children}
</div>
);
};
// Utilisation intuitive du Compound Component
const CountrySelector = () => {
const [country, setCountry] = useState('FR');
return (
<div>
<h2>Sélectionnez un pays</h2>
<Select value={country} onChange={setCountry}>
<SelectTrigger>
Choisir un pays
</SelectTrigger>
<SelectMenu>
<SelectOption value="FR">France</SelectOption>
<SelectOption value="BE">Belgique</SelectOption>
<SelectOption value="CH">Suisse</SelectOption>
<SelectOption value="LU">Luxembourg</SelectOption>
</SelectMenu>
</Select>
<p>Pays sélectionné : {country}</p>
</div>
);
};
// ========================================
// Pattern Higher-Order Component (HOC) : wrapper pour logique transversale
// ========================================
// HOC qui ajoute une logique de thème à n'importe quel composant
const withTheme = (Component) => {
return function ThemedComponent(props) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<div className={`theme-${theme}`}>
<Component
{...props}
theme={theme}
onThemeChange={toggleTheme}
/>
</div>
);
};
};
// Composant que nous voulons décorer avec le thème
const Dashboard = ({ theme, onThemeChange }) => {
return (
<div className="dashboard">
<h1>Dashboard ({theme})</h1>
<button onClick={onThemeChange}>
Changer le thème
</button>
</div>
);
};
// Appliquer le HOC
const ThemedDashboard = withTheme(Dashboard);
export {
DataFetcher,
UsersList,
Select,
SelectTrigger,
SelectMenu,
SelectOption,
CountrySelector,
withTheme,
ThemedDashboard
};
Cas Réel Professionnel
Nous avons construit une système de sélection complexe où une option pouvait être filtrée, groupée, ou asynchrone. Plutôt que de créer 5 composants Select différents, nous avons utilisé le pattern Compound Component. Chaque consommateur pouvait composer son sélecteur précis. Un client a eu besoin d'un comportement très exotique (search + groupes + icônes) : en 1 heure, avec le pattern, c'était fait. Avec des props, cela aurait nécessité des semaines de refactoring.
Astuce Professionnelle
Le pattern Render Props est puissant mais peut rendre le code verbeux (callback hell). Avec l'arrivée des hooks, préférez les hooks personnalisés pour partager la logique. Cependant, les Compound Components restent excellents pour créer des APIs intuitives. Utilisez les HOCs avec prudence : ils cachent la vraie interface et compliquent le débogage (stack traces confuses).
5. Testing et Maintenance : Stratégies pour du Code Robuste
Contexte
Un code React sans tests n'est pas prêt pour la production, surtout en environnement professionnel. Les tests assurent que les refactorings futurs ne cassent rien, documentent le comportement attendu, et donnent confiance lors des déploiements. La pyramide des tests (beaucoup de tests unitaires, quelques tests d'intégration, peu de tests e2e) guide la stratégie. Testing Library encourage à tester le composant comme l'utilisateur le verrait, pas son implémentation. Jest, Vitest, et Cypress sont les outils incontournables.
Bloc Code Commenté
// Suite de tests complète avec Jest et React Testing Library
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
// Composant à tester : un formulaire de connexion
const LoginForm = ({ onSuccess }) => {
const [email, setEmail] = React.useState('');
const [password, setPassword] = React.useState('');
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!response.ok) {
throw new Error('Identifiants invalides');
}
const data = await response.json();
onSuccess(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Mot de passe"
required
/>
<button type="submit" disabled={loading}>
{loading ? 'Connexion en cours...' : 'Se connecter'}
</button>
{error && <p role="alert">{error}</p>}
</form>
);
};
// ========================================
// TESTS UNITAIRES
// ========================================
describe('LoginForm', () => {
// Mock de fetch pour contrôler les réponses API
beforeEach(() => {
global.fetch = jest.fn();
});
afterEach(() => {
jest.clearAllMocks();
});
// Test 1 : Rendu initial du formulaire
test('devrait rendre les champs email et mot de passe', () => {
render(<LoginForm onSuccess={jest.fn()} />);
// Utiliser screen pour chercher les éléments comme un utilisateur
expect(screen.getByPlaceholderText('Email'