Retour d'expérience sur une PR, une doc qu'on devrait relire tous les jours, et une règle qu'on répète sans se demander pourquoi elle existe.
Tout part d'une PR : j’ai crée un hook pour ajouter un effet de fade au scroll sur deux composants React existants. Le hook en question, useScrollFade, ressemble à ça :
const useScrollFade = (): UseScrollFadeResult => {
const [element, setElement] = useState<HTMLElement | null>(null)
const [showTopFade, seShowTopFade] = useState(false)
const [showBottomFade, setShowBottomFade] =useState(false)
const syncScrollFadeState = useCallback(() => {
if (!element) return
setShowTopFade(element.scrollTop > 0)
setShowBottomFade(element.scrollTop + element.clientHeight < element.scrollHeight -1)
}, [element])
useEffect(() => {
syncScrollFadeState();
});
useEffect(() => {
if (!element) return undefined
const observer = new ResizeObserver(syncScrollFadeState)
observer.observer(element)
return () => observer.disconnect()
}, [element, syncScrollFadeState])
return { showTopFade, showBottomFade, onScroll: syncScrollFadeState, ref: setElement }
}
C’est un hook donc l’objectif c’est d’avoir un useScrollFade qui s’interface facilement avec ses composants “consommateurs” : pas de passer 12 mille arguments spécifiques aux composants.
Premier réflexe en reviewant ce genre de fonction : "il manque le tableau de deps”, sur le useEffect. Je me dis que c’est un bug, je me mets donc en quête des dépendances. Et la je déchante: il y en a trop. Selon le composant qui consomme useScrollFade, le contenu et les changements de ce contenu (qui impliqueraient donc les recalculs du fade) sont trèeeeees différents/nombreux/complexes.
Instinctivement, j’ai une lumière rouge qui s’allume: je me dis que ce manque de deps est non seulement faux, mais dangereux. Ca va créer une boucle infinie, React va exploser, on va tous mourir (mon côté drama queen).
C'est un réflexe plutôt légitime, parce que c'est les premières choses qu'on nous apprend à traquer sur les useEffect : d’abord, est-ce que ce useEffect est nécessaire; ensuite, est-ce que ce useEffect a bien ses dépendances.
Sauf qu'en creusant un peu, ce cas précis n'est pas un bug, c’est même parfaitement légitime. Retour d’expérience donc sur ce point technique bien sûr, mais aussi sur ce que ça dit de nos facons de transmettre les bonnes pratiques de code.
La règle qu'on entend partout quand on utilise des UseEffect : "n'oublie pas ton tableau de dépendances". C'est vrai, et c'est important: la majorité des bugs useEffect viennent effectivement d'un tableau incomplet ou mal pensé. Mais cette règle, formulée comme ça, laisse entendre une chose fausse : qu'il y aurait TOUJOURS un tableau à trouver, et que son absence est nécessairement un oubli, ou un anti-pattern en React.
Hé ben figurez-vous que je suis allée creuser et que la documentation officielle ne dit pas du tout ça…
La page Synchronizing with Effects de react.dev pose noir sur blanc trois comportements distincts, dans un encart "Pitfall" (donc pas vraiment une astuce de coin de doc ou une note de bas de page) :
useEffect(() => {
// Ceci s'exécute après CHAQUE rendu
});
useEffect(() => {
// Ceci s'exécute seulement au montage
}, []);
useEffect(() => {
// Ceci s'exécute au montage, et aussi si a ou b ont changé
}, [a, b]);
Trois signatures, trois sémantiques différentes. "Pas de tableau" n'est pas une version dégradée de "tableau vide" : c'est un troisième mode d'exécution, choisi pour un besoin précis: celui de se resynchroniser après chaque rendu, quelle qu'en soit la cause.
Dan Abramov (un des auteurs de React) va plus loin dans A Complete Guide to useEffect, qui reste une référence quasi canonique sur le sujet. Son argument : omettre le tableau n'est pas en soi une erreur. La vraie erreur, c'est de mentir sur les dépendances: c'est-à-dire mettre un tableau qui ne reflète pas ce que l'effet utilise réellement, dans le seul but d'éviter une ré-exécution. C'est ce mensonge-là qui produit les closures périmées et les bugs qu'on associe (à raison) à ce pauvre useEffect.
Son exemple par excellence : un effet document.title = ... volontairement sans tableau de dépendances. Pourquoi ? Parce que le titre à synchroniser dépend potentiellement de tellement de choses dérivées du rendu qu'une liste de deps exhaustive n'apporterait aucune garantie supplémentaire et elle serait beaucoup plus fragile à maintenir que l'absence de tableau.
C'est exactement la situation de mon syncScrollFadeState : la fonction doit être correcte quel que soit ce qui a changé la mise en page (contenu du modal, resize, changement de props affectant la hauteur...). Essayer d'énumérer ces causes dans un tableau, pour tous les consommateurs de ce hook, reviendrait à parier qu'on n'en oubliera aucune: un pari perdant par construction et que j’ai décidé de ne pas faire. Ca surchargerait le hook d’arguments, on perdrait en lisibilité et donc l'intérêt d'une bonne interface pour un hook (je reviendrai sur ca, c'est un point clé d'une de mes lectures sur comment designer la complexité, mais je me perds, revenons aux useEffect).
Pourquoi ce n'est pas un anti-pattern React et pourquoi on ne rentre pas dans les cas du canonique "you might not need an effect" ?
La doc React moderne ("You Might Not Need an Effect", l’article le plus canonique de tous les articles React, + Synchronizing with Effects) propose une grille de lecture plus utile que "deps ou pas deps" :
La bonne question dans le cas d'un useEffect, c’est “qu’est ce qu’un système “externe” : pour moi, c’était surtout des API tierces par exemple, mais en l’occurrence ici le DOM, ca marche aussi !
scrollTop, scrollHeight, clientHeight sont de l'état DOM impératif: une information que React ne connaît pas et ne peut pas dériver depuis son propre state. C'est un cas typique de synchronisation avec un système externe, pas le genre d'anti-pattern que "You Might Not Need an Effect" cherche à éliminer.
Une fois qu'on accepte que c'est un pattern légitime, la question suivante est légitime aussi : si l'effet tourne après chaque rendu, est-ce qu'on ne vient pas de s’ajouter un problème de perf ? Car c’est le warning qu’on nous assène dans le fait de bien mettre des tableaux de dépendances et c’est la que le vrai trade off se joue dans mon cas par exemple.
Et là, un détail du fonctionnement de React change la donne, dans mon cas (attention, concentrez-vous, ça devient technique): car, en React, si la nouvelle valeur fournie à un setter d'état est identique à la valeur actuelle, React saute le re-rendu du composant et de ses enfants (c’est écrit noir sur blanc ici)
Attention ! Cette comparaison est faite avec Object.is: une fonction native, très proche de ===. Pour des booléens comme showTopFade / showBottomFade, elle se comporte exactement comme une égalité simple, MAIS pour des objets ou des tableaux, elle compare les références, pas le contenu. C’est un détail crucial sur lequel on revient plus bas.
Reprenons, ce qu'il se passe dans mon useScrollFade à la lumière de toutes ces informations:
setShowTopFade(element.scrollTop > 0);
setShowBottomFade(
element.scrollTop + element.clientHeight < element.scrollHeight - 1,
);
Imaginons que showTopFade vaut déjà false, et le composant parent re-render pour une raison sans rapport avec le scroll (un autre state qui change ailleurs). La séquence est la suivante :
syncScrollFadeState.false.setShowTopFade(false) est appelé.Object.is(false, false) → identique.useScrollFade, pas de nouveau commit DOM, donc pas de nouveau passage de l'effet (si tu ne sais pas ce qu'est le bail-out, c'est le moment de lire ca :)Pas de cascade, donc. Pas au sens où l'effet ne se répéterait qu'une fois, mais au sens où rien ne se produit en aval de cet appel quand la valeur ne change pas. Le seul coût réel est celui de l'appel de fonction, des trois lectures DOM (les element.scrollTop, element.clientHeight, element.scrollHeight) et de la comparaison: négligeable, et payé uniquement quand un rendu a de toute façon déjà eu lieu. Le re-render ne se produit que si la valeur calculée diffère réellement de l'état actuel (l'utilisateur vient de scroller jusqu'en bas, par exemple). Exactement le comportement souhaité.
Ce mécanisme de bail-out a une limite qu'il faut connaître : il ne fonctionne de façon fiable que pour des primitives. Si le hook stockait un objet ou un tableau et le mutait en place avant de rappeler le setter avec la même référence, Object.is verrait deux fois la même référence — bail-out à tort, même si le contenu, lui, a changé.
Ce n'est pas un risque dans useScrollFade : showTopFade et showBottomFade sont des booléens recalculés intégralement à chaque appel, pas des objets mutés (arrêtons de muter des objets aussi, mais bon autre débat).
Non: elle reste un bon réflexe par défaut, parce que la majorité des useEffect d'une codebase dérivent effectivement un état depuis des props/state, et dans ce cas un tableau de dépendances correct est indispensable. Mais la formuler comme une règle absolue ("il faut toujours un tableau") revient à en faire une heuristique qu'on applique sans comprendre ce qu'elle protège.
Ce que la doc et Dan Abramov disent réellement, c'est plus nuancé :
Object.is, dans mon cas précis, ce choix n'implique pas de cascade de re-renders: juste un coût de calcul minime, payé uniquement quand un rendu a déjà eu lieu pour d'autres raisonsD’où l’importance de réfléchir au cas par cas, de ne pas toujours appliquer des règles générales, mais de comprendre ce qui se passe vraiment dans le cas qui nous concerne. Car la vraie question n'est donc pas "y a-t-il un tableau de deps ?" mais "cet effet dérive-t-il un état React, ou synchronise-t-il avec quelque chose que React ne gère pas ?". Dans le premier cas, l'absence de tableau est presque toujours un signal d'alerte. Dans le second, comme ici, c'est une décision documentée qu'on peut justifier ligne par ligne.