Blog
useEffect en production : closures obsolètes, cleanup et effets déclenchés au mauvais moment
Un ancien abonnement modifie la nouvelle vue, une actualisation efface le brouillon. Définir les dépendances et la durée de vie des effets, puis tester callbacks et timers sans attendre le réseau.
Après un changement d’annexe, les anciens incidents réapparaissent
Une agente passe de harbour à hill dans le tableau de bord d’un réseau de bibliothèques. Le titre change immédiatement. Quelques instants plus tard, le compteur affiche de nouveau les incidents de l’annexe précédente. Recharger la page règle le problème, jusqu’au prochain changement.
Ce cas fictif sert de fil conducteur : un suivi des incidents avec actualisation en direct, interrogation périodique facultative et note de transmission entre équipes. Il illustre un défaut plausible en production, pas un incident vécu par GiSoft. Une session prolongée révèle ce qu’un essai local rapide laisse passer : un callback créé pour une annexe reste actif après la sélection d’une autre.
Je commencerais par retrouver à quelle vue appartient cette mise à jour. Quelle annexe le callback a-t-il capturée ? Qu’est-ce qui devait mettre fin à son activité lors du changement ?
Moment Vue Travail extérieur à React
t0 harbour abonnement associé à harbour
t1 hill ancien callback encore en attente
t2 hill arrivée des données de harbour
Défaut : compteur de harbour sous le titre hill
Correction : ancien callback ignoré ; hill reçoit ses donnéesDes réponses locales rapides et de courtes sessions masquent le chevauchement. Une latence variable, les onglets en arrière-plan et les navigations répétées le rendent visible. Le problème concerne la durée de vie du travail, pas un supposé désordre des paquets HTTP.
Qu’est-ce qui justifie un effet ici ?
useEffect peut synchroniser une vue validée par React avec une ressource extérieure : abonnement, écouteur du navigateur, timer ou widget piloté par une API impérative. Les dépendances indiquent quelles valeurs imposent de renouveler cette synchronisation. Avant le setup correspondant à de nouvelles dépendances, React exécute le cleanup précédent ; il l’exécute aussi au démontage du composant.
« Après le rendu » est une règle de conception trop vague. Un effet correspond à un rendu validé, s’exécute côté client et ne garantit pas, de manière générale, que le navigateur a déjà peint l’écran. Un calcul à partir des props courantes n’a pas besoin de cette frontière. Un clic dispose d’un gestionnaire d’événement. Le chargement des données serveur peut déjà relever du routeur ou d’une couche API.
Les exemples utilisent React 19.2 et TypeScript. Le frontend inspecté repose sur Next.js 16 avec App Router, Vitest et React Testing Library. DeskFeed et DeskReader sont des contrats créés pour cet article, pas des API React ni des helpers existants du projet. Un adaptateur réel gérerait l’authentification du transport, le décodage et la vérification des données, les reconnexions et l’ordre des événements.
export type DeskScope = {
branchId: string;
sessionEpoch: number;
};
export type DeskSnapshot = {
branchId: string;
waiting: number;
};
export interface DeskFeed {
watch(
scope: DeskScope,
receive: (snapshot: DeskSnapshot) => void,
): () => void;
}
export interface DeskReader {
read(scope: DeskScope, signal?: AbortSignal): Promise<DeskSnapshot>;
}sessionEpoch est une génération locale modifiée lorsque le contexte d’authentification change. Elle ne contient aucun secret et n’accorde aucun droit. Elle permet de retirer au travail d’une ancienne session son autorité sur la vue. Le backend reste responsable de l’autorisation des requêtes et des abonnements.
Un timer peut continuer à utiliser un ancien rendu
Ce hook volontairement incorrect semble assez soigné : il lance un intervalle et le supprime au nettoyage. Pourtant, le tableau de dépendances vide conserve dans le callback les valeurs initiales de branchId, sessionEpoch et reader.
import { useEffect, useState } from 'react';
import type { DeskReader, DeskSnapshot } from './deskContract';
export function useFrozenDesk(
branchId: string,
sessionEpoch: number,
reader: DeskReader,
) {
const [snapshot, setSnapshot] = useState<DeskSnapshot | null>(null);
useEffect(() => {
const timer = window.setInterval(() => {
void reader.read({ branchId, sessionEpoch })
.then(setSnapshot)
.catch(() => setSnapshot(null));
}, 20_000);
return () => window.clearInterval(timer);
}, []);
return snapshot;
}Changer l’annexe affichée ne recrée pas ce callback. Les vingt secondes ne sont qu’un intervalle d’exemple, sans garantie d’exécution à un instant exact. Si une lecture a déjà démarré, supprimer l’intervalle n’empêche pas non plus sa réponse d’appeler ensuite setSnapshot.
C’est une stale closure, autrement dit une fermeture qui conserve des valeurs devenues inadaptées à son travail. Les fermetures sont un mécanisme normal de JavaScript. L’erreur consiste à attendre qu’elles récupèrent automatiquement les valeurs d’un rendu ultérieur. Une mise à jour fonctionnelle du state aide à calculer le prochain état à partir du précédent ; elle ne remplace ni l’identifiant d’annexe capturé ni le contexte d’authentification.
Les dépendances décrivent la synchronisation
Ajouter les dépendances manquantes est nécessaire ici, mais il reste à traiter la lecture en cours. Si la correction d’un avertissement du linter provoque des reconnexions permanentes, examinez l’identité des dépendances.
Par exemple, const scope = { branchId, sessionEpoch } dans le corps du composant crée un nouvel objet à chaque rendu. Dépendre de scope relance l’effet même si ses deux champs n’ont pas changé. React compare les dépendances avec Object.is, sans parcourir leur contenu. Construisez ce petit objet dans l’effet et utilisez ses entrées primitives comme dépendances.
Le même raisonnement vaut pour les fonctions déclarées inline et les clients constamment recréés. Un effet qui utilise feed dépend bien de cette instance. Donnez un propriétaire explicite au client au lieu de le soustraire à la liste. useMemo et useCallback peuvent stabiliser une valeur pertinente ; ils ne réparent pas un effet qui devrait être un calcul ou une action explicite.
Le plugin React Hooks d’ESLint est installé dans la pile inspectée via l’outillage Next.js. Aucun fichier de configuration ESLint actif ni script de lint n’a toutefois été trouvé. La présence du paquet ne prouve donc pas que exhaustive-deps s’exécute. Activez ces contrôles selon les conventions du projet plutôt que de masquer leurs avertissements dans les exemples.
L’abonnement appartient à une annexe et à une session
Dans la version en direct, chaque abonnement possède son callback et sa fonction de désabonnement. Le cleanup retire d’abord au callback le droit de modifier la vue, puis libère l’abonnement. Cela couvre aussi un appel déjà placé dans la file par l’adaptateur.
'use client';
import { useEffect, useState } from 'react';
import type { DeskFeed, DeskSnapshot } from './deskContract';
type Props = {
branchId: string;
sessionEpoch: number;
feed: DeskFeed;
};
export function DeskActivity(props: Props) {
const identity = `${props.sessionEpoch}:${props.branchId}`;
return <ActivityScope key={identity} {...props} />;
}
function ActivityScope({ branchId, sessionEpoch, feed }: Props) {
const [snapshot, setSnapshot] = useState<DeskSnapshot | null>(null);
useEffect(() => {
let acceptsEvents = true;
const stop = feed.watch({ branchId, sessionEpoch }, next => {
if (acceptsEvents && next.branchId === branchId) {
setSnapshot(next);
}
});
return () => {
acceptsEvents = false;
stop();
};
}, [branchId, sessionEpoch, feed]);
return (
<section>
<h2>{branchId}</h2>
<p role="status">
{snapshot ? `${snapshot.waiting} incidents en attente` : 'En attente de données'}
</p>
</section>
);
}Le key réinitialise uniquement le sous-arbre du statut courant lorsque l’annexe ou la session change. L’ancien snapshot ne reste donc pas sous le nouveau titre pendant la connexion. Cette clé ne remplace pas le cleanup. Gardez le formulaire de saisie hors de cette frontière de réinitialisation, sauf si l’abandon du brouillon est explicitement prévu.
La vérification de l’annexe rejette un événement mal dirigé. acceptsEvents bloque le travail d’un abonnement retiré, y compris lorsque seul feed est remplacé pour la même annexe. Cet exemple suppose que l’adaptateur fournit des snapshots ordonnés au sein d’un abonnement actif. Si le transport peut inverser des révisions, le contrat doit aussi prévoir leur contrôle.
La ressource possédée par la vue détermine le nettoyage :
Ressource de la vue Libération correspondante
Abonnement à une annexe se désabonner de ce topic
WebSocket partagé libérer son abonnement uniquement
Timer supprimer le callback programmé
Écouteur du navigateur retirer la même fonction
Requête de lecture ignorer le résultat ; annuler si possible
Widget impératif appeler son API dispose/destroyFermer un socket partagé depuis un seul panneau peut priver les autres de leurs données. À l’inverse, le composant qui crée une connexion privée est normalement chargé de la fermer. Setup et cleanup doivent s’accorder sur ce périmètre.
Un cleanup peut exister sans rien supprimer
Ce deuxième hook est lui aussi incorrect. Il actualise les données lors d’un changement de visibilité de l’onglet. Le setup enregistre une fonction ; le cleanup en transmet une autre, nouvellement créée, bien que son texte soit identique.
import { useEffect } from 'react';
export function useLeakingResume(
branchId: string,
refresh: (branchId: string) => void,
) {
useEffect(() => {
document.addEventListener('visibilitychange', () => refresh(branchId));
return () => document.removeEventListener(
'visibilitychange', () => refresh(branchId),
);
}, [branchId, refresh]);
}removeEventListener exige la référence du callback enregistré et une valeur de capture correspondante. Ici, les changements d’annexe accumulent donc les écouteurs. Au retour sur l’onglet, plusieurs lectures démarrent, dont certaines pour des annexes qui ne sont plus affichées.
Conservez le callback dans la portée du setup et retirez exactement cette fonction :
import { useEffect } from 'react';
export function useDeskResume(
branchId: string,
refresh: (branchId: string) => void,
) {
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === 'visible') refresh(branchId);
};
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
}, [branchId, refresh]);
}refresh est ici un déclencheur synchrone de lecture appartenant à la fonctionnalité. Son implémentation prend en charge les erreurs asynchrones et la pertinence des réponses ; ce hook ne possède que l’écouteur. Si sa référence change à chaque rendu, l’écouteur est correctement remplacé, mais inutilement. Déterminez d’abord où doit vivre ce déclencheur avant d’ajouter de la mémoïsation.
Le polling a besoin d’une durée de vie
Une lecture lente peut dépasser l’intervalle entre deux appels de setInterval. Dans ce tableau de bord, le polling remplace les mises à jour en direct ; il ne constitue pas une deuxième source écrivant en parallèle. Programmer la lecture suivante après la fin de la précédente évite leur chevauchement au sein d’un même cycle de vie.
import { useEffect } from 'react';
import type { DeskReader, DeskSnapshot } from './deskContract';
export function useDeskPolling(
branchId: string,
sessionEpoch: number,
reader: DeskReader,
receive: (snapshot: DeskSnapshot) => void,
report: (error: unknown) => void,
pauseMs = 20_000,
) {
useEffect(() => {
let retired = false;
let timer: number | undefined;
const controller = new AbortController();
async function refresh() {
try {
const next = await reader.read(
{ branchId, sessionEpoch }, controller.signal,
);
if (!retired) receive(next);
} catch (error) {
if (!retired && !controller.signal.aborted) report(error);
} finally {
if (!retired) timer = window.setTimeout(refresh, pauseMs);
}
}
void refresh();
return () => {
retired = true;
controller.abort();
window.clearTimeout(timer);
};
}, [branchId, sessionEpoch, reader, receive, report, pauseMs]);
}Placez ce hook dans la même frontière d’annexe et de session. receive et report sont des callbacks stables de la fonctionnalité, par exemple des setters React. Le contrat du reader prévoit un snapshot de l’annexe demandée. L’intervalle représente une pause minimale après la fin de la lecture. La limitation des onglets en arrière-plan et la planification du navigateur peuvent l’allonger.
Le cleanup retire le prochain timer, annule le transport si celui-ci le permet et rend les callbacks inactifs même si l’adaptateur ne peut pas interrompre l’opération. Une lecture doit finir ou respecter l’annulation pour que le polling puisse progresser. Cet extrait n’implémente ni timeout du transport ni backoff. Il suppose également que les callbacks ne lèvent pas d’exception. Ces garanties relèvent des contrats de l’adaptateur et de la fonctionnalité, pas d’un hook générique de timer.
Données, erreurs et chargement ont le même problème de propriétaire
Une modification des dépendances peut lancer B alors que la lecture A continue. B réussit, puis A échoue. Si le catch de A inscrit son erreur dans l’état courant, il remplace un résultat pourtant valide. Un finally { setLoading(false) } inconditionnel produit un autre défaut : A se termine, B attend encore, mais l’indicateur de chargement disparaît.
Les transitions de données, d’erreur et de chargement doivent appartenir à la même opération active. Vérifiez cette appartenance pour les trois, ou confiez-la à la couche de requêtes existante. Un booléen suffit pour une opération séquentielle unique, pas pour plusieurs requêtes indépendantes. Le hook de polling évite le chevauchement dans un cycle et bloque les anciens callbacks. Il ne coordonne pas toutes les requêtes de la fonctionnalité.
Une annulation volontaire lors de la navigation ne devrait généralement pas afficher une panne serveur. Vérifiez le contrat de votre client HTTP : un wrapper peut transformer l’erreur initiale. L’état du signal possédé par l’effet ou un résultat d’annulation explicite peut convenir mieux que l’hypothèse selon laquelle tous les clients transmettent AbortError sans modification.
Annuler une lecture signifie que le client cesse de l’attendre. Annuler une mutation ne prouve pas que le serveur l’a annulée lui aussi. Permissions, idempotence et contrôle de concurrence restent des responsabilités du backend. Requêtes concurrentes dans React : quand une ancienne réponse écrase l’état courant détaille les tests d’ordre des réponses et les compromis des mutations. Ici, nous nous concentrons sur la durée de vie de l’effet.
Transmettre une relève est une action
Envoyer la note via setShouldSend(true) puis un effet observant shouldSend masque l’action initiale. Une restauration d’état ou un remontage peut alors déclencher ce qui devait résulter uniquement d’un clic.
Un gestionnaire explicite rend cette cause visible. Il s’agit d’un extrait de composant : pending, draft, les setters et sendHandover viennent de la fonctionnalité ; l’adaptateur n’est pas une API du framework. L’exemple suppose que le formulaire conserve son annexe jusqu’à la fin de l’opération.
async function handleHandover() {
if (pending) return;
setPending(true);
setError(null);
try {
await sendHandover({ branchId, note: draft });
setSent(true);
} catch {
setError('Impossible de transmettre la relève.');
} finally {
setPending(false);
}
}Raccordez-le à la soumission du formulaire, empêchez l’envoi natif lorsque c’est nécessaire et désactivez la soumission pendant l’attente. Le booléen facilite l’interaction ; il ne protège pas le backend contre les doublons. Si l’agente peut changer de contexte en cours d’envoi, le résultat doit aussi avoir une identité d’opération ou un propriétaire distinct. Un timeout ne rend pas automatiquement sûr le renvoi d’une opération aux conséquences métier.
Un effet convient pour maintenir un abonnement conforme à l’annexe visible. La commande « Transmettre la relève » a une cause plus claire dans le gestionnaire d’événement.
Une actualisation en arrière-plan ne doit pas prendre possession du brouillon
Le nombre d’incidents ouverts sans personne assignée peut se calculer pendant le rendu à partir de la liste courante. Le stocker séparément et le mettre à jour dans un effet ajoute un rendu et une deuxième valeur à maintenir cohérente. Une petite fonction pure exprime la règle :
type DeskIncident = {
status: 'open' | 'resolved';
assigneeId: string | null;
};
export function countUnassigned(incidents: readonly DeskIncident[]) {
return incidents.filter(incident =>
incident.status === 'open' && incident.assigneeId === null,
).length;
}Le composant utilise const unassigned = countUnassigned(incidents). Aucun état supplémentaire à synchroniser, aucun tableau de dépendances à entretenir.
Le brouillon pose une autre question. Un effet contenant setDraft(handover.note) avec [handover] ressemble à une synchronisation. Si une actualisation remplace cet objet, il peut effacer le texte saisi depuis l’ouverture du formulaire.
Définissez le contrat : le formulaire conserve-t-il son brouillon jusqu’à l’envoi, les changements externes doivent-ils le remplacer, ou une nouvelle révision serveur doit-elle signaler un conflit ? Une remise à zéro au changement d’identité de l’enregistrement peut convenir, si la navigation traite explicitement le travail non enregistré. Une nouvelle référence d’objet ne signifie pas que l’utilisateur a demandé un reset. Les réponses d’autosauvegarde demandent la même prudence : confirmer la version sauvegardée sans écraser les caractères saisis ensuite.
Les chaînes d’effets dissimulent un processus
Un changement d’annexe pourrait charger les permissions, un effet en déduire canReview, un autre charger les incidents, puis un quatrième sélectionner le premier et demander ses détails. Pris séparément, ces fragments paraissent simples. Ensemble, ils créent des états intermédiaires et du travail périmé dont l’origine devient difficile à suivre.
Calculez canReview directement s’il découle des permissions. Pour des requêtes réellement dépendantes, envisagez une séquence asynchrone explicite avec une identité de contexte commune, la couche de données du routeur ou l’abstraction de requêtes déjà utilisée. Des abonnements indépendants peuvent rester séparés. Il n’est pas interdit qu’un effet modifie une entrée d’un autre effet. Le signal d’alerte est un processus métier dont l’ordre n’apparaît qu’en suivant les setters à travers tout le composant.
De même, ne regroupez pas socket, polling, titre du document et analytics dans un seul effet au seul motif qu’ils sont actifs quand le panneau est visible. Séparez les responsabilités extérieures et leurs durées de vie, pas mécaniquement chaque ligne de code.
Strict Mode révèle des hypothèses de cycle de vie
Avec React 19.2 et Strict Mode à la racine, le développement comporte un cycle supplémentaire setup–cleanup–setup des effets. App Router active Strict Mode par défaut dans la génération de Next.js inspectée. Une frontière Strict Mode limitée à un sous-arbre comporte des nuances ; le comportement de la racine ne décrit pas toutes les configurations.
La production n’exécute pas ce cycle supplémentaire au titre de cette vérification de développement. Les changements de dépendances, navigations et remontages exigent néanmoins un nettoyage correct. Deux lectures en développement ne prouvent pas, à elles seules, un défaut de production. Deux abonnements encore actifs une fois le setup terminé sont un indice beaucoup plus parlant.
Désactiver Strict Mode peut masquer cet indice. Vérifiez que setup, cleanup puis setup laissent exactement les ressources prévues. Ne fondez pas le diagnostic sur d’anciens avertissements de mise à jour après démontage : les fuites et erreurs de responsabilité comptent, qu’un message apparaisse ou non.
Next.js ne transforme pas chaque chargement en effet
Ces exemples interactifs appartiennent au côté client d’une frontière App Router. Les effets ne s’exécutent pas pendant la génération du HTML serveur. Server Components, chargement des routes et abonnements du navigateur répondent à des besoins différents. Utilisez la couche existante lorsqu’elle possède déjà les données.
Ce dépôt dispose d’un client API et de services par fonctionnalité, sans bibliothèque de requêtes configurée. Le frontend public utilise aussi un export statique, qui limite les fonctions serveur exécutées à chaque requête. Conseiller de « déplacer cela côté serveur » suppose de tenir compte de ce déploiement. Ailleurs, une bibliothèque de requêtes peut centraliser cache, rechargement et annulation ; elle ne décide pas si une actualisation doit écraser un brouillon.
Un écart d’hydratation est un autre problème : les représentations initiales du serveur et du client diffèrent. Lire un état propre au navigateur dans un effet peut convenir à une conception précise. Déplacer arbitrairement la logique de rendu dans des effets n’est pas une réparation universelle. De même, des dizaines de composants ne devraient pas reconstruire chargement, erreurs et annulation lorsqu’une couche de données en est déjà responsable.
Reproduire l’ancien callback sans attendre le réseau
Un petit adaptateur de test suffit pour le composant abonné. Le ManualDeskFeed de cet article enregistre les connexions et expose leurs callbacks. Appeler un callback conservé après le désabonnement simule volontairement un travail déjà en file d’attente. Cela ne signifie pas qu’un transport correct devrait continuer à livrer de nouveaux événements après le désabonnement.
import type { DeskFeed, DeskScope, DeskSnapshot } from './deskContract';
type Connection = {
scope: DeskScope;
receive: (snapshot: DeskSnapshot) => void;
closed: boolean;
};
export class ManualDeskFeed implements DeskFeed {
readonly connections: Connection[] = [];
watch(scope: DeskScope, receive: Connection['receive']) {
const connection = { scope, receive, closed: false };
this.connections.push(connection);
return () => { connection.closed = true; };
}
}Le test utilise Vitest, React Testing Library, l’environnement jsdom habituel et les matchers de @testing-library/jest-dom/vitest. Les imports locaux désignent les exemples de l’article. Il vérifie le résultat visible et la libération des deux abonnements.
import { act, render, screen } from '@testing-library/react';
import { expect, it } from 'vitest';
import { DeskActivity } from './DeskActivity';
import { ManualDeskFeed } from './ManualDeskFeed';
it('ignore le callback de l’ancienne annexe et libère les deux abonnements', () => {
const feed = new ManualDeskFeed();
const view = render(
<DeskActivity branchId="harbour" sessionEpoch={1} feed={feed} />,
);
const harbour = feed.connections[0]!;
act(() => harbour.receive({ branchId: 'harbour', waiting: 7 }));
view.rerender(
<DeskActivity branchId="hill" sessionEpoch={1} feed={feed} />,
);
const hill = feed.connections[1]!;
expect(harbour.closed).toBe(true);
expect(hill.closed).toBe(false);
expect(screen.getByRole('status')).toHaveTextContent('En attente de données');
act(() => hill.receive({ branchId: 'hill', waiting: 2 }));
act(() => harbour.receive({ branchId: 'harbour', waiting: 99 }));
expect(screen.getByRole('heading')).toHaveTextContent('hill');
expect(screen.getByRole('status')).toHaveTextContent('2 incidents en attente');
expect(screen.queryByText('99 incidents en attente')).not.toBeInTheDocument();
view.unmount();
expect(hill.closed).toBe(true);
});Ajoutez un remplacement de feed sans changer d’annexe : il vérifie la garde contre les anciens callbacks sans s’appuyer sur le démontage provoqué par key. Changez aussi sessionEpoch ; le travail de l’ancien contexte authentifié doit perdre sa responsabilité, même pour la même annexe. Avec StrictMode à la racine, vérifiez un abonnement actif après le setup et aucun après démontage, plutôt que d’exiger globalement un seul appel au setup.
Pour l’écouteur, déclenchez visibilitychange, effectuez un nouveau rendu pour une autre annexe, puis déclenchez à nouveau l’événement. Chaque événement lorsque l’onglet est visible doit lancer une seule actualisation de l’annexe courante, et aucune après démontage. Pour le polling, vi.useFakeTimers() et vi.advanceTimersByTimeAsync() de Vitest pilotent le temps. Gardez la promesse de lecture en attente pour prouver qu’aucun autre poll ne démarre ; résolvez-la, avancez de la pause prévue, changez le contexte, puis livrez l’ancien résultat ou rejet. Rétablissez les timers réels après chaque test. Aucune attente réelle n’est nécessaire.
Confier une frontière précise à chaque test
La première protection utile dépend de l’endroit où le défaut devient observable :
Risque Première protection utile
Dépendance réactive absente lint Hooks + test de comportement
Décision de brouillon/reducer test unitaire de la décision
Ancien abonnement composant avec flux contrôlé
Durée de vie du timer composant avec temps simulé
Données/erreur/chargement composant avec promesses retenues
Annulation du transport intégration/contrat de l’adaptateur
Effet métier en double test fonctionnel/intégration backend
Navigation entre annexes parcours navigateur cibléLint et TypeScript détectent certaines erreurs de dépendances et de types ; ils ne prouvent pas la libération de la bonne ressource. Les tests d’adaptateur vérifient les topics, les changements d’authentification, l’annulation et les erreurs publiques avec un transport contrôlé. Un test navigateur peut couvrir la navigation avec des réponses ou événements retardés. Fixer précisément leur ordre coûte moins cher dans un test de composant.
Protégez aussi le brouillon : saisissez une note, livrez un nouvel objet serveur, puis vérifiez le comportement prévu pour le brouillon ou le conflit. Pour les vues distinctes fondées sur des lectures, testez B réussi avant l’échec de A, puis A terminé alors que B charge encore. Ce sont des assertions différentes, même avec un helper de promesses commun. Tester l’architecture, les défaillances et les parcours utilisateur avec Next.js traite la stratégie globale. Il n’est pas nécessaire de la répéter ici ni d’installer un autre runner pour cet article.
Retrouver le propriétaire avant de modifier le timing
Consignez de quoi reconstruire un défaut précis : identifiant d’annexe, génération de session non secrète, instants de setup et de cleanup, topic et identifiant d’opération. Comparez le changement d’annexe à la fin de l’ancien callback. Excluez les identifiants d’authentification, tokens, payloads protégés et données personnelles inutiles.
Un tableau vide peut figer les mauvaises valeurs ; désactiver exhaustive-deps le dissimule. Ajouter aveuglément des objets recréés à chaque rendu peut provoquer des reconnexions continuelles. Mémoïsation, isMounted ou timeout pour « attendre le state » ne désignent pas l’annexe propriétaire d’une mise à jour. Partez de la raison d’être de l’effet, puis déterminez ses entrées et sa durée de vie.
Avant de valider un effet, répondez à cinq questions :
- Quelle ressource extérieure doit suivre cette vue validée ? Un calcul ou un gestionnaire de clic suffirait-il ?
- Quelles valeurs identifient son propriétaire, y compris après un changement d’authentification ?
- Qu’acquiert le setup, et le cleanup libère-t-il exactement cette ressource ?
- Un ancien travail peut-il encore modifier données, chargement, erreur ou brouillon ?
- Quel callback, quelle promesse ou quelle horloge contrôlée permettra de le vérifier ?
Dans le tableau de bord, la correction repose sur une durée de vie claire : le travail de Harbour cesse de posséder la vue dès que Hill prend le relais. Le tableau de dépendances participe à cette décision. Désabonnement, callbacks en attente et propriété du brouillon la complètent.
Références techniques sur le cycle de vie et les API de la plateforme :
- https://react.dev/reference/react/useEffect
- https://react.dev/reference/react/StrictMode
- https://react.dev/reference/eslint-plugin-react-hooks/lints/exhaustive-deps
- https://nextjs.org/docs/app/api-reference/config/next-config-js/reactStrictMode
- https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener
