Structural Sharing dans TanStack Query (et React)

LeDevNoviceLeDevNovice
8 min read

Le structural sharing (en français partage structurel) est une technique utilisée principalement dans les structures de données dites immuables. On entend par là des données qui reste identique et ne change pas. Elle consiste à réutiliser les parties inchangées d’une structure de données lors de sa mise à jour, au lieu de tout recréer à neuf. Autrement dit, lorsqu’on produit une nouvelle version d’un objet ou d’une liste à partir d’une ancienne version par exemple, on partage autant que possible la structure entre les deux versions pour ne copier que les éléments modifiés.

Imaginez un livre de 500 pages dont vous modifiez une phrase à la page 100. Sans structural sharing, vous réimprimeriez tout le livre (500 nouvelles pages). Avec le structural sharing cependant, vous réimprimez seulement la page 100 modifiée et vous conservez toutes les autres pages identiques à l’ancienne version. On obtient ainsi un nouveau livre qui partage 499 pages avec l’ancien. Seules les pages modifiées sont nouvelles.

Ce concept permet moins de copies inutiles et de garder certaines références identiques entre les anciennes et nouvelles versions.

Appliqué à des données en mémoire, cela signifie que si vous avez par exemple un gros objet ou un tableau et que vous n’en changez qu’une petite partie, la nouvelle version réutilisera les références des parties inchangées au lieu de créer de nouveaux objets pour celles-ci. Concrètement, pour un tableau de 1000 éléments où seul l’élément d’index 5 change, on créera un nouveau tableau, on remplacera la case 5 par la nouvelle valeur, mais tous les autres éléments du tableau pointeront vers les mêmes objets que dans l’ancienne version. On partage ainsi la majorité de la structure.

En React (et en JavaScript en général), les références et l’égalité référentielle jouent un grand rôle dans le rendu et la performance de ce rendu. React compare les anciennes et nouvelles props ou états principalement par référence pour déterminer si un composant doit se mettre à jour. Si on fournit à un composant un objet totalement nouveau à chaque rendu, même avec les mêmes données internes, React considère que “ça a changé” (car la référence objet est différente en mémoire) et pourra re-render ou recalculer des choses inutilement. À l’inverse, si les références restent stables pour les données inchangées, React peut éviter des nouveaux rendus inutiles.

Le structural sharing contribue justement à cette stabilité. En conservant la même instance d’un objet ou d’une partie de données quand son contenu n’a pas changé, on garantit que pour React, cette partie est identique d’un rendu à l’autre. Grâce au partage structurel, si la donnée n’a pas changé, la référence reste la même et le composant enfant ne se réévaluera pas inutilement. Par exemple, un hook useEffect ou useMemo qui dépend de data (résultat d’un useQuery). Si la donnée reste exactement la même (référence inchangée) entre deux rendus, l’effet ne sera pas re-déclenché, car React verra que la dépendance [data] n’a pas varié. Sans partage structurel, même des données identiques en contenu seraient perçues comme nouvelles (nouvelles instances) et pourraient relancer des effets ou recalculs inutiles.

Le structural sharing évite de signaler des changements là où il n’y en a pas réellement, ce qui s’aligne parfaitement avec le modèle de rendu de React pour réduire le travail inutile. Pour l’utilisateur de TanStack Query, cela se traduit par une application plus performante et moins de “surprises” liées à des re-rendus intempestifs, surtout avec des gros volumes de données. Bien sûr, si une donnée a vraiment changé, elle aura une nouvelle référence et il est normal que React re-rende le composant dans ce cas-là !

En utilisant TanStack Query au quotidien, on peut profiter de ce mécanisme sans même s’en rendre compte.

Chaque appel à useQuery retourne un objet résultat (avec les propriétés data, error, status, isLoading, isFetching, refetch, etc...). Par défaut, TanStack Query va faire en sorte que la propriété data conserve la même référence tant que les données sont identiques. Par exemple, si vous faites un fetch et que le serveur renvoie exactement la même réponse qu’avant, data pointera vers le même objet que lors du rendu précédent. Aucun changement de référence. Idem, si seulement une petite partie de data change, TanStack Query réutilisera toutes les portions inchangées et ne créera de nouveaux objets que pour les morceaux modifiés.

Imaginons que data soit un tableau de tâches Todos. La première fois, le serveur renvoie :

oldData = [
    { id: 1, name: "Learn React", status: "active" },
    { id: 2, name: "Learn React Query", status: "todo" }
]

Plus tard, une tâche est mise à jour (id 1 passe en done) et le serveur renvoie :

newDataFromAPI = [
    { id: 1, name: "Learn React", status: "done" },
    { id: 2, name: "Learn React Query", status: "todo" }
]

Sans optimisation, on aurait un nouveau tableau avec deux nouveaux objets. Avec le structural sharing, TanStack Query va créer un nouveau tableau, remplacer l’objet id 1 par la nouvelle version, mais réutiliser l’objet id 2 inchangé depuis l’ancien data. En code, le résultat final ressemblerait à :

resultData = [
newDataFromAPI[0], // l'objet pour id 1 est nouveau (modifié)
oldData[1] // l'objet pour id 2 est réutilisé tel quel
]

console.log(resultData[1] === oldData[1]) // true, même référence

Ainsi, pour notre composant React, la référence du todo {id:2} reste strictement égale (===) à celle d’avant. Si ce composant ne regarde que le todo 2 (par exemple via un sélecteur), il ne verra aucun changement et ne re-rendra pas. Seule la portion réellement modifiée (todo 1) a changé d'identité.

D’autres propriétés du résultat de useQuery suivent le même principe de stabilité dès que possible. Par exemple, la propriété error (si une erreur a eu lieu) restera la même instance d’erreur tant que c’est la même erreur qui persiste. Les booléens et statuts (status, isLoading, isSuccess, etc…) reflètent l’état du moment. S’ils ne changent pas (par exemple status reste success entre deux rendus), ils conservent évidemment la même valeur primitive.

À noter que l’objet retourné par useQuery en lui-même est toujours un nouvel objet à chaque re-rendu (sa référence globale change constamment). Cela peut sembler aller à l’encontre de ce qu’on vient de dire, mais en réalité ce n’est pas un problème. Ce qui compte, ce sont les propriétés internes de cet objet. Tant que les propriétés que vous utilisez (data, error, etc…) restent, elles, stables et égales à la version précédente, votre composant ne sera pas perturbé. La doc précise bien ce point :

“L’objet de niveau supérieur retourné par useQuery n’est pas stable référentiellement (...). En revanche, les propriétés data (et consorts) sont aussi stables que possible.”.

En pratique, cela signifie que si on compare l’ancien et le nouveau résultat via { data: oldData } vs { data: newData }, on trouvera oldData === newData (si rien n’a changé), même si le conteneur objet est différent.

TanStack Query agit un peu comme un garde du corps de vos composants, il ne les réveille que lorsque c’est pertinent. Le structural sharing s’assure que vos données ne semblent pas avoir changé si leur contenu est identique. Résultat : vous pouvez utiliser les hooks (useQuery, etc…) de manière impérative sans ajouter vous-même de useMemo partout par exemple, la librairie fait déjà beaucoup pour éviter les re-rendus superflus.

La logique du structural sharing est centrée autour de la classe QueryObserver (fichier queryObserver.ts dans le core). Chaque hook useQuery React correspond en interne à un QueryObserver qui observe une requête (un Query du cache) et produit des résultats pour le composant React abonné.

Quand une requête se met à jour (par exemple suite à un simple fetch), le core calcule un nouvel état contenant la donnée fraîche (newData). Il garde sous la main l’ancienne donnée (oldData) issue du cache de requête. Le QueryObserver va alors comparer l’ancienne et la nouvelle donnée en profondeur, et reconstruire la nouvelle en réutilisant les morceaux inchangés.

Cette logique est implémentée par une fonction utilitaire interne replaceEqualDeep(oldData, newData). Comme son nom l’indique, elle va parcourir récursivement la structure de newData et, pour chaque sous-partie, décider de garder l’ancienne référence (si oldData a une portion égale) ou d’utiliser la nouvelle valeur. Au final, on obtient un resolvedData optimisé qui intègre les mises à jour du serveur mais pointe encore vers tout ce qui était identique avant. C’est exactement ce qu’on décrivait dans l’exemple des todos, l’objet id 2 a été repris tel quel au lieu d’être recréé. C’est cette opération qu’on appelle le structural sharing. Elle se produit automatiquement tant que l’option structuralSharing est à true (par défaut). Le cœur de replaceEqualDeep peut être vu comme une forme de comparaison partielle puis copie. On parcourt les objets et tableaux et on fait un shallow compare des champs. Si une clé est présente dans l’ancienne et la nouvelle donnée avec des valeurs qui s’avèrent profondément égales, on remet l’ancienne valeur. Si ça diffère, on prend la nouvelle valeur (ou on creuse plus loin dans l’arborescence). In fine, on retourne une structure qui peut être un mélange d’éléments anciens et nouveaux.

Si votre requête utilise un sélecteur (select), le QueryObserver va ensuite appliquer cette fonction pour dériver les données. Là encore, TanStack Query veille à optimiser. Le résultat du select subit lui aussi un structural sharing par rapport au résultat précédent du même select. En effet, chaque observer stocke la dernière valeur sélectionnée. S’il recalcule une nouvelle valeur via select et que, profondément, rien n’a changé dans ce résultat dérivé, il peut conserver l’ancienne référence. En pratique, cela signifie que même après une transformation, si le shape final est identique, vous garderez exactement la même sortie. Comme mentionné plus tôt, ceci est particulièrement utile si votre select ne renvoie qu’une petite partie de l’info, comme un nombre d’items. Tant que ce nombre reste le même, le composant ne sera pas notifié du changement d’autres détails ailleurs. Techniquement, TanStack Query applique donc le partage structurel deux fois lorsqu’il y a un select. D’abord sur les données brutes du cache, puis sur le résultat du select). Une fois le nouveau résultat construit (après structural sharing), le QueryObserver compare ce nouveau résultat avec le précédent.

TanStack Query prend vraiment soin de ne provoquer des re-rendus que quand c’est nécessaire. Le duo QueryObserver + replaceEqualDeep est le cœur de cette logique.

Pour les curieux, il est possible de lire les tests associés dans le repo GitHub, qui couvrent des cas comme “ne pas réexécuter le select si les données n’ont pas changé” ou “réutiliser l’ancien objet si le nouveau est profondément égal”, ce qui confirme bien le fonctionnement décrit.Les tests du core montrent par exemple que si newData est profondément identique à oldData, la fonction renverra carrément l’instance oldData inchangée. Et si une petite portion diffère, seul cet îlot sera remplacé, le reste venant de l’ancien objet.

0
Subscribe to my newsletter

Read articles from LeDevNovice directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

LeDevNovice
LeDevNovice

I'm Greg, aka LeDevNovice, a very passionate developer interested by programming and web subjects. I'm convince that in the web and programming, there is always something to learn. So I would always be a novice in terms of the amount of knowledge to learn. I always be LeDevNovice.