Stan versus granice TanStack Query w czasie rzeczywistym w monorepo Next.js
Dowiedz się o jasnej zasadzie własności dla stanu interfejsu użytkownika, stanu serwera oraz zdarzeń w czasie rzeczywistym w współpracy w monorepo Next.js przy użyciu Zustand, TanStack Query i sokietów.
Źródła: Zustand for UI, TanStack Query for the Server: The Split That Finally Made Our App Sane
Wiele projektów napisanych w Next.js zaczyna się od małego magazynu Zustand do przechowywania „stanu globalnego”, który stopniowo przekształca się w narzędzie obejmujące wszystko: najpierw dane o obecnym użytkowniku, potem lista dokumentów, następnie mapa obecności uzyskiwana z socketa, a w końcu optymistyczne edycje dokonywane przez kogoś innego. Kilka miesięcy później magazyn ten wchodzi w konflikt z renderowaniem na serwerze, proces unieważniania danych dotyka niewłaściwych informacji, a każda prośba o aktualizację ponownie rodzi dyskusje na temat tego, gdzie powinny znajdować się dane. Ten artykuł przedstawia prostą zasadę przypisania odpowiedzialności w aplikacjach współpracy, pokazuje kod magazynu i zapytań realizujący tę zasadę w monorepo oraz omawia, jak wydarzenia w czasie rzeczywistym, sesje autoryzacyjne i wydajność pasują do tego samego modelu.
Szczegóły dotyczą produktu opartego na intensywnej współpracy: edytora tekstu z funkcjami Tiptap i Hocuspocus, Socket.io do lekkiego monitorowania obecności użytkowników i wysyłania powiadomień, a także monorepo zawierające aplikację internetową, wspólne komponenty interfejsu użytkownika oraz typy API. W takim układzie łączenie pamięci cache serwera z Zustandem nie jest tylko nieskuteczne – zakłóca również proces SSR, powoduje błędne aktualizacje i sprawia, że śledzenie nieoczekiwanych ponownych renderowań staje się ciągłą udręką.
Jedna zasada zarządzania dla trzech rodzajów stanu
Zasada ta jest celowo prosta, co ułatwia jej stosowanie pod presją.
- Zustand zarządza stanem interfejsu użytkownika. Obejmuje to wszystko, co nigdy nie trafia do odpowiedzi HTTP: czy pasek boczny jest otwarty, który zakładka jest wybrana, flagi dotyczące modaliów w wersji roboczej, elementy otaczające edytora, czy menu z poleceniami jest widoczne, oraz krótkotrwałe ustawienia układu.
Szybki test rozstrzyga większość sporów: jeśli wartość zostałaby utracona przy odświeżeniu strony i nikt by się tym nie przejął, to jest to stan interfejsu. Jeśli jej utrata oznaczałaby konieczność ponownego pobrania, to jest to stan serwera.
Dwa błędy pojawiające się, gdy granica między nimi staje się niejasna
Migające i niezgodne karty po SSR
Zwykła pierwsza próba polega na hydratacji listy dokumentów do Zustand po stronie klienta po pobraniu danych na serwerze – najpierw za pomocą getServerSideProps, a później dzięki komponentom serwerowym React w połączeniu z pobraniem danych na stronie klienta. Przy całkowitym odświeżeniu strony początkowo wyświetlają się dane serwerowe, następnie Zustand inicjalizuje się jako pusty, a potem ponownie się wypełnia. Użytkownik widzi chwilowe zamglenie obrazu. Co gorsza, dwa otwarte karty mogą pokazywać różne listy, ponieważ magazyn Zustand nie posiada informacji o tożsamości cache’u; jest to zwykły stan w pamięci.
TanStack Query został stworzony właśnie do rozwiązania tego problemu. Jego opcja initialData oraz funkcje obsługi dehydratacji/hydratacji umożliwiają klientowi pobranie dokładnie tych danych, które zostały wyświetlone na serwerze. Zustand nie oferuje niczego równoważnego, ponieważ nigdy nie miał służyć jako cache’u.
Optymistyczne aktualizacje, które nigdy nie są cofane
Drugim modelem awarii jest ręcznie sterowany optymizm. Komponent wywołuje set(), aby pokazać nowy tytuł dokumentu w Zustand, uruchamia mutację i zapomina przywrócić stare wartości, jeśli żądanie się nie powiedzie. Funkcje zwrotne onMutate, onError i onSettled z TanStack Query umożliwiają przewidywalne cofnięcie zmian. Customowy mechanizm optymistyczny w Zustand zazwyczaj jest pomysłowy, ale błędny.
Przeniesienie mutacji do Query i zachowanie Zustand do informacji takich jak „okno przemiany nazwy jest otwarte” eliminuje większość takich błędów.
Strukturyzowanie stanu w monorepo
W środowisku typu Turborepo układ wygląda mniej więcej w ten sposób:
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
Najważniejszą decyzją jest umieszczenie zarówno kluczy Query, jak i przechowalni Zustand w katalogu packages/state. Gdy każda folder z funkcjonalnościami tworzy własny schemat nazywania kluczy, unieważnianie pamięci cache staje się domysłem.
Przechowalnia Zustand ograniczona do interfejsu edytora
Poniższa przechowalnia rejestruje tylko to, czego potrzebuje otaczający edytor interfejs: widoczność paska bocznego, menu z kreską oraz to, który panel boczny jest aktywny. Każde pole ma prosty mechanizm ustawiania wartości, a funkcja resetChrome zamyka tymczasowy interfejs bez wpływu na ustawienia paska bocznego. Należy zauważyć, że publikowany fragment używa cudzysłowów typograficznych wokół liter zmiennych, co uniemożliwia skompilowanie kodu; należy je zastąpić zwykłymi cudzysłowami podczas adaptacji.
// 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 }),
}))
To, co sklep pomija, jest równie ważne. Nie ma żadnych dokumentów, nie ma currentUser, a także nie jest kopiowany stan połączenia z soketa. Mały sklep do przechowywania stanu połączenia jest do przyjęcia, jeśli dotyczy wyłącznie aspektów interfejsu użytkownika po stronie klienta, chociaż często można go uzyskać bezpośrednio od dostawcy. Lista dokumentów jednak w żadnym wypadku nie powinna znajdować się tutaj.
Haki do zapytań, fabryka kluczy i bezpieczna zmiana nazwy
Moduł dokumentów definiuje fabrykę kluczy, zapytanie listowe oraz mutację przemiany nazwy. Obiekt documentKeys wytwarza hierarchiczne, typowane klucze: all jest korzeniem, a list oraz detail je rozszerzają. Zapytanie listowe wykorzystuje wartość staleTime równą 30 sekundom. Mutacja przemiany nazwy postępuje zgodnie ze standardową sekwencją optymistyczną: anuluje trwające pobieranie dokumentu, tworzy zrzut stanu poprzedniej wartości, zapisuje nową nazwę do pamięci cache, przy błędzie przywraca zrzut stanu oraz unieważnia zarówno informacje szczegółowe, jak i cały drzewo dokumentów po zakończeniu żądania. Podobnie jak w przypadku magazynu, fragment ten zawiera cytaty typograficzne oraz znaki elipsy zamiast operatorów rozszerzania, a ciąg importu nie ma zamkniętego cudzysłowu – należy to poprawić przed użyciem.
// 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 })
},
})
}
Fabryka kluczy wykonuje więcej zadań, niż się wydaje. Ponieważ te same klucze są importowane wszędzie w monorepo, obsługi socketów oraz elementy interfejsu użytkownika odnoszą się do identycznych wpisów w pamięci podręcznej. Sytuacja, w której jedna aplikacja aktualizuje swój magazyn danych, a wspólny pakiet o tym nie dowiaduje się, po prostu nie może wystąpić. Unieważnienie wartości documentKeys.all również działa skutecznie, ponieważ Query porównuje klucze według prefiksu, dzięki czemu każda lista oraz wpis szczegółowy poniżej zostają oznaczone jako przestarzałe przy jednej tylko próbie. Aby dowiedzieć się więcej na temat organizacji warstwy Query w ten sposób, zapoznaj się z artykułem strukturyzacja warstwy danych TanStack Query od queryOptions do rollbacks.
Włączanie aktualizacji w czasie rzeczywistym do modelu
W tym stacku Hocuspocus zajmuje się dokumentem edytora współpracy, natomiast Socket.io przekazuje lżejszy ruch, takiego jak informacje o obecności użytkowników i powiadomienia. Kuszącym wstępnym rozwiązaniem jest użycie usePresenceStore, które odzwierciedla pełną listę członków z socketa, wraz z cache’em z tych samych członków załadowanym przez REST. Przechowywanie dwóch kopii tej samej listy w synchronizacji to dokładnie ten rodzaj duplikacji, który powoduje uciążliwe błędy.
Lepszy podział obowiązków:
- Początkowa lista członków pochodzi z hooka typu Query, np.
useWorkspaceMembers. - Zdarzenia dotyczące obecności aktualizują tylko tymczasowe pola, takie jak status online lub kolor kursora, przechowywane albo w kompaktowej części Zustand, albo w stanie poszczególnych komponentów indeksowanym według ID użytkownika.
Treści współpracy pozostają wewnątrz Y.Doc zarządzanego przez Y.js i Tiptap. Dla wygody kopiowanie pełnego JSON ProseMirror do Zustand tworzy dwie reprezentacje dokumentu, które ostatecznie będą się różnić, zazwyczaj w najmniej odpowiednim momencie.
Lekcje dotyczące wydajności z edytora współpracy
Wybieraj fragmenty zamiast czytać całą bazę danych
Zawołanie useEditorChrome((s) => s.sidebarOpen) ponownie renderuje komponent tylko wtedy, gdy zmienia się ta wartość. Wywołanie tego hooka bez selektora sprawia, że aplikacja słucha każdego pola w przechowalniku danych, więc wpisywanie treści w oknie modalnym, które aktualizuje jakiekolwiek pole, może spowodować ponowny render niepowiązanego panelu. Rada ta wydaje się oczywista, jednak słuchacze całego przechowalnika danych są łatwo używane przypadkowo.
Zapobiegaj częstym operacjom kursora w React
Żywe kursory stanowią klasyczną pułapkę w edytorach współpracy. Jeśli każdy ruch kursora trafia przez Zustand do React, co z kolei powoduje zmiany układu interfejsu, edytor będzie działał wolno. Y.js został stworzony właśnie do obsługi takich szybkich, tymczasowych danych, więc niech on zajmuje się kursorami, a stan React powinien być przeznaczony dla elementów interfejsu zmieniających się wolniej.
Rozpatruj staleTime jako decyzję produktową
staleTime powinien odzwierciedlać, jak kosztowne są przestarzałe dane dla danego ekranu. Gdy przestarzałe uprawnienia mogą być niebezpieczne, jak na ekranach SaaS typu opieka zdrowotna, należy użyć niskiej wartości i agresywnie unieważniać je, gdy okno ponownie zyskuje uwagę użytkownika. W przypadku listy dokumentów, która rzadko się zmienia, dłuższa wartość oszczędza zasoby sieci. Jedna globalna wartość staleTime: 0 powodowała znaczny wzrost obciążenia aplikacji Next.js bez żadnych korzyści.
Nawilżaj stan serwera, a nie elementy interfejsu
Przy SSR lub RSC należy usunąć dane z Query na serwerze, aby pierwszy rysunek ekranu był poprawny, i trzymać Zustand wyłącznie po stronie klienta, chyba że istnieje poważny powód, by tego nie robić. Rysowanie stanu elementów interfejsu na serwerze rzadko jest warte dodatkowej złożoności.
Konwencje utrzymujące podział
Kilka konwencji zespołu sprawia więcej niż kolejna warstwa abstrakcji:
- Zachowaj jeden moduł
documentKeysi importuj go zarówno z hooków, obsługujących sokety, jak i testów. - Nigdy nie wykonywaj żadnych operacji pobierania danych wewnątrz akcji Zustand. Akcja, która wymaga połączenia z siecią, to w rzeczywistości hook mutacji, a nie wywołanie
set(). - Katalogi z funkcjonalnościami mogą mieć własne magazyny interfejsu użytkownika. Umieszczenie
useSlashCommandStoreobok edytora jest w porządku; natomiast entity serwerowe udostępniane pomiędzy aplikacjami nie powinny tego robić. - Importuj wspólne typy z
packages/api. Za każdym razem, gdy modyfikuje się DTO do przemiany nazwy, hooki Query oraz payloade Prisma zmieniają się razem, a aktualizacja cache przestaje polegać na luźno ztypowanych ciągach znaków.
Celem nie jest skomplikowana strategia zarządzania stanem. Jest to zasada, którą zmęczony inżynier może poprawnie zastosować późno w nocy.
Sesje autoryzacyjne i flagi funkcjonalne to również stan serwera
Zarządzanie sesjami w aplikacjach SaaS opartych na Next.js jest częstym źródłem duplikowanego stanu. Sama sesja, czy to plik cookie, token JWT, czy obiekt w stylu NextAuth, stanowi stan serwera, podobnie jak profil wyświetlany w interfejsie aplikacji. Cacheowanie użytkownika w Zustand w celu przyspieszenia renderowania menu nawigacyjnego tworzy drugie źródło prawdy, które staje się przestarzałe w momencie zmian uprawnień: po przyjęciu zaproszenia, awansie roli lub zmianie organizacji przez użytkownika.
Bardziej solidne rozwiązanie:
- Na serwerze, w miarę możliwości, odczytuj sesję za pośrednictwem narzędzia pomocniczego do autoryzacji.
- Na stronie klienta, udostępniaj użytkownika za pomocą hooka typu Query, np.
useMe, z stabilnym kluczem takim jak['me'].
activeWorkspaceId, ponieważ wybór przestrzeni roboczej jest operacją wyboru w interfejsie użytkownika. Nie powinien on również przechowywać całego array członkostw, skoro ten array znajduje się już w Query.Gdy socket zgłasza zmianę roli, należy unieważnić ['me'] oraz odpowiednie klucze przestrzeni roboczej. Błędy związane z uprawnieniami pojawiają się właśnie wtedy, gdy ręcznie modyfikuje się kilka pól w Zustand i ma się nadzieję, że nic nie zostało przeoczone.
Flagi funkcjonalne działają według tej samej logiki. Flagi dostarczane przez API należą do Query. Lokalne modyfikacje w celach debugowania mogą znajdować się w Zustand, pod warunkiem, że magazyn danych ma jednoznaczną nazwę, na przykład useDevFlagOverrides, aby nikt nie pomylił go z wartościami używanymi w produkcji.
Kompromisy i kiedy to pominąć
Używanie jednocześnie Zustand i TanStack Query oznacza konieczność opanowania dwóch bibliotek, co stanowi prawdziwy obciążenie dla nowszych inżynierów dołączających do repozytorium. Krótki plik STATE.md, który wyjaśnia tę podział i jest dostępny w szablonie pull request, szybko się zwraca.
Część stanów rzeczywiście dotyczy obu środowisk, np. wybrany dokument. Rozwiązaniem jest przechowywanie tylko id w Zustand, a odczytywanie entity za pomocą useQuery lub getQueryData. Kopiowanie entity do pamięci interfejsu powoduje ponowne pojawienie się problemu dublowania.
Dla małej strony marketingowej z jednym formularzem taka struktura jest przesadą. Natomiast w przypadku produktu z edytorami w czasie rzeczywistym, przestrzeniami roboczymi zabezpieczonymi hasłem oraz wspólnymi pakietami w monorepo, staje się szybko koniecznością.
Lista kontrolna na pierwszy dzień pracy z nowym aplikacją
- Zainstaluj TanStack Query i od samego początku zdefiniuj jasną fabrykę kluczy.
- Stwórz jeden magazyn Zustand dla szkieletu i elementów interfejsu.
- Połącz zdarzenia socket i Hocuspocus z metodami
queryClient.setQueryDataorazinvalidateQueries. - Odrzucaj prośby o zmianę kodu, które pobierają dane wewnątrz Zustand.
- Zanim zaczniesz optymalizować cokolwiek innego, sprawdź, jak często ścieżka edytora ponownie renderuje się.
Główne wnioski
- Aby określić właściciela danych, zastanów się, które stany mogą zniknąć po odświeżeniu, a które muszą przetrwać zmianę karty; pierwsze to stany interfejsu, drugie – stany serwera.
- Zdarzenia w czasie rzeczywistym służą jako dane wejściowe do cache Query lub Y.Doc, a nie jako powód do tworzenia oddzielnego magazynu.
- Dzięki wspólnej fabryce kluczy unikamy problemów z nieważnością danych w różnych aplikacjach, pakietach i obsługujących socket.
- Zapisuj identyfikatory magazynów, a nie same dane, gdy wybory w interfejsie odnoszą się do informacji z serwera.
Literatura pokrewna
- Strukturyzacja warstwy danych TanStack Query, od queryOptions do Rollbacks — Budowanie warstwy danych TanStack Query krok po kroku: wspólne queryOptions, generatorzy kluczy, selektory, paginacja, wcześniejsze pobieranie danych, centralne unieważnianie i bezpieczne aktualizacje optymistyczne.
- Budowanie modułu React opartego na funkcjach: Posts CRUD z TanStack Query — Przestrukturyzuj aplikację React według funkcji zamiast typu pliku, połącz pełne operacje CRUD dla postów z TanStack Query i axios oraz sprawdź, gdzie ten model przestaje być skalowalny.
- Ile kosztuje użycie effect-fetching i co zmienia TanStack Query — Dlaczego pobieranie danych na podstawie effect powoduje duplikowane żądania, kod szablonowy oraz przestarzałe zamykania w React, oraz jak klucze, staleTime i mechanizm unieważniania w TanStack Query rozwiązują te problemy.