Subscribable et NotifyManager dans Tanstack Query

LeDevNoviceLeDevNovice
6 min read

Sous le capot, Tanstack Query implémente un pattern observer afin de pouvoir réagir aux changements des données et mettre à jour l’UI. La lib est pensé pour être agnostique du framework UI. Elle peut fonctionner avec React, mais aussi Solid, Vue, Svelte, ou d’autres encore.

Mais cette indépendance a des implications. Il faut forcément un mécanisme générique pour abonner et désabonner des observateurs à certaines entités (les requêtes, le cache, etc…), et un moyen de notifier ces observateurs en gérant le timing (pour, par exemple, regrouper les notifications et éviter des rafraîchissements d’UI successifs et inutiles).

C’est précisément le rôle respectif de Subscribable et du notifyManager dans le query core de Tanstack Query.

Subscribable fournit l’infrastructure de base pour qu’une classe du core puisse avoir des abonnés (subscribers) écoutant ses événements. NotifyManager, de son côté, orchestre la manière dont les notifications de changements sont propagées et éventuellement batchées (regroupées) dans le temps.

Concrètement, Subscribable est un sujet observable auquel des listeners peuvent s’abonner. Plusieurs composants centraux de la bibliothèque en héritent :

  • Le cache de requêtes (QueryCache) est défini comme export class QueryCache extends Subscribable<QueryCacheListener>. Cela signifie que le QueryCache peut avoir des listeners (QueryCacheListener) qui seront notifiés à chaque changement global du cache (ajout, suppression ou mise à jour d’une requête par exemple).

  • De même, chaque observateur de requête (QueryObserver), qui gère l’état d’une requête spécifique pour un composant React, étend aussi Subscribable avec des listeners de type QueryObserverListener. Cela permet aux composants React (via le hook useQuery) de s’abonner aux changements d’une requête donnée à travers l’observateur.

La classe Subscribable maintient en interne une collection (un Set) de listeners. Elle expose typiquement une méthode subscribe(listener) qui ajoute un abonné, et retourne une fonction pour se désabonner.

export class Subscribable<TListener extends Function> {
  protected listeners = new Set<TListener>()

  constructor() {
    this.subscribe = this.subscribe.bind(this)
  }

  subscribe(listener: TListener): () => void {
    this.listeners.add(listener)

    this.onSubscribe()

    return () => {
      this.listeners.delete(listener)
      this.onUnsubscribe()
    }
  }

  hasListeners(): boolean {
    return this.listeners.size > 0
  }

  protected onSubscribe(): void {
    // Do nothing
  }

  protected onUnsubscribe(): void {
    // Do nothing
  }
}

Lorsqu’on appelle sa méthode subscribe(listener), le listener est enregistré (ajouté au set) et la méthode onSubscribe() est appelée. Inversement, lorsque vous appelez la fonction de désinscription retournée, le listener est retiré et onUnsubscribe() est invoqué.

Par défaut, ces deux méthodes ne font rien dans la classe de base. Ce sont des éléments destinés à être surchargés dans les sous classes si besoin. En effet, certaines classes dérivées en profitent pour implémenter une logique spécifique à l’arrivée du premier abonné ou au départ du dernier abonné. Par exemple, dans QueryObserver.onSubscribe, on voit que lorsque le premier observer s’abonne, l’observateur s’attache à la requête correspondante et peut déclencher un fetch initial si nécessaire. Dès qu’un composant s’abonne via un QueryObserver à une requête, l’observateur enregistre ce composant comme listener, ajoute l’observateur à la Query (avec currentQuery.addObserver(this)), puis vérifie s’il faut lancer la récupération des données tout de suite. Ce mécanisme garantit que tant qu’aucun composant n’écoute, on n’encombre pas le système à interroger l’API. Mais dès qu’un “écouteur” arrive, la donnée est actualisée au besoin.

On voit bien que Subscribable fournit l’outillage nécessaire pour que chaque composant interne qui gère de l’état (cache, requête, etc.) puisse être écouté de l’extérieur en toute sécurité. Subscribable est le socle du système d’événements interne de Tanstack Query. Il implémente une logique d’abonnement ou pubsub minimaliste permettant d’ajouter un abonné, de le retirer, et permettre aux sous classes de réagir aux transitions “pas de listener → au moins un listener” ou vice-versa par exemple.

Mais si Subscribable est le mécanisme d’abonnement, NotifyManager est lui le mécanisme de notification. Le NotifyManager gère la planification et le batching des callbacks dans Tanstack Query. En clair, c’est un gestionnaire global chargé de contrôler quand et comment on appelle les fonctions de tous ces listeners dont on vient de parler.

Pourquoi avoir besoin d’un tel gestionnaire ?

Imaginons qu’en arrière-plan, plusieurs requêtes se mettent à jour quasi simultanément (par exemple, l’utilisateur vient de revenir en ligne, le focus revient sur la page, et Tanstack Query effectue un refetch de plusieurs queries “stale” ou “périmée” en même temps). Chaque Query va potentiellement changer son état (passer en fetching, puis recevoir de nouvelles données), et le QueryCache lui-même pourrait émettre plusieurs événements à la suite. Sans coordination, cela pourrait entraîner une cascade de notifications et donc plusieurs potentiels rendus React consécutifs (un rendu par mise à jour, ce qui n’est pas optimal). C’est là qu’intervient le NotifyManager. Il va regrouper et différer l’appel aux callbacks pour éviter ce problème.

Dans le hook useBaseQuery (utilisé par useQuery), on a observer.subscribe(notifyManager.batchCalls(onStoreChange));.

Ici, onStoreChange est la fonction qui va provoquer un re-render du composant. En la passant à notifyManager.batchCalls, on s’assure que si plusieurs notifications arrivent très vite, elles ne déclencheront qu’une seule fois le onStoreChange, peu importe le nombre de changements de la Query dans l’intervalle. C’est crucial pour la performance et la stabilité. Par exemple, si la Query subit plusieurs modifications successives, le composant ne fera qu’un seul rendu final avec l’état à jour, au lieu de deux rendus intermédiaires.

Tanstack Query est pensée dans son implémentation pour minimiser le travail inutile côté UI tout en s’assurant que rien ne passe entre les mailles du filet.

NotifyManager est le chef d’orchestre des notifications dans Tanstack Query. Il veille à ce que les appels aux abonnés soient faits au bon moment et de la bonne manière. Ni trop tôt (risque d’états intermédiaires inutiles), ni trop souvent (risque de surcharger le rendu).

Finalement, lorsqu’un composant utilise useQuery, la bibliothèque crée en interne un QueryObserver lié à la Query demandée. Ce QueryObserver est un Subscribable. Il accepte des listeners. Le composant React s’y abonne pour être notifié des changements de données. Grâce à notifyManager.batchCalls, cet abonnement est fait de sorte à batcher les notifications de l’observer vers le composant. À ce stade, si c’est le premier abonné sur cette Query, onSubscribe du QueryObserver va attacher l’observateur à la Query et éventuellement lancer le fetch initial. La boucle est enclenchée.

Supposons désormais que la Query effectue sa requête réseau et reçoit une réponse. Elle met à jour son état interne (données, statut, etc…). Dans le code, à chaque mise à jour significative, la Query va notifier ses observateurs. Ici aussi, Tanstack Query utilise potentiellement le NotifyManager pour décaler ou batcher ces notifications. Par exemple, s’il y a plusieurs observers (plusieurs composants écoutant la même Query), tous seront notifiés en bloc.

Lorsqu’un QueryObserver reçoit la notification d’une Query, il va à son tour mettre à jour son état d’observer. Ensuite, il déclenche ses propres listeners. En pratique, il y en a généralement un par observer, le fameux callback onStoreChange du composant. Grâce au NotifyManager, l’appel de ce callback a été programmé dans un batch. Donc même si 5 choses se sont passées quasi simultanément, chaque onStoreChange sera appelé de façon asynchrone dans le batch suivant, garantissant au passage que React ne fera qu’un seul rendu pour l’ensemble de ces 5 changements.

Enfin, React fait effectivement un nouveau rendu du composant avec les nouvelles données de la Query. L’UI se met à jour pour refléter l’état du serveur actualisé. Si d’autres changements surviennent plus tard, le même mécanisme se répète. La Query met à jour ses observers, les observers notifient les composants via NotifyManager, etc…

Ce ballet entre Subscribable et NotifyManager est au cœur de l’exécution de Tanstack Query. Subscribable fournit la colle qui lie les différentes pièces du puzzle entre elles, en permettant à chacune de s’abonner aux événements d’une autre (les QueryObservers s’abonnent aux Query, les composants s’abonnent aux QueryObservers, etc…). C’est l’implémentation interne d’un pattern observer sur mesure pour la lib. NotifyManager est lui le chef de timing qui fait en sorte que toutes ces notifications ne se marchent pas dessus et arrivent de manière optimisée. Il permet à Tanstack Query d’être réactif tout en restant efficace, en évitant l’écueil d’un déclenchement naïf et immédiat de chaque callback au moindre changement.

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.