Галоўная / Артыкулы

Стан пры TanStack Query Boundaries у рэальнайчасовым Next.js Monorepo

Выучыце чыстае правіло аднаковасці стану інтэрфейса, сервера і рэальна-часовых запускаў у сааверкатываным монорепазе Next.js, які выкарыстоўвае Zustand, TanStack Query і сокеты.

Выдавец: REACTAPP.TOP10 хв чытання2359 слоў

Крыніцы: Zustand for UI, TanStack Query for the Server: The Split That Finally Made Our App Sane

Большынства проектаў на Next.js пачынаюцца з маленькага сховішча Zustand для „глобальнага стану“, якое паступова ператвараецца на ўсепрацоўваючы элемент: спачатку тут зберагаецца інфармацыя пра текущаг адвітара, потым — спіс дакументаў, пасля чаго — карта прысутнасці з сокета, а ўжо пазней — даныя від оптымістичных змян. Чэрез калькі месцаў сховішча пачынае ствараць проблемы з серверным атрыбутаванням дадзеных, некоректна выконваюцца процесы анулювання, і кожны запрос да змян знова пачынае дыскусію пра тое, дзе самэўцем маюць знаходзіцца даны. У этай статыце прастая правіла апрантавання для кольектываў, якія працуют над аплікацыяй, паказаны код сховішча і запитоў, якія ёе рэалізуюць у монорепо, а таксама раз’яснены, як рэальнычасовыя запускі, сесіі аутанацыі і практычнае выконання аплікацыі впіваюцца ў аднойчасны модэль.

Этот прынцып стосуецца продакту, які актыўна выкарыстоўвае саавершанне: рэдагаванне тэксту з выкарыстаннем Tiptap і Hocuspocus, Socket.io для лёгкага выявлення прысутнасці і паведамленняў, а таксама монорепаз, які зберагае веб-дзялоўку, спакушаны компоненты UI і типы API разам. У такой схеме включэнне кешу сервера ў Zustand — не толькі некалячнае рашэнне. Яно паўтарае пры процесе SSR, узневажальвае некаторыя данні і прыводзіць да таго, што адстежэнне нечаканых перераскладоў стае постаяльым завадам.

Адна правіла адпаведальнасці для трох відаў стану

Гэтае правіла ўмышлена простае, і самэ гэта дапамагае лёгка яго застосаваць пад тым пясцем.

  • Zustand адпаведальны за стан UI. Це ўсё, што ніколі не прыходзіць у адпаведным HTTP-адказе: чы розкрываецца бокавая панель, якая вкладка выбрана, флагі для модалных вікнаў у формате чернавіка, абрамленне рэдагувальніка, чы показваецца меню з командамі, і трымканыя настаўкі лейауту.
  • TanStack Query контролюе стан сервера. Усё, чыя адказнае джерела знаходзіцца ў бэкендзе, належыць сюды: дакументы, члены, правыя, пазнакі білінгу, а таксама ўсё, што трэба зноў запрашваць пасля змены.
  • Каналы рэальнага часу апдэйтуюць кэш у замен на стварэнне трэціх баз дадзенаў. Адбыванні Socket.io і Hocuspocus падправляюць кэш Query або дакументы Y.js. Яны не ўсунулі патрэбу ў дублікате той самай спіса ў Zustand толькі таму, што ён зміняецца у рэальны час.
  • Швыракі тэст раз’ясняе большасць суперчынстваў: якщо значэнне будзе загублена пасля апдэйту сторанкі і ніхто не будзе гледаць на гэта, то гэта стан UI. Якщо ж яго загуба значыць неабходнасць зноў запрашваць яго, то гэта стан сервера.

    Два багі, якія з’яўляюцца, калі грані стану размываюцца

    Мерканняе і несувязаныя вікні пасля 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 })
     },
     })
    }
    

    Фабрыка ключоў выканае больш работы, чым можа здавацца. Паколькі тыя ж ключы імпортуюцца ў всіх частках монарепа, працоўнікі сокетаў і элементы UI адбіваюцца на тых сабе ж кешовых ентратах. Ситуацыя, калі адна прыкладка апдэйтуе свой сховішча, а спяльны пакет проста не даведаецца пра гэта, проста не можа выйсці. Анулюванне documentKeys.all таксама дапамагае, таму што Query падбірае ключы за прыметнікам, так што кожны элемент супаковкі і деталей пад яму атрымле статус застарэлага ў аднам вызыве. Чытайце пра болей дакладным апрантаванні шару Query такім спосабам у структуруванні шара дадзейнасці TanStack Query ад queryOptions да rollbacks.

    Інтеграцыя рэальна-часовых апдэйтаў у модель

    У гэтым стаке Hocuspocus адрабатвае з документамі для саўместнай рэдагавання, тады як Socket.io перавозіць легшы трафік, такі як інформацыя пра наявнасць корыстнікаў і паведамлення. Апэлюючы дизайн – это usePresenceStore, які відбівае цэлы список членаў з сокета, а таксама кэш запытанняў з тымі ж членамі, завантажаныя через REST. Зберагчы два копіі однаго списку ў синхроназе, мы ствараем самэ тую дуплікацыю, якая спрычыняе проблематычныя багі.

    Кращы распад адпаведальнасцяў:

    1. Пачатковы список членаў прадаёцца з хука запытанняў, такога як useWorkspaceMembers.
    2. Падзеі пра наявнасць апдэйтуе толькі тымчасовыя поля, такія як статус у сеті чы выканек курсора, якія зберагваюцца або ў компактным фрагменте Zustand, або ў стане кожнага компонента, індексаванамым па ID корыстніка.
  • Структурныя змены, такія як прыўлеканне новага члена да рабочай зоны, апдэюцуюць або унічыляюць кеш запита, так сама, як і мутацыя у формате REST.
  • Контэнт, створаны са співпрацайю, застаецца ўнутрь Y.Doc, які керуецца бібліятэкамі Y.js і Tiptap. Для зручнасці копіюванне всіх дадзенняў у формате ProseMirror JSON у Zustand стварае два варыянты докумэнта, якія зрэштай будуць несусільныя, зазвычай у найменш падходягучы момент.

    Урокі па кантролі выконвальных спроможнасцей ад редактара для співпрацы

    Выбірайце часткі дакумента заместо чытання всіх дадзеных

    Выклік useEditorChrome((s) => s.sidebarOpen) перзапускае адраджэнне компаненту толькі тады, калі змянюецца гэтыя значэнне. Выклік хука без селектара слухае кожны поле ў складзі, таму введэнне даных у модальныя вікна, якіе зменяюць будзь-яке поле, можа перзапускае адраджэнне неканэксуальных панелей. Адказ здаецца бяспадзерожным, пры тым чым викорыстоўванне слухачаў для всього складзу лёгка адбываецца прыпадкова.

    Утримайце частыя дзіянні курсора за межамі React

    Жывыя курсоры ўтвараюць класычную пастку ў саўместных редакторах. Якщо кожны рух курсора праходзіць через Zustand у React і пасля таго спрычыняе зміны лейауту, редактор будзе працаваць медленна. Y.js створаны для такіх шырокіх, трымкнутых даных, таму нехай ён керуе курсорамі, а стан React будзе выкарыстоўваны для элементаў інтерфейсу, якія зменяюцца медленнее.

    Расследжвайце параметр staleTime як рашэнне па прыняццю продукту

    staleTime павінен адражацьваць, насколькі дорагі є застарэлыя данні для паведамленага экрана. Калі застарэлыя правы прызначэння можу быць небяпечнымі, як у экранах SaaS у сфере здравоцохранэння, трэба выкарыстоўваць низкія значэння і агрэсыўна ануляваць их, калі вокно знова стане актыўным. Для спісу дакументаў, які рэдка зміняецца, большае значэння дапамагае захаваць ресурсы сеті. Адна глобальная настройка staleTime: 0 змусіла аплікацыю Next.js значна больш кантроляваць сетевыя запыты, не даўшы жадных выгод.

    Заправляйце стан сервера, а не дэталі UI

    За дапамогою SSR або RSC неабходна падчысціць данні Query на сервере, каб першая візуалізацыя была правильной, і трэба строго выкарыстоўваць Zustand толькі на кліянтэ, якщо толькі немае важлівых прычын для іншага. Візуалізацыя стану дэталей UI на сервере рэдка ўзрабатваецца зарады такой складнасі.

    Стандарты, якія падтрымваюць раздзеленне

    Некалькі стандартаў команды даюць большых рэзультатаў, чым які-небудзь іншы шар абстракцыі:

    • Зберагаюце адзін модуль documentKeys і імпортуеце яго з хуків, працоўнікаў сокетаў і тэстаў.
    • Ніколы не выкарыстоўвайце запыты ўнутар адзіны з дзейнасцяў Zustand. Дзеянне, якое патрэбуе сеті, на самай працоўцы є хукам з мутацыямі, а не вызывам set().
    • Папкі з функцыямі могу маты свои скарбніцы UI. Размешчэнне useSlashCommandStore падле редактара ўсё гаразд; аднак сэрверныя ентытаты, якія дзелюцца между аплікацыямі, — ні.
    • Імпортуеце спакушаныя типы з packages/api. Калі меняецца DTO для перайменавання, хукі Query і пэйлоады Prisma зменяюцца разам, а патчаванне кэша больш не выкарыстоўвае строкі з слабым типам.

    Мэтай ня є складная стратэгія калектывавання стану. Це правіла, якія вядомы вымоцанамі інжынерам і якія ўсё можна правільна застосавіць пазней у ночы.

    Сесіі аутантыкацыі і фіч-флагі таксама ўважаюцца станам сервера

    Адрабатка сесій у дапытках Next.js SaaS ўсё часта стварае прычыны дублікацыі стану. Сама сесія, будзь то кукі, JWT чы об’ект у стыле NextAuth, ўважаецца станам сервера, так само як і прафіль, які адображаецца ў корпусе дапытка. Зберагчэнне інфармацыі пра корыстніка ў Zustand, каб спрыяць шырэйшам адображэнню навігацыйнага бара, створыла другы источнік правды, який стае застарэлым як толькі зменяюцца правы прыступу: калі приймаецца запрошэнне, падымаецца роль чы корыстнік пераходзіць у іншую організацыю.

    Болей надзеяны спосаб:

    • На сервере, калі толькі можна, чытайце сесію через ваш адпамогчык аутантыкацыі.
    • На кліенте, адкрывайце інфармацыю пра корыстніка через хук Query, напрыклад useMe, з стабільным ключам, такім як ['me'].
  • Zustand можа хаваць activeWorkspaceId, таму што выбір рабочага прастора — це выбар у інтэрфейсе. Ён не павінен таксама хаваць цэлы массив членстваў, калі гэты массив вялікі ў Query.
  • Калі сокет паведамляе пра змену ролі, неяксшы ['me'] і адпаведныя ключы рабочага прастора. Ручная падстановка калькі з полей Zustand і надзея, што нічога не было пракінута, — гэта спосаб, якаму прасачы дазвола праскальзваюць.

    Флагі функцыйяў працуюць па той жа логіке. Флагі, якія надае API, належаць Query. Локальныя замены для дыбаггінгу могу хавацца ў Zustand, за выключэнням случая, калі імя стоўкі є абавязкова ясным, напрыклад useDevFlagOverrides, каб ніхто не плутаў яго з значэннямі для прыемнай експлуатацыі.

    Компрэсіі і калі ўзяць гэта за скаканне

    Выкарыстоўванне Zustand і TanStack Query адночасна значыць неабходнасць выучэння двух бібліятак, што стварае дапэўны трыбут для новых інжынераў, якія прыўязваюцца да репазітарыю. Короткі файл STATE.md, який пояснюе такое раздзеленне і ўказвае на яго лінк у шаблоне pull request, быстра компенсуе ся своімі зусіллямі.

    Дзеякі статы з іншаю стороны дзейсна апінаюцца на обе сферы, напрыклад, выбраны документ. Рашэнняя — хаваць у Zustand толькі id, а entity чытаць за дапамогою useQuery або getQueryData. Копіюванне entity у базу данных UI знову стварае проблему дуплікацыі.

    Для невялікога маркетынговага сайту з адным формам такая структура ўсё-такі занадта. Для продукту з рэальнай часу редактарамі, працоўнымі прасторамі, захаванымі праз аутэнтыкацыю, і спакункамі монорепазітарыя, гэта быстра становіцца неабходнасцю.

    Спіс пераконтраць для новага дапрыемства

    1. З’явіце TanStack Query і з самага пачатку задаце чыстую фабрыку ключоў.
    2. Створыце адну базу дадзеных Zustand для шэлу і UI chrome.
    3. Праўяце сокеты і падзеі Hocuspocus да queryClient.setQueryData і invalidateQueries.
    4. Адхіліце прыгласы да змены коду, якія запрашаюць дадзеныя ўнутры Zustand.
    5. Перш чым оптымацаваць што-небудзь іншае, выявіце, як часта маршрут рэдагування перзначаеся.

    Галоўныя выводы

    • Што стосуецца адпаведнасці за дадзеныя, то трэба выявіць, які стан можа зникнуць пасля апдэйту, а які должен застацца пасля змены вікна; першы — це стан UI, другі — стан сервера.
    • Падзеі у рэальны час не ўжо вхідныя даны для кеша Query або Y.Doc, а не прычына стварэння паралельной базы дадзеных.
    • Спяльная фабрыка ключоў забезпечвае надзейнае анулювання дадзеных у розных прыкладнах, пакетах і обробніках сокетаў.
    • Калі выборы ў UI адносяцца да дадзеных сервера, трэба зберагваць іды баз дадзеных, а не самыя ентыты.
  • Данныя прыткага руху, такія як курсоры, трэба заліць цалкам пазірныя ад React.