Состояние против границ TanStack Query в реальном времени в монорепозитории Next.js
Изучите четкое правило принадлежности для состояния интерфейса, серверного состояния и событий в реальном времени в коллаборативном монорепозитории Next.js с использованием Zustand, TanStack Query и сокетов.
Источники: Zustand for UI, TanStack Query for the Server: The Split That Finally Made Our App Sane
Многие проекты на Next.js начинаются с небольшого хранилища Zustand для «глобального состояния», которое постепенно превращается в универсальный инструмент: сначала хранится информация о текущем пользователе, затем список документов, потом карта присутствия из сокета, а ещё позже — данные от оптимистичных изменений. Через несколько месяцев такое хранилище начинает конфликтовать с серверной отрисовкой, процедуры аннулирования влияют на неверные данные, и каждый pull request снова вызывает споры о том, где должны храниться те или иные данные. В этой статье изложено простое правило распределения ответственности для совместных приложений, показан код хранилища и запросов, реализующий его в монорепозитории, а также объясняется, как реальные времени события, сессии аутентификации и производительность вписываются в эту же модель.
Речь идет о продукте, требующем активного сотрудничества разработчиков: редактирование текста с использованием Tiptap и Hocuspocus, Socket.io для легкого отслеживания активности пользователей и отправки уведомлений, а также монорепозиторий, объединяющий веб-приложение, общие компоненты интерфейса и типы API. В такой конфигурации использование серверной кэш-системы вместе с Zustand не только создает беспорядок, но и мешает работе технологии SSR, приводит к некорректной обработке данных и делает отслеживание неожиданных перерисовок постоянной задачей.
Одно правило управления для трех типов состояния
Это правило специально сформулировано просто, что облегчает его применение в условиях давления.
- Zustand отвечает за состояние интерфейса. Это все то, что никогда не поступает в ответе HTTP: открыта ли боковая панель, какой вкладка выбрана, флаги для модалок с черновиками, элементы оформления редактора, отображается ли меню команд, а также временные настройки макета.
Быстрый тест помогает разрешить большинство споров: если значение будет потеряно при обновлении страницы и никто этого не заметит, это состояние интерфейса. Если его потеря приведёт к необходимости снова загрузить данные, это состояние сервера.
Два сбоя, возникающих при размытии границ
Мерцание и несоответствие вкладок после SSR
Обычно в первой попытке список документов гидратируется в Zustand на клиенте после получения данных с сервера: сначала с помощью getServerSideProps, а затем с использованием React Server Components в сочетании с запросом на клиенте. При полном обновлении страницы сначала отображаются данные с сервера, затем Zustand инициализируется с пустыми данными, после чего они снова заполняются. Пользователь видит мимолетное отображение старых данных. Что ещё хуже, два открытых таба могут показывать разные списки, поскольку хранилище Zustand не имеет информации об идентичности кэша — это простое состояние в памяти.
TanStack Query был создан для решения этой проблемы. Его опция initialData и поддержка процессов дехидратации/гидратации позволяют клиенту получать именно те данные, которые отрендерил сервер. Zustand не предлагает ничего подобного, поскольку изначально он не предназначался для использования в качестве кэша.
Оптимистичные обновления, которые никогда не откатываются
Второй способ возникновения ошибок — это оптимизм, реализованный вручную. Компонент вызывает метод set(), чтобы отобразить новое название документа в Zustand, инициирует изменение данных, но забывает восстановить старое значение при сбое запроса. Функции-обратные вызовы onMutate, onError и onSettled от TanStack Query обеспечивают предсказуемый способ отката. Самостоятельная реализация оптимистичного подхода в Zustand часто бывает неэффективной и содержит ошибки.
Перенос операций изменения данных в Query и использование Zustand только для таких состояний, как «диалог переименования открыт», позволяет устранить большинство подобных ошибок.
Структурирование состояния внутри монорепозитория
В рабочем пространстве в стиле Turborepo структура обычно выглядит примерно так:
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
Самое важное решение — размещать как ключи запросов, так и хранилища Zustand в папке packages/state. Когда каждая папка с функциями создаёт свою собственную систему именования ключей, аннулирование кэша превращается в угадывание.
Хранилище Zustand, ограниченное интерфейсом редактора
Приведённое ниже хранилище отслеживает только то, что необходимо окружающему интерфейсу редактора: видимость боковой панели, меню с штрихом и активную боковую панель. У каждого поля есть простой метод для установки значений, а функция resetChrome закрывает временный интерфейс, не влияя на настройки боковой панели. Обратите внимание, что в приведённом фрагменте для строковых литералов используются типографские кавычки, что приведёт к ошибке компиляции; при адаптации замените их на обычные кавычки.
// 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 }),
}))
То, что игнорируется хранилищем, имеет не меньшее значение. Здесь нет документов, нет переменной currentUser, а также не копируется статус соединения из сокета. Небольшое хранилище для статуса соединения допустимо, если речь идет исключительно о вопросах пользовательского интерфейса с клиентской стороны, хотя часто его можно получить непосредственно от поставщика услуг. Однако список документов ни в коем случае не должен храниться здесь.
Хуки запросов, фабрика ключей и безопасное переименование
Модуль документов определяет фабрику ключей, запрос к списку и мутацию переименования. Объект documentKeys генерирует иерархические, типизированные ключи: all является корнем, а list и detail расширяют его. Запрос к списку использует параметр staleTime в размере 30 секунд. Мутация переименования следует стандартной оптимистичной схеме: отменяются в процессе выполнения запросы к документу, делается снимок предыдущего значения, новое название записывается в кэш, при ошибке восстанавливается снимок, а после завершения запроса аннулируются данные как для деталей, так и для всего дерева документов. Как и в случае с хранилищем, в этом фрагменте вместо операторов распространения используются типографские кавычки и символы тройных точек, а в строке импорта отсутствует закрывающая кавычка; их необходимо устранить перед использованием.
// 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 })
},
})
}
Фабрика ключей выполняет больше функций, чем кажется на первый взгляд. Поскольку одинаковые ключи импортируются во всех частях монорепозитория, обработчики сокетов и элементы интерфейса обращаются к одинаковым записям кэша. Ситуация, при которой одно приложение обновляет свой кэш, а совместный пакет об этом ничего не узнает, просто не может возникнуть. Для аннулирования действия documentKeys.all также подходит аналогичный метод: Query сопоставляет ключи по префиксу, поэтому все элементы списка и детали ниже него маркируются как устаревшие за один вызов. Для более подробного рассмотрения способа организации слоя Query в таком формате см. структурирование слоя данных TanStack Query от queryOptions до механизмов возврата к предыдущему состоянию.
Интеграция реального времени в модель
В этой архитектуре Hocuspocus отвечает за документ совместного редактирования, в то время как Socket.io обрабатывает менее объемные данные, такие как информация о присутствии пользователей и уведомления. Привлекательным первоначальным решением было использование функции usePresenceStore, которая отражала полный список участников из сокета, наряду с кэшем этих же участников, загруженным через REST. Однако сохранение двух копий одного и того же списка в синхронизированном состоянии приводит именно к тем ошибкам, которые затрудняют работу.
Лучшее разделение обязанностей:
- Исходный список участников получается с помощью специального механизма, такого как
useWorkspaceMembers. - События присутствия обновляют только временные поля, такие как статус онлайн или цвет курсора; эти данные хранятся либо в компактном фрагменте состояния Zustand, либо в состоянии отдельных компонентов с индексацией по идентификатору пользователя.
Коллаборативный контент остается внутри Y.Doc, управляемого Y.js и Tiptap. Для удобства копирование полного JSON-файла ProseMirror в Zustand приводит к созданию двух версий документа, которые в конечном итоге будут не совпадать, обычно в наименее удобный момент.
Выбирайте отдельные фрагменты вместо чтения всего хранилища
Вызов useEditorChrome((s) => s.sidebarOpen) перерисовывает компонент только тогда, когда меняется соответствующее значение. Если вызывать этот хук без селектора, он будет отслеживать все поля в хранилище данных, поэтому ввод информации в модальное окно, обновляющее любое поле, может привести к перерисовке не связанной с ним панели. Этот совет кажется очевидным, но использование слушателей для всего хранилища данных легко происходит случайно.
Изолируйте частые операции с курсором от React
Динамические курсоры представляют собой классическую проблему в коллаборативных редакторах. Если каждое движение курсора проходит через Zustand в React, что запускает обновление интерфейса, редактор будет работать медленно. Библиотека Y.js предназначена для обработки таких быстрых и временных данных, поэтому позвольте ей заниматься курсорами, а состояние React оставьте для элементов интерфейса, которые меняются медленнее.
Рассматривайте параметр staleTime как решение, связанное с архитектурой продукта
staleTime должен отражать степень вреда устаревших данных для конкретного экрана. Если устаревшие разрешения могут представлять опасность, как на экранах SaaS в сфере здравоохранения, следует использовать низкое значение и активно аннулировать его, когда окно снова получает фокус. Для списка документов, который редко меняется, более высокое значение позволяет сэкономить трафик. Одно глобальное значение staleTime: 0 значительно увеличивало нагрузку на сеть в одном приложении Next.js без какой-либо пользы.
Обновляйте состояние сервера, а не внешний вид интерфейса
При использовании SSR или RSC необходимо обрабатывать данные Query на сервере, чтобы первое отрисование было корректным, и оставлять Zustand исключительно на стороне клиента, если нет веских причин этого не делать. Отрисовка состояния внешнего вида интерфейса на сервере редко оправдывает добавляемую сложность.
Конвенции, сохраняющие разделение ответственностей
Несколько командных конвенций действуют эффективнее, чем любой дополнительный слой абстракции:
- Сохраняйте единственный модуль
documentKeysи импортируйте его из хуков, обработчиков сокетов и тестов. - Никогда не выполняйте запросы внутри действия Zustand. Действие, требующее сети, на самом деле является хуком для изменения состояния, а не вызовом
set(). - Папки с функциями могут содержать собственные хранилища UI. Размещение
useSlashCommandStoreрядом с редактором допустимо; серверные объекты, используемые в нескольких приложениях, — нет. - Импортируйте общие типы из
packages/api. Каждый раз, когда изменяется DTO с новым именем, хуки Query и загрузочные данные Prisma меняются одновременно, а обновление кэша больше не зависит от строк с размытыми типами.
Цель не в сложной стратегии управления состоянием. Это правило, которое уставший инженер может правильно применить поздно ночью.
Сессии аутентификации и флаги функций также являются состоянием сервера
Обработка сессий в приложениях Next.js SaaS часто приводит к появлению дублирующегося состояния. Сама сессия — будь то куки, JWT или объект в стиле NextAuth — является состоянием сервера, как и профиль, отображаемый в интерфейсе приложения. Хранение информации о пользователе в Zustand для ускорения отображения навигационной панели создает второй источник данных, который устаревает сразу при изменении прав доступа: после принятия приглашения, повышения роли или смены организации пользователя.
Более надежное решение:
- На сервере, по возможности, читайте информацию о сессии через помощник аутентификации.
- На клиенте предоставляйте информацию о пользователе с помощью хука Query, такого как
useMe, с стабильным ключом вроде['me'].
activeWorkspaceId, поскольку выбор рабочей среды осуществляется через интерфейс. Он не должен также содержать полный массив членств, если этот массив уже находится в Query.Когда сокет сообщает о смене роли, необходимо аннулировать действие ['me'] и соответствующих ключей рабочей среды. Именно путем ручного изменения нескольких полей Zustand в надежде, что ничего не упущено, происходит появление ошибок с правами доступа.
Флаги функций работают по той же логике. Флаги, передаваемые API, принадлежат Query. Локальные изменения для отладки могут храниться в Zustand, при условии, что имя хранилища указано однозначно, например useDevFlagOverrides, чтобы никто не перепутал его с значениями для производственной среды.
Компромиссы и когда можно этого не делать
Использование Zustand и TanStack Query одновременно означает необходимость изучения двух библиотек, что представляет дополнительную нагрузку для новых разработчиков, присоединяющихся к проекту. Краткий файл STATE.md, объясняющий принцип разделения, к которому есть ссылка в шаблоне pull request, быстро окупает себя.
Некоторые состояния действительно связаны с обеими системами, например выбранный документ. Решение заключается в хранении только id в Zustand и получении entity с помощью useQuery или getQueryData. Копирование entity в хранилище интерфейса снова приводит к проблеме дублирования.
Для небольшого маркетингового сайта с одной формой такая структура является чрезмерной. Однако для продукта с реальным временем редактирования, рабочими пространствами с аутентификацией и общими пакетами в monorepo она быстро становится необходимостью.
Чек-лист для нового приложения с первого дня
- Установите TanStack Query и с самого начала определите четкий механизм генерации ключей.
- Создайте один хранилище Zustand для шелла и UI-компонентов.
- Подключите события соединения и Hocuspocus к методам
queryClient.setQueryDataиinvalidateQueries. - Отклоняйте пул-запросы, в которых данные загружаются внутри Zustand.
- Прежде чем оптимизировать что-либо еще, проанализируйте, как часто маршрут редактора перерисовывается.
Основные выводы
- Чтобы определить ответственные стороны, спросите себя, какой состояние может исчезнуть при обновлении страницы, а какое должно сохраняться при смене вкладки: первое — это состояние UI, второе — серверное состояние.
- События в реальном времени являются входными данными для кэша Query или Y.Doc, но никогда не служат основанием для создания параллельного хранилища.
- Общий механизм генерации ключей обеспечивает надежную аннулирование данных в различных приложениях, пакетах и обработчиках соединений.
- Храните идентификаторы хранилищ, а не сами объекты данных, когда выборы в интерфейсе относятся к серверным данным.