React Hooks Avancés : Patterns et Optimisations 2024
Les hooks React ont transformé notre façon de développer. Après des années d’expérience React, voici mes patterns avancés et optimisations essentielles pour créer des applications performantes et maintenables.
1. Hooks de Performance : useCallback et useMemo
useCallback : Optimisation des Fonctions
import React, { useState, useCallback, memo } from 'react';
// Composant enfant optimisé avec memo
const UserCard = memo(({ user, onUpdate, onDelete }) => {
console.log('UserCard render:', user.name);
return (
<div className="user-card">
<h3>{user.name}</h3>
<p>{user.email}</p>
<button onClick={() => onUpdate(user.id)}>
Modifier
</button>
<button onClick={() => onDelete(user.id)}>
Supprimer
</button>
</div>
);
});
// Composant parent avec useCallback
const UserList = () => {
const [users, setUsers] = useState([]);
const [filter, setFilter] = useState('');
// ❌ Mauvais : nouvelle fonction à chaque render
const handleUpdate = (userId) => {
setUsers(prev => prev.map(user =>
user.id === userId ? { ...user, lastModified: Date.now() } : user
));
};
// ✅ Bon : fonction mémorisée
const handleUpdateMemo = useCallback((userId) => {
setUsers(prev => prev.map(user =>
user.id === userId ? { ...user, lastModified: Date.now() } : user
));
}, []); // Dépendances vides car on utilise la forme fonctionnelle de setState
const handleDelete = useCallback((userId) => {
setUsers(prev => prev.filter(user => user.id !== userId));
}, []);
// Fonction avec dépendances
const handleSearch = useCallback((searchTerm) => {
setFilter(searchTerm);
// Logique complexe de recherche
console.log('Searching for:', searchTerm);
}, []); // filter n'est pas nécessaire ici car on utilise setFilter directement
return (
<div>
<input
type="text"
placeholder="Rechercher..."
onChange={(e) => handleSearch(e.target.value)}
/>
{users.map(user => (
<UserCard
key={user.id}
user={user}
onUpdate={handleUpdateMemo}
onDelete={handleDelete}
/>
))}
</div>
);
};
useMemo : Optimisation des Calculs
import React, { useState, useMemo, useCallback } from 'react';
const ExpensiveCalculations = ({ data, filter, sortBy }) => {
// ❌ Mauvais : calcul coûteux à chaque render
const expensiveResult = data
.filter(item => item.name.includes(filter))
.sort((a, b) => a[sortBy] - b[sortBy])
.reduce((acc, item) => acc + item.value, 0);
// ✅ Bon : calcul mémorisé
const memoizedResult = useMemo(() => {
console.log('Calcul coûteux exécuté');
return data
.filter(item => item.name.includes(filter))
.sort((a, b) => a[sortBy] - b[sortBy])
.reduce((acc, item) => acc + item.value, 0);
}, [data, filter, sortBy]); // Recalculé uniquement si les dépendances changent
// Objet complexe mémorisé
const processedData = useMemo(() => {
return {
filtered: data.filter(item => item.name.includes(filter)),
sorted: data.sort((a, b) => a[sortBy] - b[sortBy]),
grouped: data.reduce((acc, item) => {
const category = item.category;
if (!acc[category]) acc[category] = [];
acc[category].push(item);
return acc;
}, {}),
total: memoizedResult
};
}, [data, filter, sortBy, memoizedResult]);
return (
<div>
<h2>Résultat: {memoizedResult}</h2>
<pre>{JSON.stringify(processedData, null, 2)}</pre>
</div>
);
};
2. Custom Hooks Avancés
useApi - Hook pour les Appels API
import { useState, useEffect, useCallback, useRef } from 'react';
const useApi = (url, options = {}) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const abortControllerRef = useRef(null);
const fetchData = useCallback(async (customUrl = url, customOptions = {}) => {
try {
// Annuler la requête précédente si elle existe
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
setLoading(true);
setError(null);
const response = await fetch(customUrl, {
signal: abortControllerRef.current.signal,
...options,
...customOptions,
headers: {
'Content-Type': 'application/json',
...options.headers,
...customOptions.headers,
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setData(result);
return result;
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
console.error('API Error:', err);
}
} finally {
setLoading(false);
}
}, [url, options]);
const refetch = useCallback(() => {
return fetchData();
}, [fetchData]);
const mutate = useCallback((newData) => {
setData(newData);
}, []);
useEffect(() => {
if (url) {
fetchData();
}
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [fetchData]);
return {
data,
loading,
error,
refetch,
mutate,
execute: fetchData, // Pour les appels manuels
};
};
// Utilisation du hook useApi
const UserProfile = ({ userId }) => {
const { data: user, loading, error, mutate } = useApi(
userId ? `/api/users/${userId}` : null,
{ method: 'GET' }
);
const { execute: updateUser, loading: updating } = useApi();
const handleUpdate = async (updates) => {
try {
const updatedUser = await updateUser(`/api/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(updates),
});
// Mettre à jour le cache local
mutate(updatedUser);
} catch (err) {
console.error('Erreur lors de la mise à jour:', err);
}
};
if (loading) return <div>Chargement...</div>;
if (error) return <div>Erreur: {error}</div>;
if (!user) return <div>Utilisateur non trouvé</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<button
onClick={() => handleUpdate({ lastLogin: new Date().toISOString() })}
disabled={updating}
>
{updating ? 'Mise à jour...' : 'Marquer comme connecté'}
</button>
</div>
);
};
useForm - Gestion de Formulaires Avancée
import { useState, useCallback, useMemo } from 'react';
const useForm = (initialValues = {}, validationSchema = {}) => {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
// Validation d'un champ
const validateField = useCallback((name, value) => {
const rules = validationSchema[name];
if (!rules) return null;
for (const rule of rules) {
if (!rule.validator(value, values)) {
return rule.message;
}
}
return null;
}, [validationSchema, values]);
// Validation de tout le formulaire
const validateForm = useCallback(() => {
const newErrors = {};
let isValid = true;
Object.keys(validationSchema).forEach(name => {
const error = validateField(name, values[name]);
if (error) {
newErrors[name] = error;
isValid = false;
}
});
setErrors(newErrors);
return isValid;
}, [validationSchema, values, validateField]);
// État de validation global
const isValid = useMemo(() => {
return Object.keys(errors).length === 0 &&
Object.keys(touched).length > 0;
}, [errors, touched]);
const isDirty = useMemo(() => {
return Object.keys(values).some(key =>
values[key] !== initialValues[key]
);
}, [values, initialValues]);
// Gestionnaires
const handleChange = useCallback((name, value) => {
setValues(prev => ({ ...prev, [name]: value }));
// Validation en temps réel si le champ a été touché
if (touched[name]) {
const error = validateField(name, value);
setErrors(prev => ({
...prev,
[name]: error || undefined
}));
}
}, [touched, validateField]);
const handleBlur = useCallback((name) => {
setTouched(prev => ({ ...prev, [name]: true }));
const error = validateField(name, values[name]);
setErrors(prev => ({
...prev,
[name]: error || undefined
}));
}, [values, validateField]);
const handleSubmit = useCallback(async (onSubmit) => {
setIsSubmitting(true);
// Marquer tous les champs comme touchés
const allTouched = Object.keys(validationSchema).reduce((acc, key) => {
acc[key] = true;
return acc;
}, {});
setTouched(allTouched);
const isFormValid = validateForm();
if (isFormValid) {
try {
await onSubmit(values);
} catch (error) {
console.error('Erreur de soumission:', error);
}
}
setIsSubmitting(false);
}, [values, validationSchema, validateForm]);
const reset = useCallback(() => {
setValues(initialValues);
setErrors({});
setTouched({});
setIsSubmitting(false);
}, [initialValues]);
const setFieldValue = useCallback((name, value) => {
setValues(prev => ({ ...prev, [name]: value }));
}, []);
const setFieldError = useCallback((name, error) => {
setErrors(prev => ({ ...prev, [name]: error }));
}, []);
return {
values,
errors,
touched,
isSubmitting,
isValid,
isDirty,
handleChange,
handleBlur,
handleSubmit,
reset,
setFieldValue,
setFieldError,
validateForm,
};
};
// Exemple d'utilisation avec validation
const UserRegistrationForm = () => {
const validationSchema = {
email: [
{
validator: (value) => value && value.length > 0,
message: 'Email requis'
},
{
validator: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
message: 'Format d\'email invalide'
}
],
password: [
{
validator: (value) => value && value.length >= 8,
message: 'Le mot de passe doit contenir au moins 8 caractères'
},
{
validator: (value) => /(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value),
message: 'Le mot de passe doit contenir au moins une minuscule, une majuscule et un chiffre'
}
],
confirmPassword: [
{
validator: (value, allValues) => value === allValues.password,
message: 'Les mots de passe ne correspondent pas'
}
]
};
const {
values,
errors,
touched,
isSubmitting,
isValid,
isDirty,
handleChange,
handleBlur,
handleSubmit,
reset
} = useForm({
email: '',
password: '',
confirmPassword: ''
}, validationSchema);
const onSubmit = async (formData) => {
try {
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
if (response.ok) {
alert('Inscription réussie!');
reset();
}
} catch (error) {
alert('Erreur lors de l\'inscription');
}
};
return (
<form onSubmit={(e) => {
e.preventDefault();
handleSubmit(onSubmit);
}}>
<div>
<input
type="email"
placeholder="Email"
value={values.email}
onChange={(e) => handleChange('email', e.target.value)}
onBlur={() => handleBlur('email')}
className={errors.email && touched.email ? 'error' : ''}
/>
{errors.email && touched.email && (
<span className="error-message">{errors.email}</span>
)}
</div>
<div>
<input
type="password"
placeholder="Mot de passe"
value={values.password}
onChange={(e) => handleChange('password', e.target.value)}
onBlur={() => handleBlur('password')}
className={errors.password && touched.password ? 'error' : ''}
/>
{errors.password && touched.password && (
<span className="error-message">{errors.password}</span>
)}
</div>
<div>
<input
type="password"
placeholder="Confirmer le mot de passe"
value={values.confirmPassword}
onChange={(e) => handleChange('confirmPassword', e.target.value)}
onBlur={() => handleBlur('confirmPassword')}
className={errors.confirmPassword && touched.confirmPassword ? 'error' : ''}
/>
{errors.confirmPassword && touched.confirmPassword && (
<span className="error-message">{errors.confirmPassword}</span>
)}
</div>
<button
type="submit"
disabled={!isValid || isSubmitting}
>
{isSubmitting ? 'Inscription...' : 'S\'inscrire'}
</button>
<button
type="button"
onClick={reset}
disabled={!isDirty}
>
Réinitialiser
</button>
</form>
);
};
3. useReducer pour la Gestion d’État Complexe
import React, { useReducer, useCallback, useMemo } from 'react';
// Types d'actions
const ACTIONS = {
FETCH_START: 'FETCH_START',
FETCH_SUCCESS: 'FETCH_SUCCESS',
FETCH_ERROR: 'FETCH_ERROR',
ADD_ITEM: 'ADD_ITEM',
UPDATE_ITEM: 'UPDATE_ITEM',
DELETE_ITEM: 'DELETE_ITEM',
SET_FILTER: 'SET_FILTER',
SET_SORT: 'SET_SORT',
TOGGLE_SELECTION: 'TOGGLE_SELECTION',
SELECT_ALL: 'SELECT_ALL',
CLEAR_SELECTION: 'CLEAR_SELECTION',
};
// État initial
const initialState = {
items: [],
loading: false,
error: null,
filter: '',
sortBy: 'name',
sortOrder: 'asc',
selectedIds: new Set(),
};
// Reducer
const dataReducer = (state, action) => {
switch (action.type) {
case ACTIONS.FETCH_START:
return {
...state,
loading: true,
error: null,
};
case ACTIONS.FETCH_SUCCESS:
return {
...state,
loading: false,
items: action.payload,
error: null,
};
case ACTIONS.FETCH_ERROR:
return {
...state,
loading: false,
error: action.payload,
};
case ACTIONS.ADD_ITEM:
return {
...state,
items: [...state.items, action.payload],
};
case ACTIONS.UPDATE_ITEM:
return {
...state,
items: state.items.map(item =>
item.id === action.payload.id
? { ...item, ...action.payload.updates }
: item
),
};
case ACTIONS.DELETE_ITEM:
const newSelectedIds = new Set(state.selectedIds);
newSelectedIds.delete(action.payload);
return {
...state,
items: state.items.filter(item => item.id !== action.payload),
selectedIds: newSelectedIds,
};
case ACTIONS.SET_FILTER:
return {
...state,
filter: action.payload,
};
case ACTIONS.SET_SORT:
return {
...state,
sortBy: action.payload.field,
sortOrder: state.sortBy === action.payload.field && state.sortOrder === 'asc'
? 'desc'
: 'asc',
};
case ACTIONS.TOGGLE_SELECTION:
const newSelection = new Set(state.selectedIds);
if (newSelection.has(action.payload)) {
newSelection.delete(action.payload);
} else {
newSelection.add(action.payload);
}
return {
...state,
selectedIds: newSelection,
};
case ACTIONS.SELECT_ALL:
return {
...state,
selectedIds: new Set(action.payload),
};
case ACTIONS.CLEAR_SELECTION:
return {
...state,
selectedIds: new Set(),
};
default:
return state;
}
};
// Hook personnalisé utilisant useReducer
const useDataManager = (initialItems = []) => {
const [state, dispatch] = useReducer(dataReducer, {
...initialState,
items: initialItems,
});
// Actions
const actions = useMemo(() => ({
fetchStart: () => dispatch({ type: ACTIONS.FETCH_START }),
fetchSuccess: (items) => dispatch({ type: ACTIONS.FETCH_SUCCESS, payload: items }),
fetchError: (error) => dispatch({ type: ACTIONS.FETCH_ERROR, payload: error }),
addItem: (item) => dispatch({ type: ACTIONS.ADD_ITEM, payload: item }),
updateItem: (id, updates) => dispatch({ type: ACTIONS.UPDATE_ITEM, payload: { id, updates } }),
deleteItem: (id) => dispatch({ type: ACTIONS.DELETE_ITEM, payload: id }),
setFilter: (filter) => dispatch({ type: ACTIONS.SET_FILTER, payload: filter }),
setSort: (field) => dispatch({ type: ACTIONS.SET_SORT, payload: { field } }),
toggleSelection: (id) => dispatch({ type: ACTIONS.TOGGLE_SELECTION, payload: id }),
selectAll: (ids) => dispatch({ type: ACTIONS.SELECT_ALL, payload: ids }),
clearSelection: () => dispatch({ type: ACTIONS.CLEAR_SELECTION }),
}), []);
// Données calculées
const computedData = useMemo(() => {
let filteredItems = state.items;
// Filtrage
if (state.filter) {
filteredItems = filteredItems.filter(item =>
item.name.toLowerCase().includes(state.filter.toLowerCase()) ||
item.description.toLowerCase().includes(state.filter.toLowerCase())
);
}
// Tri
filteredItems.sort((a, b) => {
const aVal = a[state.sortBy];
const bVal = b[state.sortBy];
let comparison = 0;
if (aVal < bVal) comparison = -1;
else if (aVal > bVal) comparison = 1;
return state.sortOrder === 'asc' ? comparison : -comparison;
});
const selectedItems = filteredItems.filter(item =>
state.selectedIds.has(item.id)
);
return {
filteredItems,
selectedItems,
selectedCount: state.selectedIds.size,
totalCount: state.items.length,
filteredCount: filteredItems.length,
allSelected: filteredItems.length > 0 &&
filteredItems.every(item => state.selectedIds.has(item.id)),
someSelected: state.selectedIds.size > 0 &&
!filteredItems.every(item => state.selectedIds.has(item.id)),
};
}, [state.items, state.filter, state.sortBy, state.sortOrder, state.selectedIds]);
// Fonctions utilitaires
const toggleSelectAll = useCallback(() => {
if (computedData.allSelected) {
actions.clearSelection();
} else {
actions.selectAll(computedData.filteredItems.map(item => item.id));
}
}, [computedData.allSelected, computedData.filteredItems, actions]);
const deleteSelected = useCallback(() => {
Array.from(state.selectedIds).forEach(id => {
actions.deleteItem(id);
});
}, [state.selectedIds, actions]);
return {
state,
actions,
computedData,
toggleSelectAll,
deleteSelected,
};
};
// Composant utilisant le hook
const DataTable = () => {
const {
state,
actions,
computedData,
toggleSelectAll,
deleteSelected
} = useDataManager([
{ id: 1, name: 'Item 1', description: 'Description 1', category: 'A' },
{ id: 2, name: 'Item 2', description: 'Description 2', category: 'B' },
{ id: 3, name: 'Item 3', description: 'Description 3', category: 'A' },
]);
return (
<div>
{/* Contrôles */}
<div className="controls">
<input
type="text"
placeholder="Filtrer..."
value={state.filter}
onChange={(e) => actions.setFilter(e.target.value)}
/>
<button
onClick={() => actions.addItem({
id: Date.now(),
name: `Nouvel item ${Date.now()}`,
description: 'Description',
category: 'A'
})}
>
Ajouter un item
</button>
<button
onClick={deleteSelected}
disabled={computedData.selectedCount === 0}
>
Supprimer la sélection ({computedData.selectedCount})
</button>
</div>
{/* Table */}
<table>
<thead>
<tr>
<th>
<input
type="checkbox"
checked={computedData.allSelected}
ref={input => {
if (input) input.indeterminate = computedData.someSelected;
}}
onChange={toggleSelectAll}
/>
</th>
<th onClick={() => actions.setSort('name')}>
Nom {state.sortBy === 'name' && (state.sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => actions.setSort('description')}>
Description {state.sortBy === 'description' && (state.sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{computedData.filteredItems.map(item => (
<tr key={item.id}>
<td>
<input
type="checkbox"
checked={state.selectedIds.has(item.id)}
onChange={() => actions.toggleSelection(item.id)}
/>
</td>
<td>{item.name}</td>
<td>{item.description}</td>
<td>
<button onClick={() => actions.deleteItem(item.id)}>
Supprimer
</button>
</td>
</tr>
))}
</tbody>
</table>
{/* Informations */}
<div className="info">
{computedData.filteredCount} / {computedData.totalCount} items
{computedData.selectedCount > 0 && ` - ${computedData.selectedCount} sélectionnés`}
</div>
</div>
);
};
4. Hooks de Contexte Avancés
import React, { createContext, useContext, useReducer, useCallback } from 'react';
// Context pour le thème
const ThemeContext = createContext();
const themeReducer = (state, action) => {
switch (action.type) {
case 'TOGGLE_THEME':
return { ...state, darkMode: !state.darkMode };
case 'SET_THEME':
return { ...state, darkMode: action.payload };
case 'SET_PRIMARY_COLOR':
return { ...state, primaryColor: action.payload };
default:
return state;
}
};
export const ThemeProvider = ({ children }) => {
const [state, dispatch] = useReducer(themeReducer, {
darkMode: false,
primaryColor: '#007bff',
});
const toggleTheme = useCallback(() => {
dispatch({ type: 'TOGGLE_THEME' });
}, []);
const setTheme = useCallback((isDark) => {
dispatch({ type: 'SET_THEME', payload: isDark });
}, []);
const setPrimaryColor = useCallback((color) => {
dispatch({ type: 'SET_PRIMARY_COLOR', payload: color });
}, []);
const value = {
...state,
toggleTheme,
setTheme,
setPrimaryColor,
};
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
Conclusion
La maîtrise des hooks avancés est essentielle pour développer des applications React performantes et maintenables. Les patterns présentés permettent de créer des composants réutilisables, optimisés et robustes.
Points clés à retenir :
- Utilisez
useCallback
etuseMemo
avec parcimonie - Créez des custom hooks pour la logique réutilisable
useReducer
pour les états complexes- Optimisez avec
memo
et les dépendances des hooks
Besoin d’aide avec React ? Contactez-moi pour du développement ou de la formation React avancé.
Mathieu Mont - Expert React, Vue.js, et développement Frontend moderne