Les limites de Zustand par rapport à TanStack Query dans un monorepo Next.js en temps réel.
Apprendre une règle claire de gestion des droits d’accès pour l’état de l’interface utilisateur, l’état du serveur et les événements en temps réel dans un monorepo collaboratif Next.js utilisant Zustand, TanStack Query et des sockets.
Sources: Zustand for UI, TanStack Query for the Server: The Split That Finally Made Our App Sane
De nombreux projets Next.js commencent avec un petit stockage Zustand pour le « état global », qu’ils transforment progressivement en un système englobant tout : d’abord l’utilisateur actuel, puis la liste des documents, ensuite une carte de présence provenant du socket, et enfin les modifications optimistes effectuées par quelqu’un. Quelques mois plus tard, ce stockage entre en conflit avec le rendu serveur, les invalidations affectent les données incorrectes, et chaque demande de fusion rouvre le débat sur l’endroit où un élément de données devrait être stocké. Cet article présente une règle simple de gestion des responsabilités pour une application collaborative, montre le code du stockage et des requêtes qui la mettent en œuvre dans un monorepo, et explique comment les événements en temps réel, les sessions d’authentification et les performances s’intègrent dans ce même modèle.
Il s’agit d’un produit reposant fortement sur la collaboration : édition de texte enrichi avec Tiptap et Hocuspocus, Socket.io pour une présence légère et des notifications, ainsi qu’un monorepo regroupant l’application web, les composants UI partagés et les types API. Dans ce cadre, intégrer la mémoire cache du serveur dans Zustand n’est pas seulement peu ordonné ; cela perturbe le SSR, rend invalides des éléments inappropriés et transforme le suivi des réaffichages inattendus en une tâche récurrente.
Une règle de gestion unique pour trois types d’état
Cette règle est délibérément simple, ce qui la rend facile à appliquer sous pression.
- Zustand gère l’état de l’UI. Il s’agit de tout ce qui n’apparaît jamais dans une réponse HTTP : le fait que la barre latérale soit ouverte, l’onglet sélectionné, les indicateurs pour les modaux de brouillon, le cadre entourant l’éditeur, la présence du menu de commandes par barre oblique, ainsi que les préférences de mise en page temporaires.
Un test rapide résout la plupart des controverses : si la valeur serait perdue lors du rechargement d’une page sans que cela ne préoccupe personne, il s’agit d’un état UI. Si sa perte signifie qu’il faut la récupérer à nouveau, il s’agit d’un état du serveur.
Deux bugs qui apparaissent lorsque la frontière devient floue
Fenêtres clignotantes et onglets en désaccord après SSR
La première approche courante consiste à hydrater une liste de documents dans Zustand du côté client après que les données ont été récupérées sur le serveur, d’abord via getServerSideProps et ensuite grâce aux composants serveur React associés à une requête côté client. Lors d’un rechargement forcé, les données du serveur s’affichent d’abord, puis Zustand est initialisé avec des données vides avant d’être à nouveau rempli. L’utilisateur perçoit ainsi une étincelle visuelle. Pire encore, deux onglets ouverts peuvent afficher des listes différentes, car un stockage Zustand ne possède aucune notion d’identité de cache ; il s’agit simplement d’un état en mémoire.
TanStack Query a été conçu pour résoudre ce problème. Son option initialData ainsi que son support pour le déshydratation et l’hydratation permettent au client de récupérer exactement ce que le serveur a rendu. Zustand ne propose rien d’équivalent, car il n’a jamais été conçu pour servir de cache.
Mises à jour optimistes qui ne sont jamais annulées
Le deuxième mode de défaillance est l’optimisme manuel. Un composant appelle set() pour afficher un nouveau titre de document dans Zustand, déclenche la mutation, puis oublie de restaurer la valeur précédente en cas d’échec de la requête. Les fonctions de rappel onMutate, onError et onSettled de TanStack Query vous offrent une méthode de retrait prévisible. Un flux optimiste personnalisé dans Zustand a tendance à être inventif mais incorrect.
En déplaçant les mutations vers Query et en conservant Zustand pour des éléments tels que « le dialogue de renommage est ouvert », on élimine la plupart des bugs de ce type.
Structurer l’état au sein du monorepo
Dans un espace de travail de type Turborepo, la structure ressemble à peu près à ceci :
apps/web → Next.js app
packages/ui → shared components (ShadCN-ish)
packages/state → Zustand stores + Query keys/hooks
packages/api → shared types / Prisma-ish DTOs
La décision la plus importante consiste à placer à la fois les clés Query et les stores Zustand dans packages/state. Lorsque chaque dossier de fonctionnalité invente son propre système de nommage pour les clés, l’invalidation du cache devient une affaire de suppositions.
Un store Zustand limité à l’interface de l’éditeur
Le store ci-dessous ne suit que ce dont a besoin l’interface entourant l’éditeur : la visibilité de la barre latérale, le menu en barre oblique, et le panneau latéral actif. Chaque champ dispose d’un simple setteur, et resetChrome ferme l’interface temporaire sans toucher aux préférences de la barre latérale. Notez que le fragment publié utilise des guillemets typographiques autour des littéraux de chaîne, ce qui empêchera la compilation ; remplacez-les par des guillemets droits lors de son adaptation.
// packages/state/src/ui-store.ts
import { create } from ‘zustand’
type EditorChromeState = {
sidebarOpen: boolean
slashMenuOpen: boolean
activePanel: ‘comments’ | ‘outline’ | null
setSidebarOpen: (open: boolean) => void
setSlashMenuOpen: (open: boolean) => void
setActivePanel: (panel: EditorChromeState[‘activePanel’]) => void
resetChrome: () => void
}
export const useEditorChrome = create<EditorChromeState>((set) => ({
sidebarOpen: true,
slashMenuOpen: false,
activePanel: null,
setSidebarOpen: (sidebarOpen) => set({ sidebarOpen }),
setSlashMenuOpen: (slashMenuOpen) => set({ slashMenuOpen }),
setActivePanel: (activePanel) => set({ activePanel }),
resetChrome: () =>
set({ slashMenuOpen: false, activePanel: null }),
}))
Ce que le stockage omet est tout aussi important. Il n’y a pas de documents, pas de currentUser, et aucun état de connexion copié depuis le socket. Un petit stockage pour l’état de connexion est acceptable s’il s’agit uniquement d’une question d’UX côté client, bien qu’il soit souvent possible de l’obtenir directement du fournisseur. Cependant, la liste des documents ne doit en aucun cas être stockée ici.
Points d’ancrage de requête, une usine de clés et un renommage sécurisé
Le module documents définit une usine de clés, une requête de liste et une mutation de renommage. L’objet documentKeys génère des clés hiérarchisées et typées : all est la racine, tandis que list et detail l’étendent. La requête de liste utilise un délai staleTime de 30 secondes. La mutation de renommage suit la séquence optimiste standard : annuler les requêtes en cours pour le document, prendre un snapshot de la valeur précédente, écrire le nouveau titre dans le cache, restaurer le snapshot en cas d’erreur, et invalider à la fois les détails et toute la structure des documents une fois la requête finalisée. Comme pour le stockage, ce fragment contient des guillemets typographiques et des points d’interrogation à la place des opérateurs de diffusion, et la chaîne d’importation manque de son guillemet de fermeture ; il convient donc de les corriger avant utilisation.
// packages/state/src/documents.ts
import { useMutation, useQuery, useQueryClient } from ‘@tanstack/react-query
export const documentKeys = {
all: [‘documents’] as const,
list: (workspaceId: string) =>
[…documentKeys.all, ‘list’, workspaceId] as const,
detail: (id: string) =>
[…documentKeys.all, ‘detail’, id] as const,
}
export function useDocuments(workspaceId: string) {
return useQuery({
queryKey: documentKeys.list(workspaceId),
queryFn: () => fetchDocuments(workspaceId),
staleTime: 30_000,
})
}
export function useRenameDocument() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, title }: { id: string; title: string }) =>
renameDocument(id, title),
onMutate: async ({ id, title }) => {
await qc.cancelQueries({ queryKey: documentKeys.detail(id) })
const prev = qc.getQueryData(documentKeys.detail(id))
qc.setQueryData(documentKeys.detail(id), (old: any) =>
old ? { …old, title } : old,
)
return { prev }
},
onError: (_err, { id }, ctx) => {
if (ctx?.prev) qc.setQueryData(documentKeys.detail(id), ctx.prev)
},
onSettled: (_data, _err, { id }) => {
qc.invalidateQueries({ queryKey: documentKeys.detail(id) })
qc.invalidateQueries({ queryKey: documentKeys.all })
},
})
}
La factory de clés effectue plus de travail qu’il n’y paraît. Comme les mêmes clés sont importées partout dans le monorepo, les gestionnaires de sockets et les hooks d’interface visent les mêmes entrées de cache. Il est tout simplement impossible qu’une application mette à jour son stockage sans que le package partagé ne s’en aperçoive. La invalidation de documentKeys.all fonctionne également, car Query compare les clés par préfixe, ce qui permet de marquer comme obsolètes toutes les listes et entrées détaillées correspondantes en une seule opération. Pour en savoir plus sur l’organisation d’une couche Query de cette manière, consultez structurer une couche de données TanStack Query depuis queryOptions jusqu’aux rollbacks.
Intégrer les mises à jour en temps réel dans le modèle
Dans cette architecture, Hocuspocus gère le document de l’éditeur collaboratif, tandis que Socket.io traite des flux de données moins volumineux tels que l’état de présence et les notifications. Une solution de conception initiale tentante consiste à utiliser usePresenceStore pour refléter la liste complète des membres provenant du socket, accompagnée d’un cache de requêtes contenant les mêmes membres chargés via REST. Conserver deux copies identiques d’une même liste synchronisées est précisément le type de duplication qui provoque des bugs problématiques.
Une meilleure répartition des responsabilités :
- La liste initiale des membres provient d’un hook de requête tel que
useWorkspaceMembers. - Les événements de présence ne mettent à jour que des champs temporaires, comme l’état en ligne ou la couleur du curseur, stockés soit dans une partie compacte de Zustand, soit dans un état par composant indexé par l’ID utilisateur.
Le contenu collaboratif reste à l’intérieur de Y.Doc, géré par Y.js et Tiptap. Copier tout le JSON de ProseMirror dans Zustand pour plus de commodité crée deux représentations du document qui finiront par être en désaccord, généralement au moment le moins opportun.
Leçons de performance tirées d’un éditeur collaboratif
Sélectionner des parties spécifiques plutôt que de lire l’ensemble du stockage
Appeler useEditorChrome((s) => s.sidebarOpen) réaffiche un composant uniquement lorsque cette valeur change. Appeler ce hook sans sélecteur écoute tous les champs du store, de sorte que la saisie dans un modal qui met à jour n’importe quel champ peut réafficher un panneau d’interface non lié. Ce conseil semble évident, pourtant il est facile de mettre en place des écouteurs sur l’ensemble du store par accident.
Éloignez les mouvements fréquents du curseur de React
Les curseurs en temps réel constituent un piège classique dans les éditeurs collaboratifs. Si chaque déplacement du curseur passe par Zustand pour atteindre React, ce qui déclenche ensuite un réajustement de l’affichage, l’éditeur deviendra lent. Y.js est conçu pour ce type de données rapides et éphémères ; laissez-le gérer les curseurs et conservez l’état de React pour les éléments de l’interface qui changent plus lentement.
Considérez staleTime comme une décision de conception
staleTime doit refléter l’impact négatif des données obsolètes pour une interface donnée. Lorsque des permissions dépassées peuvent être dangereuses, comme sur les interfaces SaaS de type santé, il convient d’utiliser une valeur faible et de invalider rapidement les données dès que l’interface redevient active. Pour une liste de documents qui change rarement, une valeur plus élevée permet d’économiser des ressources réseau. Une seule valeur globale staleTime: 0 rendait une application Next.js nettement plus gourmande en bande passante sans aucun avantage.
Hydrater l’état du serveur, pas le décor de l’UI
Avec SSR ou RSC, il faut déshydrater les données Query sur le serveur afin que la première affichage soit correcte, et conserver Zustand exclusivement du côté client sauf s’il existe une raison impérieuse de ne pas le faire. Afficher l’état du décor de l’UI sur le serveur n’en vaut rarement la complexité.
Conventions qui maintiennent cette séparation
Quelques conventions d’équipe suffisent à obtenir de meilleurs résultats que n’importe quelle couche d’abstraction supplémentaire :
- Gardez un seul module
documentKeyset importez-le depuis les hooks, les gestionnaires de sockets ainsi que les tests. - N’effectuez jamais de requêtes depuis une action Zustand. Une action qui a besoin du réseau est en réalité un hook de mutation, et non une appel à
set(). - Les dossiers de fonctionnalités peuvent conserver leurs propres stores UI. Il est acceptable de placer un
useSlashCommandStoreà côté de l’éditeur ; en revanche, les entités serveur partagées entre applications ne le sont pas. - Importez les types partagés depuis
packages/api. Chaque fois que le DTO de renommage est modifié, les hooks Query et les payloads Prisma changent en même temps, ce qui fait que la mise à jour du cache cesse de dépendre de chaînes de caractères de type lâche.
L’objectif n’est pas une stratégie complexe de gestion d’état. Il s’agit plutôt d’une règle que tout ingénieur, même fatigué, peut appliquer correctement tard dans la nuit.
Les sessions d’authentification et les flags de fonctionnalité font également partie de l’état du serveur
La gestion des sessions dans les applications SaaS Next.js est une source fréquente d’état dupliqué. La session elle-même, qu’il s’agisse d’un cookie, d’un JWT ou d’un objet de type NextAuth, constitue un état du serveur, tout comme le profil affiché dans l’interface de l’application. Le stockage en cache de l’utilisateur dans Zustand afin d’accélérer l’affichage du menu de navigation crée une deuxième source de vérité qui devient obsolète dès que les permissions changent : lorsqu’une invitation est acceptée, qu’un rôle est élevé ou que l’utilisateur change d’organisation.
Une solution plus robuste :
- Sur le serveur, lire la session via votre outil d’authentification chaque fois que c’est possible.
- Sur le client, exposer l’utilisateur à travers un hook Query comme
useMe, avec une clé stable telle que['me'].
activeWorkspaceId, car le choix d’un espace de travail relève d’une sélection dans l’interface utilisateur. Il ne devrait pas en même temps contenir l’ensemble des informations relatives aux membres, étant donné que cet ensemble se trouve déjà dans Query.Lorsque le socket annonce un changement de rôle, invalidez ['me'] ainsi que les clés correspondant à l’espace de travail concerné. Modifier manuellement plusieurs champs de Zustand en espérant n’en oublier aucun est la manière dont les erreurs de permissions échappent à la détection.
Les flags fonctionnels suivent la même logique. Les flags fournis par l’API appartiennent à Query. Les modifications locales aux fins de débogage peuvent être stockées dans Zustand, à condition que le store soit nommé de manière univoque, par exemple useDevFlagOverrides, afin que personne ne le confonde avec les valeurs de production.
Compromis et moments où il convient d’ignorer cette approche
Utiliser à la fois Zustand et TanStack Query signifie devoir apprendre deux bibliothèques, ce qui représente un vrai fardeau pour les nouveaux ingénieurs intégrés au projet. Un bref document STATE.md expliquant cette séparation, accessible depuis le modèle de demande de fusion, s’avère rapidement utile.
Certains états concernent réellement les deux approches, comme un document sélectionné. La solution consiste à ne stocker que l’id dans Zustand et à lire l’entité via useQuery ou getQueryData. Copier l’entité dans le stockage de l’interface entraîne à nouveau le problème de duplication.
Pour un petit site de marketing disposant d’un seul formulaire, cette structure est excessive. En revanche, pour un produit avec des éditeurs en temps réel, des espaces de travail protégés par authentification et des packages partagés dans un monorepo, elle devient rapidement indispensable.
Liste de contrôle pour un nouveau projet dès le premier jour
- Installez TanStack Query et définites dès le début une usine de clés bien définie.
- Créez un seul stockage Zustand pour le shell et l’interface utilisateur.
- Connectez les événements du socket et de Hocuspocus à
queryClient.setQueryDataetinvalidateQueries. - Rejetez les demandes de fusion qui récupèrent des données à l’intérieur de Zustand.
- Au préalable de tout autre optimisation, analysez la fréquence à laquelle le routeur de l’éditeur se rérendu.
Points clés
- Décidez de la responsabilité en vous demandant quel état peut disparaître à la mise à jour et lequel doit persister lors du changement de onglet ; le premier correspond à l’état de l’interface, le second à l’état du serveur.
- Les événements en temps réel sont des entrées pour le cache Query ou Y.Doc, jamais une raison de créer un stockage parallèle.
- C’est une usine de clés partagée qui assure une invalidation fiable entre les applications, les packages et les gestionnaires de socket.
- Stockez des identifiants, et non des entités, lorsque les sélections de l’interface font référence à des données serveur.