Home / Articles

Zustand vs TanStack Query Boundaries in a Realtime Next.js Monorepo

Learn a clear ownership rule for UI state, server state and realtime events in a collaborative Next.js monorepo using Zustand, TanStack Query and sockets.

Publisher: REACTAPP.TOP10 min read2359 words

This article is published in English.

Sources: Zustand for UI, TanStack Query for the Server: The Split That Finally Made Our App Sane

Many Next.js codebases start with a small Zustand store for "global state" and slowly turn it into a catch-all: the current user goes in, then the document list, then a presence map from the socket, then somebody's optimistic edit. A few months later the store fights server rendering, invalidation hits the wrong data, and every pull request reopens the argument about where a piece of data should live. This article lays out a simple ownership rule for a collaborative app, shows the store and query code that implements it in a monorepo, and covers how realtime events, auth sessions and performance fit into the same model.

The scenario is a collaboration-heavy product: rich-text editing with Tiptap and Hocuspocus, Socket.io for lightweight presence and notifications, plus a monorepo holding the web app, shared UI components and API types together. In that setup, mixing server cache into Zustand is not just untidy. It interferes with SSR, invalidates the wrong things, and makes tracing unexpected re-renders a recurring chore.

One ownership rule for three kinds of state

The rule is intentionally unexciting, which is what makes it easy to apply under pressure.

  • Zustand owns UI state. This is anything that never arrived in an HTTP response: whether the sidebar is open, which tab is selected, flags for draft modals, the editor's surrounding chrome, whether the slash-command menu is showing, and short-lived layout preferences.
  • TanStack Query owns server state. Anything whose source of truth is the backend belongs here: documents, members, permissions, billing flags, and in general anything you would refetch after a mutation.
  • Realtime channels update the cache instead of forming a third store. Socket.io and Hocuspocus events patch Query caches or Y.js documents. They do not justify a duplicate copy of the same list in Zustand just because it changes live.

A quick test settles most disputes: if the value would be lost on a page refresh without anyone caring, it is UI state. If losing it would mean fetching it again, it is server state.

Two bugs that appear when the boundary blurs

Flashing and disagreeing tabs after SSR

A common first attempt hydrates a document list into Zustand on the client after data was fetched on the server, first via getServerSideProps and later via React Server Components plus a client fetch. On a hard refresh the initial paint shows server data, then Zustand initializes empty, then it fills up again. The user sees a flash. Worse, two open tabs can show different lists, because a Zustand store has no notion of cache identity; it is plain in-memory state.

TanStack Query was designed for this problem. Its initialData option and dehydration/hydration support let the client pick up exactly what the server rendered. Zustand offers nothing equivalent, because it was never meant to be a cache.

Optimistic updates that never roll back

The second failure mode is hand-rolled optimism. A component calls set() to show a new document title in Zustand, fires the mutation, and forgets to restore the old value if the request fails. TanStack Query's onMutate, onError and onSettled callbacks give you a predictable rollback path. A custom optimistic flow in Zustand tends to be inventive and incorrect.

Moving mutations into Query and keeping Zustand for things like "the rename dialog is open" removes most of this class of bugs.

Structuring state inside the monorepo

In a Turborepo-style workspace, the layout looks roughly like this:

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

The decision that matters most is putting both Query keys and Zustand stores in packages/state. When each feature folder invents its own naming scheme for keys, cache invalidation becomes guesswork.

A Zustand store limited to editor chrome

The store below tracks only what the editor's surrounding interface needs: sidebar visibility, the slash menu, and which side panel is active. Each field has a simple setter, and resetChrome closes transient UI without touching the sidebar preference. Note that the published snippet uses typographic quotes around string literals, which will not compile; replace them with straight quotes when you adapt it.

// 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 }),
}))

What the store leaves out is just as important. There are no documents, no currentUser, and no connection status copied from the socket. A tiny store for connection status is acceptable if it is purely a client-side UX concern, though often it can be derived from the provider instead. The document list, however, must never live here.

Query hooks, a key factory and a safe rename

The documents module defines a key factory, a list query and a rename mutation. The documentKeys object produces hierarchical, typed keys: all is the root, and list and detail extend it. The list query uses a 30-second staleTime. The rename mutation follows the standard optimistic sequence: cancel in-flight fetches for the document, snapshot the previous value, write the new title into the cache, restore the snapshot on error, and invalidate both the detail and the whole documents tree once the request settles. As with the store, the snippet contains typographic quotes and ellipsis characters in place of spread operators, and the import string is missing its closing quote, so clean those up before use.

// 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 })
 },
 })
}

The key factory does more work than it appears to. Because the same keys are imported everywhere in the monorepo, socket handlers and UI hooks target the identical cache entries. The situation where one app updates its store and a shared package never finds out simply cannot happen. Invalidating documentKeys.all also works because Query matches keys by prefix, so every list and detail entry below it is marked stale in one call. For a deeper treatment of organizing a Query layer this way, see structuring a TanStack Query data layer from queryOptions to rollbacks.

Fitting realtime updates into the model

In this stack Hocuspocus handles the collaborative editor document, while Socket.io carries lighter traffic such as presence and notifications. A tempting early design is a usePresenceStore that mirrors the full member list from the socket, alongside a Query cache of the same members loaded over REST. Keeping two copies of one list in sync is exactly the kind of duplication that causes painful bugs.

A better division of responsibility:

  1. The initial member list comes from a Query hook such as useWorkspaceMembers.
  2. Presence events update only ephemeral fields, like online status or cursor color, kept either in a compact Zustand slice or in per-component state indexed by user id.
  3. Structural changes, such as a new member joining the workspace, update or invalidate the Query cache, exactly as a REST mutation would.

Collaborative content stays inside the Y.Doc managed by Y.js and Tiptap. Copying the full ProseMirror JSON into Zustand for convenience creates two representations of the document that will eventually disagree, usually at the least convenient moment.

Performance lessons from a collaborative editor

Select slices instead of reading the whole store

Calling useEditorChrome((s) => s.sidebarOpen) re-renders a component only when that one value changes. Calling the hook without a selector listens to every field in the store, so typing in a modal that updates any field can re-render an unrelated outline panel. The advice sounds obvious, yet whole-store listeners are easy to ship by accident.

Keep high-frequency cursor traffic out of React

Live cursors are a classic trap in collaborative editors. If every pointer movement flows through Zustand into React and then triggers layout, the editor will feel sluggish. Y.js awareness is built for this kind of fast, ephemeral data, so let it handle cursors and keep React state for slower-changing UI chrome.

Treat staleTime as a product decision

staleTime should reflect how costly stale data is for a given screen. Where outdated permissions could be dangerous, as in healthcare-style SaaS screens, use a low value and invalidate aggressively when the window regains focus. For a document list that rarely changes, a longer value spares the network. A single global staleTime: 0 made one Next.js app noticeably chatty without any benefit.

Hydrate server state, not UI chrome

With SSR or RSC, dehydrate Query data on the server so the first paint is correct, and keep Zustand strictly on the client unless there is a compelling reason not to. Rendering UI chrome state on the server is rarely worth the complexity.

Conventions that keep the split intact

A handful of team conventions do more than another abstraction layer would:

  • Maintain a single documentKeys module and import it from hooks, socket handlers and tests alike.
  • Never fetch inside a Zustand action. An action that needs the network is really a mutation hook, not a set() call.
  • Feature folders may keep their own UI stores. Placing a useSlashCommandStore beside the editor is fine; server entities shared across apps are not.
  • Import shared types from packages/api. Whenever the rename DTO is modified, Query hooks and Prisma payloads change together, and cache patching stops relying on loosely typed strings.

The goal is not an elaborate state management strategy. It is a rule that a tired engineer can apply correctly late at night.

Auth sessions and feature flags are server state too

Session handling in Next.js SaaS apps is a frequent source of duplicate state. The session itself, whether a cookie, a JWT or a NextAuth-style object, is server state, and so is the profile you render in the app shell. Caching the user in Zustand to make the navbar render faster creates a second source of truth that goes stale the moment permissions change: an invite is accepted, a role is upgraded, or the user switches organizations.

A more robust arrangement:

  • On the server, read the session through your auth helper wherever possible.
  • On the client, expose the user through a Query hook such as useMe, with a stable key like ['me'].
  • Zustand may hold activeWorkspaceId, because choosing a workspace is a UI selection. It should not also hold the full memberships array when that array already lives in Query.

When the socket announces a role change, invalidate ['me'] and the relevant workspace keys. Hand-patching several Zustand fields and hoping none were missed is how permission bugs slip through.

Feature flags follow the same logic. Flags delivered by the API belong to Query. Local overrides for debugging can live in Zustand, provided the store is named unambiguously, for example useDevFlagOverrides, so nobody confuses it with production values.

Trade-offs and when to skip this

Running Zustand and TanStack Query together means two libraries to learn, which is real overhead for newer engineers joining the repository. A short STATE.md that explains the split, linked from the pull request template, pays for itself quickly.

Some state genuinely spans both worlds, such as a selected document. The fix is to store only the id in Zustand and read the entity through useQuery or getQueryData. Copying the entity into the UI store brings back the duplication problem.

For a small marketing site with a single form, this structure is overkill. For a product with realtime editors, auth-gated workspaces and shared monorepo packages, it quickly becomes a necessity.

A day-one checklist for a new app

  1. Install TanStack Query and define a clear key factory from the start.
  2. Create one Zustand store for shell and UI chrome.
  3. Connect socket and Hocuspocus events to queryClient.setQueryData and invalidateQueries.
  4. Reject pull requests that fetch data inside Zustand.
  5. Before optimizing anything else, profile how often the editor route re-renders.

Key takeaways

  • Decide ownership by asking which state can vanish on refresh and which must survive a tab switch; the first is UI state, the second is server state.
  • Realtime events are inputs to the Query cache or the Y.Doc, never a reason for a parallel store.
  • A shared key factory is what makes invalidation reliable across apps, packages and socket handlers.
  • Store ids, not entities, when UI selections point at server data.
  • Keep fast-moving presence data such as cursors outside React entirely.