Estado vs límites de TanStack Query en un monorepo de Next.js en tiempo real
Aprenda una regla clara de propiedad para el estado de la interfaz de usuario, el estado del servidor y los eventos en tiempo real en un monorepo colaborativo de Next.js utilizando Zustand, TanStack Query y sockets.
Fuentes: Zustand for UI, TanStack Query for the Server: The Split That Finally Made Our App Sane
Muchos proyectos basados en Next.js comienzan con un pequeño almacén Zustand para el “estado global” y gradualmente lo convierten en un almacenamiento universal: primero se almacena la información del usuario actual, luego la lista de documentos, después un mapa de presencia proveniente del socket, y finalmente las ediciones realizadas por los usuarios. Unos meses después, el almacén entra en conflicto con la renderización del servidor, las invalidaciones afectan a los datos incorrectos, y cada solicitud de integración vuelve a plantear el debate sobre dónde debería almacenarse cada dato. Este artículo presenta una regla sencilla de responsabilidad para aplicaciones colaborativas, muestra el código del almacén y las consultas que la implementan en un monorepo, y explica cómo los eventos en tiempo real, las sesiones de autenticación y el rendimiento encajan dentro del mismo modelo.
El escenario corresponde a un producto basado en la colaboración: edición de texto enriquecido con Tiptap y Hocuspocus, Socket.io para una presencia ligera y notificaciones, además de un monorepo que alberga la aplicación web, los componentes de interfaz compartidos y los tipos de API. En esa configuración, mezclar la caché del servidor con Zustand no solo resulta desordenado, sino que también interfiere con el SSR, invalida elementos incorrectos y convierte el rastreo de actualizaciones inesperadas en una tarea recurrente.
Una regla de responsabilidad para tres tipos de estado
La regla está diseñada intencionadamente para ser sencilla, lo que facilita su aplicación bajo presión.
- Zustand se encarga del estado de la interfaz. Se trata de todo aquello que nunca llega en una respuesta HTTP: si la barra lateral está abierta, qué pestaña está seleccionada, indicadores para modales en borrador, el borde del editor, si se muestra el menú de comandos por barra de división y las preferencias de layout de corta duración.
Una prueba rápida resuelve la mayoría de las dudas: si el valor se perdería al actualizar la página sin que a nadie le importe, se trata de estado de interfaz. Si perderlo significaría tener que recuperarlo nuevamente, entonces es estado del servidor.
Dos errores que aparecen cuando se difumina la frontera
Pantallas intermitentes y en desacuerdo después de SSR
Un primer enfoque común consiste en hidratar una lista de documentos en Zustand en el cliente después de que los datos se hayan obtenido en el servidor, primero mediante getServerSideProps y posteriormente con React Server Components junto a una solicitud desde el cliente. Al realizar un refresco forzado, la primera visualización muestra los datos del servidor; luego Zustand se inicializa con valores vacíos y, finalmente, vuelve a llenarse. El usuario percibe un destello visual. Peor aún, dos pestañas abiertas pueden mostrar listas diferentes, ya que un almacén de Zustand no tiene concepto de identidad de caché; se trata simplemente de un estado en memoria.
TanStack Query fue diseñado para resolver este problema. Su opción initialData y su soporte para la deshidratación e hidratación permiten que el cliente reciba exactamente lo que renderizó el servidor. Zustand no ofrece nada equivalente, ya que nunca estuvo pensado para funcionar como caché.
Actualizaciones optimistas que nunca se deshacen
El segundo modo de fallo es el optimismo manual. Un componente llama a set() para mostrar un nuevo título de documento en Zustand, dispara la mutación y olvida restaurar el valor anterior si la solicitud falla. Las funciones de callback onMutate, onError y onSettled de TanStack Query ofrecen un camino predecible para revertir la situación. Un flujo optimista personalizado en Zustand tiende a ser inventivo e incorrecto.
Al trasladar las mutaciones a Query y mantener Zustand para cosas como “el diálogo de renombramiento está abierto”, se eliminan la mayoría de estos tipos de errores.
Estructuración del estado dentro del monorepo
En un espacio de trabajo al estilo Turborepo, la estructura se parece más o menos a esto:
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 decisión más importante es colocar tanto las claves de Query como los almacenes de Zustand en packages/state. Cuando cada carpeta de funcionalidad inventa su propio esquema de nomenclatura para las claves, la invalidación del caché se vuelve algo especulativo.
Un almacén de Zustand limitado al área del editor
El almacén que se muestra a continuación solo registra lo que necesita la interfaz circundante del editor: la visibilidad de la barra lateral, el menú de barra y qué panel lateral está activo. Cada campo cuenta con un sencillo método de configuración, y resetChrome cierra la interfaz temporal sin afectar la preferencia de la barra lateral. Tenga en cuenta que el fragmento publicado utiliza comillas tipográficas alrededor de los literales de cadena, lo cual no se compilará; cámbielas por comillas normales al adaptarlo.
// 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 }),
}))
Lo que omite el almacén es igualmente importante. No hay documentos, ni currentUser, ni estado de conexión copiado desde el socket. Un almacén diminuto para el estado de conexión es aceptable si se trata únicamente de una cuestión relacionada con la experiencia de usuario del lado del cliente, aunque a menudo puede obtenerse del proveedor. Sin embargo, la lista de documentos nunca debe almacenarse aquí.
Ganchos de consulta, una fábrica de claves y un cambio de nombre seguro
El módulo de documentos define una fábrica de claves, una consulta de lista y una mutación de renombramiento. El objeto documentKeys genera claves jerárquicas y tipadas: all es la raíz, mientras que list y detail la extienden. La consulta de lista utiliza un tiempo de vencimiento de staleTime de 30 segundos. La mutación de renombramiento sigue la secuencia óptima estándar: cancela las solicitudes en curso para el documento, toma una instantánea del valor anterior, escribe el nuevo título en la caché, restaura la instantánea en caso de error y invalida tanto el detalle como todo el árbol de documentos una vez que la solicitud se resuelve. Al igual que en el almacén, el fragmento contiene comillas tipográficas y caracteres de puntos suspensivos en lugar de operadores de dispersión, y la cadena de importación carece de su comilla final; por lo tanto, hay que corregirlos antes de usarlo.
// 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 fábrica de claves realiza más trabajo del que parece. Dado que las mismas claves se importan en todas partes del monorepo, los manejadores de sockets y los ganchos de la interfaz gráfica apuntan a las mismas entradas del caché. La situación en la que una aplicación actualiza su almacenamiento y un paquete compartido nunca se entera simplemente no puede ocurrir. Invalidar documentKeys.all también funciona porque Query compara las claves por prefijo, de modo que cada lista y entrada de detalles debajo de ella se marca como obsoleta con una sola llamada. Para un análisis más profundo sobre cómo organizar la capa Query de esta manera, consulte estructurando una capa de datos TanStack Query desde queryOptions hasta rollbacks.
Integrar actualizaciones en tiempo real en el modelo
En esta arquitectura, Hocuspocus se encarga del documento del editor colaborativo, mientras que Socket.io maneja tráfico menos intensivo como la información de presencia y las notificaciones. Un diseño inicial tentador es utilizar usePresenceStore para reflejar la lista completa de miembros desde el socket, junto con un caché de consultas de los mismos miembros cargados mediante REST. Mantener dos copias sincronizadas de una misma lista es precisamente el tipo de duplicación que causa errores problemáticos.
Una mejor distribución de responsabilidades:
- La lista inicial de miembros proviene de un gancho de consulta como
useWorkspaceMembers. - Los eventos de presencia actualizan únicamente campos temporales, como el estado de conexión o el color del cursor, los cuales se almacenan ya sea en una parte compacta de Zustand o en el estado por componente indexado por ID de usuario.
El contenido colaborativo permanece dentro de Y.Doc, gestionado por Y.js y Tiptap. Copiar todo el JSON de ProseMirror a Zustand por comodidad crea dos representaciones del documento que eventualmente entrarán en desacuerdo, generalmente en el momento menos oportuno.
Lecciones de rendimiento de un editor colaborativo
Elija secciones específicas en lugar de leer todo el almacén
Llamar a useEditorChrome((s) => s.sidebarOpen) vuelve a renderizar un componente solo cuando ese valor cambia. Llamar al hook sin un selector hace que se escuche cada campo del almacén, por lo que escribir en un modal que actualice cualquier campo puede volver a renderizar un panel de contorno no relacionado. El consejo parece obvio, pero es fácil implementar escuchas en todo el almacén por accidente.
Mantenga el tráfico de cursor de alta frecuencia fuera de React
Los cursores en tiempo real son una trampa clásica en los editores colaborativos. Si cada movimiento del cursor pasa por Zustand hacia React y luego desencadena el rediseño, el editor se sentirá lento. Y.js está diseñado para este tipo de datos rápidos y efímeros, así que déjelo manejar los cursores y conserve el estado de React para los elementos de la interfaz que cambian más lentamente.
Trate staleTime como una decisión de producto
staleTime debe reflejar cuán costoso es contar con datos obsoletos en una pantalla determinada. Cuando los permisos desactualizados pueden ser peligrosos, como en las pantallas de SaaS del sector sanitario, se debe utilizar un valor bajo e invalidarlos de inmediato cuando la ventana recupere el foco. En el caso de una lista de documentos que rara vez cambia, un valor más largo ayuda a ahorrar ancho de banda. Un único valor global staleTime: 0 hizo que una aplicación Next.js generara mucha actividad de red sin ningún beneficio aparente.
Hidrata el estado del servidor, no la decoración de la UI
Con SSR o RSC, se debe deshidratar los datos de Query en el servidor para que la primera renderización sea correcta, y mantener Zustand exclusivamente en el cliente a menos que haya una razón contundente para no hacerlo. Renderizar el estado de la decoración de la UI en el servidor rara vez vale la complejidad que implica.
Convenciones que mantienen la separación intacta
Unas pocas convenciones del equipo logran más que otra capa de abstracción:
- Mantenga un único módulo
documentKeyse impórtelo desde ganchos, controladores de socket y pruebas por igual. - Nunca realice solicitudes dentro de una acción de Zustand. Una acción que necesita conectarse a la red es en realidad un gancho de mutación, no una llamada a
set(). - Las carpetas de funcionalidades pueden conservar sus propios almacenes de interfaz de usuario. Es aceptable colocar un
useSlashCommandStorejunto al editor; en cambio, las entidades del servidor compartidas entre aplicaciones no lo son. - Importe los tipos compartidos desde
packages/api. Cada vez que se modifica el DTO de renombramiento, los ganchos Query y las cargas de Prisma cambian al mismo tiempo, y la actualización del caché deja de depender de cadenas con tipado laxo.
El objetivo no es una estrategia elaborada de gestión de estado. Se trata de una regla que un ingeniero cansado pueda aplicar correctamente tarde en la noche.
Las sesiones de autenticación y las banderas de funcionalidad también son estado del servidor
El manejo de sesiones en las aplicaciones SaaS de Next.js es una fuente frecuente de estado duplicado. La sesión en sí, ya sea una cookie, un JWT o un objeto al estilo NextAuth, es estado del servidor, al igual que el perfil que se muestra en la interfaz de la aplicación. Al cachear al usuario en Zustand para acelerar la renderización de la barra de navegación, se crea una segunda fuente de datos que se vuelve obsoleta en cuanto cambian los permisos: cuando se acepta una invitación, se actualiza un rol o el usuario cambia de organización.
Un arreglo más robusto:
- En el servidor, lea la sesión a través de su herramienta de autenticación siempre que sea posible.
- En el cliente, exponga al usuario mediante un hook de consulta como
useMe, con una clave estable como['me'].
activeWorkspaceId, ya que elegir un espacio de trabajo es una selección en la interfaz de usuario. No debería contener también el array completo de membresías cuando ese array ya se encuentra en Query.Cuando el socket anuncia un cambio de rol, invalidar ['me'] y las claves del espacio de trabajo correspondientes. Corregir manualmente varios campos de Zustand con la esperanza de no haberse pasado por alto alguno es cómo se introducen errores en los permisos.
Las banderas de funcionalidad siguen la misma lógica. Las banderas entregadas por la API pertenecen a Query. Las modificaciones locales para depuración pueden almacenarse en Zustand, siempre y cuando el almacén tenga un nombre claro, por ejemplo useDevFlagOverrides, para que nadie lo confunda con los valores de producción.
Compromisos y cuándo omitir esto
Usar Zustand y TanStack Query juntos implica tener que aprender dos bibliotecas, lo cual representa una carga adicional real para los ingenieros recién incorporados al repositorio. Un breve documento STATE.md que explica esta separación, al cual se puede acceder desde el template de pull request, compensa rápidamente ese esfuerzo.
Algunos estados realmente abarcan ambos enfoques, como el documento seleccionado. La solución es almacenar únicamente el id en Zustand y leer la entidad mediante useQuery o getQueryData. Copiar la entidad al almacenamiento de la interfaz genera nuevamente el problema de duplicación.
Para un pequeño sitio de marketing con un único formulario, esta estructura es excesiva. Pero para un producto que cuenta con editores en tiempo real, espacios de trabajo protegidos por autenticación y paquetes compartidos en un monorepo, rápidamente se vuelve indispensable.
Lista de verificación para una nueva aplicación desde el primer día
- Instale TanStack Query y defina desde el principio una fábrica de claves clara.
- Cree un almacén Zustand para la interfaz y el revestimiento de la UI.
- Conecte los eventos del socket y Hocuspocus a
queryClient.setQueryDatayinvalidateQueries. - Rechace las solicitudes de pull request que obtengan datos dentro de Zustand.
- Antes de optimizar cualquier otra cosa, analice con qué frecuencia se vuelve a renderizar la ruta del editor.
Puntos clave
- Decida quién es el responsable preguntándose qué estado puede desaparecer al actualizar la página y cuál debe mantenerse al cambiar de pestaña; el primero es el estado de la UI, el segundo es el estado del servidor.
- Los eventos en tiempo real son entradas para la caché de Query o Y.Doc, nunca una razón para tener un almacén paralelo.
- Una fábrica de claves compartida es lo que garantiza la validez en todas las aplicaciones, paquetes y controladores de socket.
- Almacene identificadores, no entidades, cuando las selecciones de la UI se refieran a datos del servidor.
Lecturas relacionadas
- Estructurando una capa de datos TanStack Query, desde queryOptions hasta Rollbacks — Construya una capa de datos TanStack Query paso a paso: queryOptions compartidos, generadores de claves, selectores, paginación, precarga, invalidación centralizada y actualizaciones óptimas seguras.
- Construyendo un módulo React basado en características: Posts CRUD con TanStack Query — Reestructure una aplicación React por características en lugar de por tipo de archivo, conecte la funcionalidad completa de Posts CRUD con TanStack Query y axios, y observe dónde deja de ser escalable este patrón.
- Qué le cuesta el uso de useEffect para obtener datos y qué cambios trae TanStack Query — Por qué la obtención de datos basada en effect genera solicitudes duplicadas, código genérico y cierres obsoletos en React, y cómo las funciones keys, staleTime e invalidation de TanStack Query solucionan estos problemas.