Стан проти меж TanStack Query у реальному часі в монорепозиторії Next.js
Дізнайтеся про чітке правило власності для стану інтерфейсу, серверного стану та подій у реальному часі в колаборативному monorepo 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 до rollbacks.
Інтеграція оновлень у реальному часі в модель
У цій архітектурі Hocuspocus керує документом спільного редагування, тоді як Socket.io обробляє менш важкий трафік, такий як інформація про присутність користувачів та сповіщення. Привабливим початковим рішенням є використання usePresenceStore, який відображає повний список учасників з сокету, разом із кешем запитів тих самих учасників, завантаженим через REST. Зберігання двох копій одного списку в синхронному стані — саме цей вид дублювання спричиняє серйозні проблеми.
Kращий розподіл обов’язків:
- Початковий список учасників отримується за допомогою хука запитів, наприклад
useWorkspaceMembers. - Події присутності оновлюють лише тимчасові поля, такі як статус онлайн чи колір курсора, які зберігаються або в компактному фрагменті Zustand, або у стані окремих компонентів з індексацією за ID користувача.
Контент, створений у співпраці, залишається всередині 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 до UI-сховища знову призводить до проблеми дублювання.
Для невеликого маркетингового сайту з однією формою така структура є надмірною. Для продукту з редакторами в реальному часі, просторами роботи з автентифікацією та спільними пакетами monorepo вона швидко стає необхідністю.
Чек-лист для нового додатку від першого дня
- Встановіть TanStack Query та з самого початку визначте чіткий механізм створення ключів.
- Створіть один сховище Zustand для шелу та елементів інтерфейсу.
- Під’єднайте події socket та Hocuspocus до методів
queryClient.setQueryDataтаinvalidateQueries. - Відхиляйте запити на зміни, які намагаються отримати дані всередині Zustand.
- Перш ніж оптимізувати щось інше, проаналізуйте, як часто маршрут редагування переробляє відображення.
Основні висновки
- Визначте власника даних, запитавши, який стан може зникнути після оновлення, а який має залишитися при зміні вкладки; перший — це стан інтерфейсу, другий — серверний стан.
- Події в реальному часі є вхідними даними для кешу Query або Y.Doc, але ніколи не є причиною для створення паралельного сховища.
- Спільний механізм створення ключів забезпечує надійну анулювання даних у різних додатках, пакетах та обробниках socket.
- Зберігайте ідентифікатори сховищ, а не самі об’єкти, коли вибірки інтерфейсу посилаються на дані сервера.