Arrêter la synchronisation de l’état avec useEffect : un pattern React plus sûr
Découvrez pourquoi l’utilisation de useEffect pour synchroniser l’état dérivé provoque des conditions de course et des rendus supplémentaires, ainsi que la manière de le remplacer par une dérivation en temps de rendu et l’attribut key.
Comment traiter useEffect comme un mécanisme pour synchroniser l’état provoque des boucles de rendu, des conditions de course et des dysfonctionnements fantômes de l’interface utilisateur.
Un ticket de support arrive sur votre bureau, marqué comme urgent. Un client indique que, lors du passage d’un compte à un autre sur le tableau de bord d’équipe, le journal des activités affiche parfois des événements appartenant au compte qu’il avait consulté trente secondes plus tôt.
Vous examinez le code. Il n’y a rien d’exotique ici — pas de couche WebSocket, pas de threads de travail, juste une vue React classique type master-detail.
Lors des tests locaux, vous cliquez sur les comptes un par un. Les quatre-vingt-dix-neuf premiers changements fonctionnent correctement. Puis, lors de la centième tentative avec le ralentissement réseau activé, quelque chose se dérègle visuellement : pendant un bref instant, le niveau de facturation de l’utilisateur précédemment sélectionné apparaît dans la carte de profil de l’utilisateur nouvellement sélectionné avant de se corriger.
La cause de ce phénomène réside dans un schéma qui semble complètement inoffensif :
useEffect(() => {
if (selectedUserId) {
fetchUserData(selectedUserId).then((data) => {
setUserProfile(data);
});
}
}, [selectedUserId]);
Cette habitude — recourir à useEffect pour maintenir l’état interne d’un composant en accord avec les props ou un autre état — provoque plus de bugs subtils, de ralentissements visuels et de problèmes structurels dans le code frontend moderne que presque tout autre schéma.
1. L’erreur de conception liée au cycle de vie : pourquoi les développeurs privilégient useEffect
Lorsque les Hooks sont arrivés dans React 16.8, les ingénieurs ayant une expérience avec les composants classiques ont souvent considéré useEffect comme un remplaçant direct de componentDidMount, componentDidUpdate et componentWillUnmount regroupés en une seule API.
Cette supposition a entraîné de véritables confusions par la suite.
Les composants de classe encourageaient un style impératif : lorsque une propriété changeait, il fallait déclencher manuellement this.setState() à l’intérieur de componentDidUpdate pour recalculer tout ce qui en dépendait.
Lors du passage aux composants fonctionnels, de nombreux développeurs ont conservé cette même habitude impérative, en supposant essentiellement que chaque fois qu’une propriété changeait, c’était à eux de mettre explicitement à jour une variable d’état locale correspondante.
Le problème, c’est que React est fondamentalement déclaratif et basé sur l’état. Écrire un useEffect dont le seul but est de mettre à jour une autre variable d’état locale force en fait React à effectuer deux passes de rendu complètes au lieu d’une seule.
Voici la séquence qui se déroule :
- React rend le composant en utilisant les nouvelles propriétés ainsi que l’état encore obsolète.
setState().Prenons cet exemple :
function UserBillingSummary({
plan,
addonCount
}: {
plan: string;
addonCount: number;
}) {
const [totalCost, setTotalCost] = useState(0);
useEffect(() => {
const base = plan === 'enterprise' ? 499 : 99;
setTotalCost(base + addonCount * 25);
}, [plan, addonCount]); return <div>Total: ${totalCost} / month</div>;
}
Ici, il n’y a absolument pas besoin d’une variable d’état distincte — totalCost peut être entièrement dérivé de plan et addonCount.
En d’autres termes, le composant effectue un travail supplémentaire inutile simplement pour obtenir une valeur qui était déjà calculable lors du rendu initial.
Pendant cette frame intermédiaire, les utilisateurs peuvent voir brièvement des chiffres incohérents à l’écran. De plus, si une logique de mise en page dépend de cette valeur calculée, le navigateur pourrait également devoir refaire le travail de mise en page et d’affichage qu’il n’aurait pas dû devoir répéter.
2. L’effet domino : chaînes de dépendances en cascade
Ce coût de rendu double s’aggrave considérablement dès que plusieurs effets commencent à dépendre des résultats les uns des autres.
Imaginez un panneau de filtrage en plusieurs étapes à l’intérieur d’un tableau de bord analytique :
function AnalyticsFilters({
organizationId
}: {
organizationId: string;
}) {
const [teams, setTeams] = useState<Team[]>([]);
const [selectedTeamId, setSelectedTeamId] = useState<string>('');
const [projects, setProjects] = useState<Project[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string>('');
useEffect(() => {
fetchTeams(organizationId).then((res) => {
setTeams(res);
setSelectedTeamId(res[0]?.id || '');
});
}, [organizationId]); useEffect(() => {
if (selectedTeamId) {
fetchProjects(selectedTeamId).then((res) => {
setProjects(res);
setSelectedProjectId(res[0]?.id || '');
});
}
}, [selectedTeamId]); useEffect(() => {
if (selectedProjectId) {
logAnalyticsFilterChange(selectedProjectId);
}
}, [selectedProjectId]); return (
<div className="filter-bar">
{/* Filter UI */}
</div>
);
}
Observez ce qui se passe dès que organizationId change :
- React rérendu à nouveau en utilisant le nouveau
organizationId. - Le premier effet récupère la liste des équipes, puis met à jour à la fois
teamsetselectedTeamId. - React rérendu à nouveau.
- Un deuxième effet remarque la valeur mise à jour de
selectedTeamIdet récupère les projets associés. - React rérendu encore une fois.
- Un troisième effet prend en compte la nouvelle valeur de
selectedProjectIdet enregistre ce changement.
Ce qui a commencé par une simple mise à jour d’une propriété s’est transformé en une chaîne de modifications d’état et d’exécutions d’effets.
Au fur et à mesure que l’application grandit, de telles chaînes deviennent véritablement difficiles à tracer. Si les réponses du réseau arrivent dans le mauvais ordre, ou si l’une d’elles renvoie rien en raison d’un problème de permissions ou d’un autre cas particulier, l’interface peut tomber dans un état incohérent sans qu’aucune erreur évidente ne soit générée.
Le véritable problème n’est pas seulement le nombre supplémentaire de rendus — c’est que le composant s’est silencieusement transformé en une mini-machine à états asynchrone que personne n’a conçue délibérément comme telle.
3. Le fantôme des conditions de course asynchrones
Les appels asynchrones non gérés à l’intérieur de useEffect constituent une autre source fréquente de données fantômes dans les applications single-page.
Imaginez un agent de support qui clique rapidement sur différentes lignes de tickets dans un tableau :
function TicketDetailView({
ticketId
}: {
ticketId: string;
}) {
const [ticket, setTicket] = useState<TicketData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true); api.getTicket(ticketId).then((data) => {
setTicket(data);
setLoading(false);
});
}, [ticketId]); if (loading) return <div>Loading ticket...</div>; return <TicketDetails ticket={ticket} />;
}
Voici la séquence d’événements qui détruit ce composant :
- L’agent clique sur le ticket n°101. La demande A est déclenchée.
- setTicket(ticket101).
React n’est pas en cause ici. Le véritable problème réside dans le fait que le composant permet à une demande obsolète de remplacer l’état après que l’utilisateur a déjà navigué ailleurs.
Si vous effectuez des requêtes à l’intérieur d’un effet sans aucune bibliothèque d’aide, vous devez prévenir cela en effectuant un nettoyage manuel :
useEffect(() => {
let isCurrent = true;
setLoading(true); api.getTicket(ticketId)
.then((data) => {
if (isCurrent) {
setTicket(data);
setLoading(false);
}
})
.catch((err) => {
if (isCurrent) {
handleTicketError(err);
}
}); return () => {
isCurrent = false;
};
}, [ticketId]);
Une meilleure option, lorsque votre client API le prend en charge, est AbortController. Au lieu de simplement ignorer une réponse arrivée en retard, vous annulez réellement la requête en cours avant qu’elle ne puisse être traitée.
4. L’alternative propre : état dérivé lors du rendu
Souvent, la solution la plus simple aux bugs de synchronisation consiste à éviter de synchroniser l’état dès le départ.
Dans de nombreux cas où les développeurs ont instinctivement recours à useState associé à useEffect, la valeur qu’ils tentent de stocker existe déjà quelque part dans les props actuels ou l’état du parent.
Au lieu de dupliquer cette valeur dans un état local, vous pouvez simplement la calculer directement pendant le rendu.
Voici le schéma problématique :
function OrderSummary({
items,
discountCode
}: OrderSummaryProps) {
const [discountPercent, setDiscountPercent] = useState(0);
const [subtotal, setSubtotal] = useState(0);
const [finalTotal, setFinalTotal] = useState(0);
useEffect(() => {
const rawSum = items.reduce(
(acc, item) => acc + item.price * item.quantity,
0
); setSubtotal(rawSum);
}, [items]); useEffect(() => {
const discount = calculateDiscount(discountCode);
setDiscountPercent(discount);
}, [discountCode]); useEffect(() => {
setFinalTotal(
subtotal - subtotal * (discountPercent / 100)
);
}, [subtotal, discountPercent]); return (
<SummaryView
subtotal={subtotal}
total={finalTotal}
/>
);
}
Comparez maintenant cela à une version basée sur un état dérivé :
function OrderSummary({
items,
discountCode
}: OrderSummaryProps) {
const subtotal = items.reduce(
(acc, item) => acc + item.price * item.quantity,
0
);
const discountPercent = calculateDiscount(discountCode); const finalTotal =
subtotal - subtotal * (discountPercent / 100); return (
<SummaryView
subtotal={subtotal}
total={finalTotal}
/>
);
}
Le contraste est important. Il n’y a pas d’état dupliqué, aucun mécanisme chargé de synchroniser les valeurs, et aucune fenêtre dans laquelle finalTotal pourrait dévier de l’alignement avec subtotal et discountPercent.
Les props eux-mêmes restent la seule source de vérité à tout moment.
Lorsqu’un calcul est réellement coûteux, useMemo vous permet de mettre en cache le résultat entre les rendus :
const filteredTransactions = useMemo(() => {
return rawTransactions.filter((tx) => {
return (
tx.amount >= minThreshold &&
tx.category === activeCategory
);
});
}, [rawTransactions, minThreshold, activeCategory]);
Le point clé est que useMemo existe uniquement pour mémoriser un calcul — il n’est pas conçu pour synchroniser deux états distincts entre eux.
5. Réinitialiser l’état de manière déclarative avec la prop key
Un piège similaire apparaît lorsque un formulaire modifiable doit réinitialiser ses champs chaque fois que l’entité en cours de modification change.
L’instinct typique est le suivant :
function EditUserModal({
user
}: {
user: UserData;
}) {
const [name, setName] = useState(user.name);
const [role, setRole] = useState(user.role);
useEffect(() => {
setName(user.name);
setRole(user.role);
}, [user.id]); return (
<form>
<input
value={name}
onChange={(e) => setName(e.target.value)}
/> <select
value={role}
onChange={(e) => setRole(e.target.value)}
/>
</form>
);
}
Cette approche peut provoquer un éclairage visible, où les données du record précédent restent affichées pendant une mise à l’écran avant que l’effet ne s’active et n’actualise les champs d’entrée.
Pire encore, elle peut écraser silencieusement ce que l’utilisateur était en train de taper si de nouvelles données arrivent au milieu de la saisie.
React dispose déjà d’une solution déclarative intégrée pour cela : la propriété key.
Dans le composant parent :
function UserAdminPage() {
const [selectedUser, setSelectedUser] =
useState<UserData | null>(null);
return (
<div>
<UserList onSelectUser={setSelectedUser} /> {selectedUser && (
<EditUserForm
key={selectedUser.id}
initialUser={selectedUser}
/>
)}
</div>
);
}
Le état local du composant enfant reste alors simple, sans rien à concilier :
function EditUserForm({
initialUser
}: {
initialUser: UserData;
}) {
const [name, setName] = useState(initialUser.name);
const [role, setRole] = useState(initialUser.role);
return (
<form>
<input
value={name}
onChange={(e) => setName(e.target.value)}
/> <select
value={role}
onChange={(e) => setRole(e.target.value)}
/>
</form>
);
}
Lorsque la key passe de user-1 à user-2, React ne tente pas de mettre à jour le composant existant — il le supprime et monte une nouvelle instance, avec un état initialisé à partir des nouvelles données de l’utilisateur.
Aucun effet de synchronisation n’est nécessaire du tout.
6. Où se trouve réellement le code : gestionnaires d’événements vs. effets
Imaginez que vous ayez besoin de déclencher un événement d’analyse et d’afficher une notification de confirmation chaque fois que quelqu’un clique sur « Soumettre la commande ».
Une façon d’écrire cela :
function CheckoutButton({
orderId
}: {
orderId: string;
}) {
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (submitted) {
analytics.track('order_submitted', {
orderId
}); showToast('Order placed successfully!');
}
}, [submitted, orderId]); return (
<button onClick={() => setSubmitted(true)}>
Place Order
</button>
);
}
Le problème, c’est que cela sépare le déclencheur de l’action — le clic définit un indicateur, et un effet réagit à cet indicateur plus tard.
Une approche plus propre consiste à garder tout cela directement dans le gestionnaire d’événements lui-même :
function CheckoutButton({
orderId
}: {
orderId: string;
}) {
const handlePlaceOrder = async () => {
await submitOrderApi(orderId);
analytics.track('order_submitted', {
orderId
}); showToast('Order placed successfully!');
}; return (
<button onClick={handlePlaceOrder}>
Place Order
</button>
);
}
Désormais, la causalité est explicite : l’utilisateur clique, la commande est soumise, et les actions suivantes s’exécutent immédiatement dans le cadre du même événement. Il n’y a aucun changement d’état intermédiaire permettant de détecter et de réagir à un effet distinct.
7. Quand useEffect est-il vraiment justifié ?
Tout cela ne signifie pas pour autant que useEffect lui-même soit défectueux.
Les problèmes commencent lorsque cet outil est utilisé comme solution universelle pour transférer des données entre différentes parties de l’état React.
Sa véritable fonction est de maintenir votre composant en synchronisation avec quelque chose qui se trouve en dehors du modèle de rendu propre à React.
Ce « quelque chose en dehors de React » relève généralement de catégories telles que :
- Les API natives du navigateur, comme
window.addEventListener,IntersectionObserveroumatchMedia
document.titleLe suivi de la largeur de la zone de visualisation du navigateur lors des redimensionnements est un bon exemple d’effet légitime :
function useWindowWidth() {
const [width, setWidth] = useState(
() => window.innerWidth
);
useEffect(() => {
const handleResize = () => {
setWidth(window.innerWidth);
}; window.addEventListener(
'resize',
handleResize
); return () => {
window.removeEventListener(
'resize',
handleResize
);
};
}, []); return width;
}
Dans ce cas, l’effet consiste à effectuer quelque chose que la seule rendu ne peut pas accomplir : il s’agit d’établir une abonnement à un événement du navigateur puis de le désactiver lors du nettoyage.
C’est précisément le type de tâche pour laquelle useEffect a été conçu.
Les règles architecturales que je suis désormais
Lorsqu’un tableau de bord ou une application basée sur React commence à être lent, imprévisible ou riche en erreurs de synchronisation difficiles à reproduire, le premier endroit à vérifier est la manière dont useEffect est utilisé dans l’ensemble du code.
Quatre principes directeurs permettent généralement de résoudre la plupart des problèmes.
1. Calculer les valeurs pendant le rendu.
Tout ce qui peut être dérivé des props ou de l’état existant doit être calculé directement dans le corps du rendu. N’utilisez useMemo que lorsque ce calcul est réellement coûteux.
2. Conserver la logique déclenchée par l’utilisateur à l’intérieur des gestionnaires d’événements. Lorsqu’un événement tel qu’un clic, une touche, une sélection ou une soumission se produit, cette logique doit être placée juste à côté de cet événement, et non dispersée dans un effet distinct.
3. Réinitialiser l’état proprement avec la propriété key.
Lors du passage d’une entité à une autre, il faut générer une instance de composant complètement nouvelle ; laissez React rémonter le composant plutôt que de synchroniser manuellement chaque champ individuel.
4. Réservez useEffect à une véritable synchronisation externe.
Les événements du navigateur, les abonnements, les connexions WebSocket et les intégrations impératives avec des bibliothèques constituent le domaine approprié pour les effets.
L’objectif n’est pas d’éliminer complètement useEffect de votre codebase.
L’objectif est d’arrêter à utiliser ce mécanisme comme un canal informel pour transférer des valeurs entre différentes parties de l’état React.
Lorsque les valeurs dérivées sont traitées comme telles, que les actions de l’utilisateur sont gérées en tant qu’événements et que seuls les systèmes externes réels sont connectés via des effets, il devient beaucoup plus facile de comprendre les composants React.
De plus, une grande partie des bugs mystérieux qui ne se manifestent qu’après des dizaines de clics, sur une connexion réseau lente ou exclusivement en production deviennent bien plus faciles à prévenir dès le départ.
Lectures complémentaires
- Construire un modèle mental pour React : Reconciliation, State et Hooks — Découvrez les principes sous-jacents aux concepts fondamentaux de React — reconciliation, composants, props, state et hooks — afin de développer une intuition plutôt que de mémoriser des API.
- Un ensemble de hooks personnalisés reutilisables pour chaque nouveau projet React — Explorez un ensemble soigneusement sélectionné de hooks personnalisés pour React — couvrant le stockage, le débouncing, les clics et la récupération de données — qui éliminent le code générique répétitif dans les nouveaux projets.