- 8 minutes

useSyncExternalStore dans React

React
Typescript
Laravel
Markdown logo

Un composant React utilise habituellement de la donnée provenant d’un état (useState, useReducer…), de propriétés passées en entrée, ou d’un contexte (useContext). Tout ça est géré par React.

Mais comment fait-on quand on veut suivre une donnée que React ne gère pas ? Par exemple, comment des bibliothèques comme MobX ou Zustand nous fournissent un état qui semble naturellement suivi par React ?

Suivre une donnée externe

useSyncExternalStore, disponible depuis la version 18 de React, permet de lire et suivre de la donnée provenant de l’extérieur de React, par exemple : une bibliothèque de gestion d’état, ou bien une valeur fournie par le navigateur, et qui pourrait changer.

Dans le navigateur

Quand on crée une application Laravel avec Inertia et le React Starter Kit, on se retrouve avec pas mal de code déjà généré côté front.

Parmi les fichiers générés, on trouve plusieurs hooks dans le dossier resources/js/hooks. Deux d’entre eux, useIsMobile et useAppearance, permettent de suivre les dimensions du navigateur et le thème à appliquer à la page (clair ou sombre).

useIsMobile

Observons le code du hook useIsMobile.

1 - mql : MediaQueryList

Le point de départ de cette fonctionnalité se trouve dans mql, une MediaQueryList qui contient les informations dont on a besoin : est-ce que la largeur de la fenêtre est inférieure à MOBILE_BREAKPOINT - 1, soit 767px ?

const mql =
  typeof window === "undefined"
    ? undefined
    : window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);

Donc la question qu’on voudra poser, c’est : est-ce que la media query déclarée dans mql match ou pas ?

Pour déclarer cette media query, on a utilisé window.matchMedia, une fonctionnalité disponible dans l’API du navigateur. Ce n’est donc pas une donnée qu’on a déclarée dans notre application, sous forme d’état.

2 - Changements de la valeur

Pour suivre l’évolution de cette donnée, on va écouter l’événement change sur la media query :

mql.addEventListener("change", callback);

L’information sur cette media query existe donc en-dehors de notre application, dans le navigateur, et son évolution est gérée par un événement tout aussi extérieur à notre application.

3 - Synchronisation avec React

On va donc vouloir se synchroniser à cette donnée, garder un oeil dessus (l’observer ou l’écouter, finalement) pour que React sache quand déclencher un rendu des composants qui en auront besoin.

C’est là que useSyncExternalStore nous est utile :

export function useIsMobile(): boolean {
  return useSyncExternalStore(
    mediaQueryListener, // subscribe
    isSmallerThanBreakpoint, // getSnapshot
    getServerSnapshot, // getServerSnapshot
  );
}

Pour le paramètre subscribe, on passe la fonction mediaQueryListener qui attend un callback. C’est React qui se chargera d’appeler cette fonction et qui lui fournira le callback.

function mediaQueryListener(callback: (event: MediaQueryListEvent) => void) {
  //...
  mql.addEventListener("change", callback);
  //...
}

4 - Lecture de la valeur

Pour récupérer la valeur qu’on veut, React utilisera le deuxième paramètre passé à useSyncExternalStore : getSnapshot. On lui passe la fonction isSmallerThanBreakpoint.

export function useIsMobile(): boolean {
  return useSyncExternalStore(
    mediaQueryListener, // subscribe
    isSmallerThanBreakpoint, // getSnapshot
    getServerSnapshot,
  );
}

Dans notre cas, on va simplement renvoyer l’information qu’on souhaite : est-ce que la media query match ou pas ?

function isSmallerThanBreakpoint(): boolean {
  return mql?.matches ?? false;
}

Au changement de cette valeur, React sait qu’il faut re-rendre les composants qui utilisent ce hook.

Par exemple, toujours dans notre application Laravel avec Inertia, dans le composant resources/js/components/nav-user.tsx :

export function NavUser() {
    //...
    const isMobile = useIsMobile(); // Récupération de la valeur de la media query

    return (
      { /* ... */ }
      <DropdownMenuContent
          className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
          align="end"
          side={
              isMobile
                  ? 'bottom' // Si mobile, on affiche en bas
                  : state === 'collapsed'
                    ? 'left'
                    : 'bottom'
          }
      >
          {/* ... */}
      </DropdownMenuContent>
      { /* ... */ }
    );
}

5 - Côté serveur

En cas de rendu d’un composant côté serveur, on n’a pas de fenêtre (objet window), donc pas accès à l’API du navigateur. mql sera donc undefined et getServerSnapshot renverra false.

const mql =
  typeof window === "undefined"
    ? undefined
    : window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);

//...

function getServerSnapshot(): boolean {
  return false;
}

6 - Nettoyage

La méthode de souscription mediaQueryListener retourne elle-même une fonction. À la manière de ce que retourne un useEffect, son rôle est de nettoyer derrière elle, dans deux situations :

  • Démontage d’un composant
  • Changement de référence de la fonction de souscription

Le nettoyage à effectuer est simple, on supprime le listener qu’on avait installé :

function mediaQueryListener(callback: (event: MediaQueryListEvent) => void) {
  //...

  // Fonction de nettoyage (cleanup)
  return () => {
    mql.removeEventListener("change", callback);
  };
}

Notre fonction de souscription est mediaQueryListener, déclarée au niveau du fichier useIsMobile.tsx, donc du module : elle est stable et ne changera pas. La synchronisation est donc supprimée (nettoyée) au démontage du composant uniquement.

useAppearance

Observons à présent le code du hook useAppearance.

1 - currentAppearance

currentAppearance est la donnée qui va contenir l’apparence (light, dark ou system) et qui évoluera. C’est elle qui servira de snapshot, d’instantané quand React aura besoin de récupérer l’apparence. C’est donc la valeur de currentAppearance qu’on voudra suivre dans React.

let currentAppearance: Appearance = "system";

2 - Modifications

currentAppearance pourra être modifiée dans deux cas :

  • initialisation du thème à partir du localStorage (ne concerne pas notre hook, est appelée au chargement de l’application)
  • action de l’utilisateur depuis l’interface

3 - Branchement avec React

Dans le hook useAppearance, on synchronise notre currentAppearance avec React :

export function useAppearance(): UseAppearanceReturn {
  const appearance: Appearance = useSyncExternalStore(
    subscribe,
    () => currentAppearance,
    () => "system",
  );

  //...
}

La méthode subscribe, quant à elle, ajoute manuellement un listener qui sera appelé lors du changement de l’apparence.

4 - Mises à jour

Le hook useAppearance déclare et retourne une fonction updateAppearance :

export function useAppearance(): UseAppearanceReturn {
  //...

  const updateAppearance = useCallback((mode: Appearance): void => {
    currentAppearance = mode;

    // Store in localStorage for client-side persistence...
    localStorage.setItem("appearance", mode);

    // Store in cookie for SSR...
    setCookie("appearance", mode);

    applyTheme(mode);
    notify();
  }, []);

  return { appearance, resolvedAppearance, updateAppearance } as const;
}

Dans cette méthode, on applique le thème choisi, puis on notifie les(s) écouteur(s) installé(s) : c’est à ce moment-là que React peut déclencher les rendus des composants qui utilisent ce hook.

De cette façon, notre application est naturellement synchronisée avec tout changement opéré par l’utilisateur.

Bibliothèques externes

On vient de voir, à travers deux exemples, qu’on pouvait brancher une application React sur une donnée externe provenant du navigateur.

Mais qu’en est-il dans le cas d’une bibliothèque ?

MobX

MobX est une bibliothèque de gestion d’état qu’on peut utiliser au sein d’une application React.

Dans son intégration à React, MobX inclut entre autres un composant Observer et un composant d’ordre supérieur (HOC) observer permettant de déclencher automatiquement un rendu au changement de valeur d’un Observable.

Pour brancher la gestion d’état (interne à MobX) et le rendu des composants (dont se charge React), elle déclare un hook useObserver qui utilisera lui-même useSyncExternalStore.

Ainsi, lors d’un changement d’état interne à MobX, React sera synchronisée et pourra effectuer un rendu si nécessaire.

Zustand

Zustand est une autre bibliothèque de gestion d’état, qui se présente comme une alternative à Redux ou encore l’API Context présente dans React.

Dans le fichier src/react.ts, on retrouve l’utilisation de useSyncExternalStore pour brancher la logique de gestion d’état (propre à Zustand) au déclenchement d’un rendu si nécessaire.

Conclusion

useSyncExternalStore ne remplace pas les états gérés par React, mais permet de suivre des données externes à React, comme un état géré par une bibliothèque ou encore une valeur fournie par le navigateur (issue d’une API ou bien de notre application).