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).