Zustand versus TanStack Query Grenzen in einem Echtzeit-Next.js Monorepo
Erlernen Sie eine klare Regel für das Eigentumsverhältnis von UI-Zuständen, Server-Zuständen und Echtzeit-Ereignissen in einem kollaborativen Next.js-Monorepo unter Verwendung von Zustand, TanStack Query und Sockets.
Quellen: Zustand for UI, TanStack Query for the Server: The Split That Finally Made Our App Sane
Viele Next.js-Codebasen beginnen mit einem kleinen Zustandsspeicher für den „globalen Zustand“ und entwickeln ihn allmählich zu einem Allzweckwerkzeug: Zuerst wird der aktuelle Benutzer gespeichert, anschließend die Dokumentliste, danach eine Präsenzmappe über den Socket und schließlich optimistische Änderungen von Benutzern. Einige Monate später kämpft der Speicher mit dem Server-Rendering, die Invalidation betrifft falsche Daten, und jeder Pull Request führt erneut zu Diskussionen darüber, wo ein Datensatz gespeichert werden sollte. Dieser Artikel legt eine einfache Eigentumsregel für gemeinsam genutzte Anwendungen dar, zeigt den Speicher- und Abfragedcode, der sie in einem Monorepo umsetzt, und erläutert, wie Echtzeitereignisse, Authentifizierungs Sessions und Leistung in dasselbe Modell passen.
Dieses Szenario beinhaltet ein produktorientiertes Konzept mit starkem Fokus auf Zusammenarbeit: Rich-Text-Editierung mithilfe von Tiptap und Hocuspocus, Socket.io für eine leichte Überwachung der Benutzerpräsenz und Benachrichtigungen sowie ein Monorepo, das die Webanwendung, gemeinsam genutzte UI-Komponenten und API-Typen zusammenhält. In dieser Konfiguration ist die Einbeziehung eines Servercaches in Zustand nicht nur unübersichtlich, sondern stört auch den SSR-Prozess, führt dazu, dass falsche Daten ungültig werden, und macht das Nachverfolgen unerwarteter Neuladungen zu einer ständigen Aufgabe.
Eine Regel für die Verwaltung von drei Arten von Zuständen
Die Regel ist absichtlich einfach gehalten, was es ermöglicht, sie auch unter Druck leicht anzuwenden.
- Zustand ist für den UI-Zustand verantwortlich. Dazu gehören alle Elemente, die niemals in einer HTTP-Antwort enthalten sind: ob die Seitenleiste geöffnet ist, welche Registerkarte ausgewählt wird, Flags für Entwurfsmodale, der umgebende Rahmen des Editors, ob das Slash-Command-Menü angezeigt wird sowie kurzlebige Layout-Einstellungen.
Ein schneller Test klärt die meisten Zweifel: Wenn der Wert bei einem Seitenaufruf verloren geht und niemandem etwas ausmacht, handelt es sich um UI-Zustand. Wenn sein Verlust bedeutet, dass er erneut abgerufen werden muss, handelt es sich um Serverzustand.
Zwei Fehler, die auftreten, wenn die Grenze verschwimmt
Flackernde sowie unübereinstimmende Tabellen nach SSR
Ein gängiger erster Ansatz besteht darin, nach dem Abrufen der Daten auf dem Server – zunächst über getServerSideProps und später über React Server Components in Kombination mit einem Client-Fetch – eine Dokumentliste im Zustand des Clients zu speichern. Bei einem kompletten Neuladen wird zunächst die Serverdaten angezeigt, anschließend initialisiert Zustand diesen mit leeren Werten und füllt ihn danach wieder auf. Der Benutzer sieht dabei einen kurzen Bildschirmflimmern. Noch schlimmer ist, dass zwei geöffnete Tabellen unterschiedliche Listen anzeigen können, da ein Zustand-Speicher keine Kenntnis von Cache-Identitäten hat; es handelt sich dabei um reinen In-Memory-Zustand.
TanStack Query wurde genau für dieses Problem entwickelt. Seine Option initialData sowie die Unterstützung für Dehydration und Hydration ermöglichen es dem Client, genau das zu erhalten, was der Server bereitgestellt hat. Zustand bietet nichts Ähnliches, da er nie als Cache konzipiert wurde.
Optimistische Aktualisierungen, die niemals rückgängig gemacht werden
Der zweite Fehlermodus ist der manuell umgesetzte Optimismus. Ein Komponente ruft set() auf, um einen neuen Dokumenttitel in Zustand anzuzeigen, löst die Mutation aus und vergisst, den alten Wert wiederherzustellen, falls die Anfrage fehlschlägt. Die Callbacks onMutate, onError und onSettled von TanStack Query bieten einen vorhersehbaren Weg zur Rücksetzung. Ein benutzerdefinierter Optimismus-Flow in Zustand neigt dazu, kreativ – aber fehlerhaft – zu sein.
Durch Verlegung der Mutationen in Query und Beibehaltung von Zustand für Informationen wie „das Umbenennungsdialogfeld ist geöffnet“ verschwindet der größte Teil dieser Fehlerarten.
Strukturierung des Zustands innerhalb des Monorepos
In einem Turborepo-ähnlichen Arbeitsumfeld sieht die Struktur ungefähr so aus:
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
Die wichtigste Entscheidung besteht darin, sowohl die Query-Schlüssel als auch die Zustand-Speicher in packages/state unterzubringen. Wenn jedes Feature-Verzeichnis sein eigenes Namensschema für Schlüssel entwickelt, wird die Cache-Invalidierung zu einer Schätzung.
Ein Zustand-Speicher, der auf den Editor-Chrome beschränkt ist
Der untenstehende Speicher verfolgt nur das, was die umgebende Benutzeroberfläche des Editors benötigt: Sichtbarkeit der Seitenleiste, das Schrägstrich-Menü sowie welches Seitensymbol aktiv ist. Jedes Feld verfügt über einen einfachen Setter, und resetChrome schließt vorübergehende UI-Elemente, ohne die Einstellungen der Seitenleiste zu verändern. Beachten Sie, dass im veröffentlichten Beispiel typografische Anführungszeichen um String-Literale verwendet werden, was zu einem Kompilierfehler führt; ersetzen Sie diese bei der Anpassung durch normale Anführungszeichen.
// 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 }),
}))
Was der Store weglässt, ist genauso wichtig. Es gibt keine Dokumente, keinen currentUser, und kein Verbindungsstatus, der vom Socket kopiert wird. Ein kleiner Store für den Verbindungsstatus ist akzeptabel, wenn es sich ausschließlich um eine Frage der Client-Seite-UX handelt – obwohl dieser oft auch vom Anbieter abgeleitet werden kann. Die Dokumentenliste jedoch darf auf keinen Fall hier gespeichert werden.
Abfrage-Hooks, eine Schlüssel-Factory und ein sicheres Umbenennen
Das Dokumentenmodul definiert eine Schlüsselgenerierungsfunktion, eine Abfragemethode für Listen und eine Mutation zum Umbenennen. Das Objekt documentKeys erzeugt hierarchische, typisierte Schlüssel: all ist der Wurzelschlüssel, während list und detail ihn erweitern. Die Abfragemethode verwendet eine staleTime-Wert von 30 Sekunden. Die Umbenennungsmutation folgt der standardmäßigen optimistischen Vorgehensweise: Es werden laufende Abrufe des Dokuments abgebrochen, der vorherige Wert gespeichert, der neue Titel im Cache geschrieben, bei Fehlern wieder der ursprüngliche Wert hergestellt und sowohl der Detaileintrag als auch der gesamte Dokumentenbaum nach Abschluss der Anfrage ungültig gemacht. Wie im Store enthält auch dieser Auszug typografische Anführungszeichen sowie Ellipsen anstelle von Spread-Operatoren, und der Importstring fehlt das schließende Anführungszeichen – beheben Sie diese Probleme vor der Verwendung.
// 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 })
},
})
}
Die Schlüssel-Factory erledigt mehr Arbeit, als es auf den ersten Blick scheint. Da dieselben Schlüssel überall im Monorepo importiert werden, richten sich Socket-Handler und UI-Hooks auf identische Cache-Einträge. Eine Situation, in der eine Anwendung ihren Speicher aktualisiert und ein gemeinsames Paket davon nie erfährt, kann einfach nicht auftreten. Die Invalidation von documentKeys.all funktioniert ebenfalls, da Query Schlüssel anhand des Präfixes abgleicht – dadurch werden alle darunter liegenden Listen- und Detail-Einträge mit einer einzigen Aufrufung als veraltet markiert. Für eine ausführlichere Behandlung der Organisation einer Query-Schicht auf diese Weise siehe Die Strukturierung einer TanStack Query-Datenlayer von queryOptions bis Rollbacks.
Echtzeit-Updates in das Modell integrieren
In diesem Stack kümmert sich Hocuspocus um das Dokument des gemeinsamen Editors, während Socket.io leichtere Datenströme wie Präsenzinformationen und Benachrichtigungen überträgt. Ein verlockender früher Entwurf ist ein usePresenceStore, der die vollständige Mitgliederliste aus dem Socket widerspiegelt, zusammen mit einem Query-Cache derselben Mitglieder, die über REST geladen werden. Das Halten von zwei Kopien derselben Liste in Einklang zu bringen, ist genau die Art von Doppelung, die schmerzhafte Fehler verursacht.
Eine bessere Aufteilung der Verantwortlichkeiten:
- Die ursprüngliche Mitgliederliste stammt von einem Query-Hook wie
useWorkspaceMembers. - Präsenzereignisse aktualisieren nur vorübergehende Felder wie den Online-Status oder die Mauszeigerfarbe, die entweder in einem kompakten Zustand-Slice oder im pro-Komponenten-Zustand unter Verwendung der Benutzer-ID gespeichert werden.
Kollaborativer Inhalt bleibt innerhalb von Y.Doc, das von Y.js und Tiptap verwaltet wird. Um Bequemlichkeit willen wird das gesamte ProseMirror-JSON in Zustand kopiert, was zwei Darstellungen des Dokuments erzeugt, die letztendlich nicht übereinstimmen – meist zum unpassendsten Zeitpunkt.
Lektionen zur Leistung bei einem kollaborativen Editor
Wählen Sie spezifische Abschnitte statt den gesamten Speicher zu lesen
Die Aufrufung von useEditorChrome((s) => s.sidebarOpen) lädt ein Komponente nur dann erneut, wenn sich dieser Wert ändert. Der Aufruf des Hooks ohne Selektor überwacht alle Felder im Store, sodass das Eingeben in ein Modal, das ein beliebiges Feld aktualisiert, auch ein unverwandtes Outline-Panel neu laden kann. Der Rat scheint offensichtlich zu sein, doch überall im Store lauschende Funktionen werden leicht versehentlich implementiert.
Halten Sie häufige Mausbewegungen außerhalb von React
Live-Cursor sind eine klassische Falle in kollaborativen Editoren. Wenn jede Mausbewegung über Zustand an React weitergeleitet wird und anschließend eine Neulayoutierung auslöst, wirkt der Editor träge. Y.js ist für diese Art von schnellen, vorübergehenden Daten konzipiert – lassen Sie es also die Curer verwalten und den React-State für UI-Elemente mit langsameren Änderungen beibehalten.
Betrachten Sie staleTime als Produktentscheidung
staleTime sollte widerspiegeln, wie kostspielig veraltete Daten für einen bestimmten Bildschirm sind. Wenn überholte Berechtigungen gefährlich sein können, wie beispielsweise bei SaaS-Bildschirmen im Gesundheitswesen, sollte ein niedriger Wert verwendet werden und die Daten sollten aggressiv ungültig gemacht werden, sobald das Fenster wieder den Fokus erhält. Für eine Dokumentliste, die selten ändert, spart ein längerer Wert das Netzwerk. Ein einziger globaler Wert von staleTime: 0 führte bei einer Next.js-App zu erheblich mehr Datenverkehr ohne jeden Nutzen.
Erfrischen Sie den Serverzustand, nicht die UI-Elemente
Mit SSR oder RSC sollten Query-Daten auf dem Server entwässert werden, damit die erste Darstellung korrekt ist, und Zustand sollte strikt auf der Client-Seite gehalten werden, es sei denn, es gibt triftige Gründe dagegen. Die Darstellung von UI-Elementenzuständen auf dem Server lohnt die Komplexität in den meisten Fällen nicht.
Konventionen, die die Trennung aufrechterhalten
Eine Handvoll Teamkonventionen bewirken mehr als eine weitere Abstraktionsschicht:
- Bewahren Sie ein einziges
documentKeys-Modul auf und importieren Sie es sowohl aus Hooks, Socket-Handlern als auch aus Tests. - Holen Sie niemals Daten innerhalb einer Zustand-Aktion ab. Eine Aktion, die das Netzwerk benötigt, ist eigentlich ein Mutation-Hook und keine
set()-Aufruf. - Funktionsordner dürfen ihre eigenen UI-Stores beibehalten. Es ist in Ordnung,
useSlashCommandStoreneben dem Editor zu platzieren; Server-Entitäten, die über verschiedene Apps geteilt werden, hingegen nicht. - Importieren Sie gemeinsam genutzte Typen aus
packages/api. Immer wenn das Rename-DTO geändert wird, ändern sich gleichzeitig die Query-Hooks und Prisma-Payloads, wodurch das Caching nicht mehr auf schwach typisierte Zeichenketten angewiesen ist.
Ziel ist keine ausgefeilte State-Management-Strategie. Es handelt sich vielmehr um eine Regel, die ein müder Entwickler auch spät in der Nacht korrekt anwenden kann.
Authentifizierungs-Sessions und Feature-Flags sind ebenfalls Server-Zustand
Die Handhabung von Sessions in Next.js SaaS-Anwendungen ist eine häufige Quelle für doppelten Zustand. Die Session selbst – egal ob Cookie, JWT oder ein Objekt im NextAuth-Stil – ist Server-Zustand, genauso wie das Profil, das im Anwendungsrahmen angezeigt wird. Das Caching des Benutzers in Zustand, um die Darstellung der Navigationleiste zu beschleunigen, schafft eine zweite Quelle der Wahrheit, die sofort veraltet, sobald sich Berechtigungen ändern: ein Invite wird angenommen, eine Rolle wird aufgewertet oder der Benutzer wechselt zu einer anderen Organisation.
Eine robustere Lösung:
- Auf dem Server sollte die Session möglichst über den Auth-Hilfsfunktionen abgerufen werden.
- Auf der Client-Seite sollte der Benutzer über einen Query-Hook wie
useMemit einer stabilen Schlüsselstruktur wie['me']bereitgestellt werden.
activeWorkspaceId enthalten, da die Auswahl eines Arbeitsbereichs eine Benutzeroberflächen-Aktion ist. Er sollte jedoch nicht auch den vollständigen Memberships-Array enthalten, wenn dieses Array bereits in Query vorhanden ist.Wenn der Socket eine Änderung der Rolle ankündigt, müssen ['me'] sowie die entsprechenden Arbeitsbereichsschlüssel für ungültig erklärt werden. Durch manuelles Anpassen mehrerer Zustand-Felder in der Hoffnung, nichts übersehen zu haben, entstehen oft Fehler bei den Berechtigungen.
Feature-Flags folgen derselben Logik. Von der API bereitgestellte Flags gehören zu Query. Lokale Überwrites zum Debuggen können in Zustand gespeichert werden, sofern der Store eindeutig benannt ist – beispielsweise useDevFlagOverrides – damit niemand ihn mit den Produktionswerten verwechselt.
Kompromisse und wann man darauf verzichten sollte
Die gemeinsame Verwendung von Zustand und TanStack Query bedeutet, dass zwei Bibliotheken gelernt werden müssen, was für neue Entwickler, die zum Repository stoßen, eine echte Belastung darstellt. Ein kurzer STATE.md-Eintrag, der die Trennung erläutert und vom Pull-Request-Vorlage-Link aus zugänglich ist, lohnt sich schnell.
Einige Zustände erstrecken sich tatsächlich auf beide Bereiche, wie beispielsweise ein ausgewähltes Dokument. Die Lösung besteht darin, in Zustand nur die id zu speichern und die Entity über useQuery oder getQueryData abzurufen. Das Kopieren der Entity in den UI-Store führt erneut zum Problem der Doppelung.
Für eine kleine Marketing-Website mit einem einzigen Formular ist diese Struktur übertrieben. Für ein Produkt mit Echtzeit-Editoren, authentifizierten Arbeitsräumen und gemeinsamen Monorepo-Paketen wird sie schnell zu einer Notwendigkeit.
Checkliste für ein neues Projekt am ersten Tag
- Installieren Sie TanStack Query und definieren Sie von Anfang an eine klare Key-Factory.
- Erstellen Sie einen einzigen Zustand-Speicher für das Shell und den UI-Chrome.
- Vereinbaren Sie Socket- sowie Hocuspocus-Ereignisse mit
queryClient.setQueryDataundinvalidateQueries. - Ablehnen Sie Pull Requests, die Daten innerhalb des Zustands abrufen.
- Bevor Sie etwas anderes optimieren, analysieren Sie, wie oft die Editor-Route neu gerendert wird.
Haupterkenntnisse
- Bestimmen Sie das Eigentumsverhältnis, indem Sie prüfen, welcher Zustand beim Neuladen verschwinden kann und welcher bei einem Tab-Wechsel erhalten bleiben muss; der erste ist der UI-Zustand, der zweite der Server-Zustand.
- Echtzeit-Ereignisse sind Eingaben für den Query-Cache oder Y.Doc, niemals ein Grund für einen parallelen Speicher.
- Eine gemeinsame Key-Factory sorgt dafür, dass die Invalidation in Anwendungen, Paketen und Socket-Handlern zuverlässig funktioniert.
- Speichern Sie IDs statt Entities, wenn UI-Selektionen auf Serverdaten verweisen.