Le typecheck et le lint
Deux vérifications automatiques, deux questions : les types sont-ils cohérents, et le code respecte-t-il les règles de React ? Le lint ramené à zéro et ajouté à pnpm check, et ce que les règles du React Compiler apprennent sur la façon d'écrire un composant.
Précédemment.
pnpm checkenchaîne des gardes rapides, le typecheck de trois projets et le lint de tout le code écrit à la main ; il ne voit que ce qui se lit sans exécuter.
Une erreur qu'un outil peut trouver ne devrait jamais attendre qu'un humain la croise. Ce chapitre présente les deux vérifications que pnpm check lance avant chaque commit : le typecheck, qui compare les types de tout le projet, et le lint, qui relit le code à la recherche de motifs que l'on sait fautifs. Il raconte aussi comment le lint a été ramené à zéro, parce que chaque correction enseigne une règle de React.
Deux vérifications, deux questions#
Vérification | Question posée | Exemple de ce qu’elle trouve |
|---|---|---|
Typecheck (tsc) | Les types sont-ils cohérents d’un bout à l’autre ? | Une procédure qui renvoie un champ renommé, et un composant qui lit encore l’ancien nom. |
Lint (ESLint) | Le code respecte-t-il les règles d’usage du langage et de React ? | Un état modifié dans un effet, un ref écrit pendant le rendu, une variable jamais lue. |
Showing entries1to2entries
Le typecheck ne connaît que les types : un composant parfaitement typé peut quand même provoquer un rendu de trop, ou afficher une valeur périmée. Le lint connaît les règles de React : celles des hooks, et celles du React Compiler, activé dans next.config.ts, qui optimise au build les composants qui les respectent. Les deux se complètent ; aucun ne remplace l'autre.
Le lint dans pnpm check#
Le lint s'exécute à la fin de pnpm check, sur apps/web puis sur les paquets (chapitre 1), et n'accepte aucun avertissement :
"lint": "eslint . --max-warnings 0",Un avertissement toléré en appelle d'autres : au bout de quelques mois, la liste en compte des dizaines, et plus personne ne la lit. Une règle qui ne convient pas au projet se désactive dans la configuration, avec un commentaire qui dit pourquoi ; elle ne reste pas affichée en jaune. Les fichiers qui ne sont pas notre code, comme le worker minifié de pdf.js servi depuis public/, sont exclus de la même façon.
Avant d'entrer dans pnpm check, le lint comptait 22 erreurs et 14 avertissements. Ils se rangeaient en deux familles.
Famille | Nombre | Exemples | Correction |
|---|---|---|---|
Mécanique | 20 | Apostrophe droite dans le texte JSX, variable ou import inutilisé, let jamais réassigné | Sans effet sur le comportement : apostrophe typographique, suppression du code mort. |
Règles du React Compiler | 16 | État recopié dans un effet, ref écrit pendant le rendu, mémoïsation qu’il ne peut pas garantir | Réécrire le composant selon le motif que la règle attend. |
Showing entries1to2entries
Ce que le lint révèle : du code mort#
La première famille paraît anodine ; elle ne l'est pas toujours. Le formulaire des disciplines acceptait une option submitLabel, et les pages de création et d'édition lui passaient « Créer » ou « Enregistrer ». Le lint signalait qu'elle n'était jamais lue : depuis l'arrivée des deux boutons « Enregistrer en brouillon » et « Publier », son libellé était écrit en dur. Une option qui ne fait rien trompe celui qui la lit ; elle a été retirée, avec ses usages.
De même, deux états de l'arborescence du Finder (« chargement », « erreur ») n'étaient jamais modifiés : l'indicateur de chargement qui en dépendait ne pouvait pas s'afficher. Le code qui ne s'exécute jamais ment sur ce que fait le programme.
Les règles du React Compiler#
Le React Compiler réécrit chaque composant pour éviter les calculs et les rendus inutiles, sans useMemo ni useCallback écrits à la main. Il ne peut le faire que si le composant respecte les règles de React : un rendu ne doit rien modifier hors de lui, un effet sert à se synchroniser avec l'extérieur, pas à recopier des données. Quand un composant s'en écarte, le Compiler renonce à l'optimiser, et la règle de lint correspondante le signale. Les seize cas du projet relevaient de six motifs.
Les règles d'abord, le Compiler ensuite. Pendant longtemps, le projet a vérifié les règles du Compiler sans l'exécuter : next.config.ts ne contenait pas reactCompiler: true, et Next.js ne l'active pas de lui-même. Le lint a d'abord été ramené à zéro, puis le Compiler activé. Si un composant enfreint une règle que le lint n'a pas vue, le Compiler ne le casse pas : il le laisse tel quel, sans optimisation.
Ne pas recopier une donnée dans un état depuis un effet#
Les Réglages du site remplissaient leur formulaire avec un useEffect : à l'arrivée des données, l'effet recopiait chaque valeur dans un état. Le formulaire s'affichait donc d'abord vide, puis React refaisait un rendu complet. La règle react-hooks/set-state-in-effect l'interdit.
Le remplissage se fait maintenant pendant le rendu, une seule fois, protégé par un drapeau :
React accepte qu'un composant modifie son propre état pendant le rendu, à condition qu'une condition l'empêche de recommencer : il refait alors immédiatement le rendu, avant d'afficher quoi que ce soit. Le formulaire apparaît directement rempli.
Déduire « en cours » au lieu de le stocker#
Les aperçus des blocs « image et texte » résolvaient leur média dans un effet qui commençait par remettre trois états à zéro (media, loading, l'erreur d'image), avant de lancer la requête. C'est encore un état modifié dans le corps de l'effet.
Le hook partagé useResolvedPreviewMedia garde un seul état, le résultat, étiqueté par la clé du média auquel il répond. L'effet ne le modifie qu'à l'arrivée de la réponse :
« En cours » n'est plus stocké : il se déduit. S'il y a un média et pas encore de résultat pour lui, c'est qu'on l'attend.
Le même raisonnement règle deux autres problèmes. Une réponse arrivée trop tard pour un ancien média est ignorée d'office, puisque sa clé ne correspond plus. Et la légende, prise dans le bloc à chaque rendu, se met à jour pendant la frappe sans relancer de requête, alors qu'avant elle restait figée jusqu'au changement de média. Les deux aperçus recopiaient la même logique ; ils partagent désormais le hook.
Une saisie en cours : un brouillon, pas une copie#
Le recadrage d'image associe à chaque curseur un champ numérique. Un effet recopiait la valeur du curseur dans le texte du champ à chaque changement. Le champ affiche maintenant la valeur du curseur, sauf pendant la saisie, où un brouillon prend le relais :
Une source extérieure : useSyncExternalStore#
Le bandeau d'actualités compte les nouvelles publiées depuis la dernière ouverture du panneau, date gardée dans le localStorage du navigateur. Le serveur ne la connaît pas : il fallait la lire après le montage, et un effet la recopiait dans un état.
React a un hook fait pour lire une source extérieure : useSyncExternalStore. Il reçoit une fonction d'abonnement, une fonction de lecture, et la valeur à utiliser côté serveur :
Côté serveur et pendant l'hydratation, la valeur vaut null : pas de badge, donc pas de différence entre le HTML du serveur et le premier rendu du navigateur. Ensuite le navigateur lit sa valeur, et React se réabonne aux changements, y compris ceux faits dans un autre onglet.
Ne pas écrire un ref pendant le rendu#
L'envoi de photos personnelles garde ses aperçus dans un ref, pour les libérer au démontage. Le ref était mis à jour pendant le rendu, ce que la règle react-hooks/refs interdit : React peut abandonner un rendu en cours, et le ref garderait alors une valeur qui n'a jamais été affichée. La mise à jour passe dans un effet, qui ne s'exécute qu'après un rendu effectivement affiché :
Laisser le Compiler mémoïser#
Trois mémoïsations écrites à la main (useMemo, useCallback) empêchaient le Compiler de travailler, parce qu'il ne peut pas garantir que leurs dépendances ne changeraient pas en douce. La plus simple : sur la page « Documents », un useMemo dépendait d'un tableau recréé à chaque rendu tant que la requête n'avait pas répondu, et ne mémoïsait donc rien. Il a été retiré : il ne servait à rien, et le Compiler mémoïse lui-même.
Dans le recadrage, le dessin de l'aperçu était un useCallback appelé avant d'être déclaré. Il est devenu un effet qui lit des constantes, et l'arrivée de l'image le relance par un état modifié dans le rappel onload, ce qui est permis : seul le corps de l'effet ne doit pas modifier d'état.
Sur la carte des entités, le placement des atomes, coûteux, est désormais gardé dans un cache de module, indexé par graphe et par ensemble d'atomes : la simulation est déterministe, le même ensemble donne toujours la même carte.
Les bibliothèques : useWatch plutôt que watch#
Les formulaires de galerie lisaient leurs champs avec form.watch() de react-hook-form. Cette fonction renvoie des valeurs que le Compiler ne sait pas suivre, et il renonçait à optimiser tout le composant (react-hooks/incompatible-library). La bibliothèque fournit un hook prévu pour cela, useWatch :
Les six motifs disent la même chose : un état ne doit pas être la copie d'une autre donnée. Ce qui se calcule à partir des props, d'un résultat de requête ou d'une source extérieure se calcule au rendu. Un état garde seulement ce que rien d'autre ne sait : ce que l'utilisateur a tapé, ce qu'une requête a renvoyé.
Lire une erreur du lint#
Une erreur du lint donne le fichier, la ligne, et le nom de la règle (react-hooks/set-state-in-effect). Le nom suffit pour retrouver la page de documentation de la règle, qui explique le motif attendu ; les règles du Compiler renvoient en plus à la page « You Might Not Need an Effect » de la documentation de React, la lecture la plus utile sur le sujet.
Désactiver une règle sur une ligne (// eslint-disable-next-line) reste possible, mais c'est une exception qui se justifie dans un commentaire. Le projet en garde une, connue : les aperçus relancent leur requête quand le média change, pas à chaque modification du bloc, ce que la règle des dépendances d'effet ne peut pas deviner.
Récapitulatif#
Question | Réponse du projet |
|---|---|
Où s’exécute le lint ? | Sur apps/web, puis sur le backend, les contrats et le moteur du Finder, avec la même configuration. |
Combien d’avertissements le lint tolère-t-il ? | Aucun (--max-warnings 0) ; une règle inadaptée se désactive avec un commentaire. |
Pourquoi ne pas recopier une donnée dans un état depuis un effet ? | L’écran s’affiche d’abord faux, puis React refait un rendu ; le Compiler renonce à optimiser le composant. |
Comment remplir un formulaire à l’arrivée des données ? | Pendant le rendu, une seule fois, protégé par un drapeau. |
Comment représenter « en cours » ? | Le déduire : un résultat étiqueté par sa clé, et pas encore de résultat pour la clé actuelle. |
Comment lire le localStorage sans écart d’hydratation ? | useSyncExternalStore, avec null comme valeur serveur. |
Showing entries1to6entries
À suivre#
Le typecheck et le lint appliquent des règles générales, écrites pour tous les projets. Certaines erreurs n’appartiennent qu’à celui-ci : un fichier de Next posé au mauvais endroit, un tutoriel qui vise un élément disparu, une documentation qui cite une procédure renommée. Pour elles, le projet a écrit ses propres gardes.
Chapitre suivant : les gardes maison. Quatre scripts courts, chacun pour une erreur silencieuse, et la façon d’en écrire un cinquième.