Strona główna / Artykuły

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.

Wydawca: REACTAPP.TOP10 min czytania2359 słó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.
  • TanStack Query zarządza stanem serwera. Tutaj znajduje się wszystko, czego źródłem prawdy jest backend: dokumenty, użytkownicy, uprawnienia, flagi fakturowania oraz ogólnie wszystko to, co chciałbyś ponownie pobrać po zmianie danych.
  • Kanały w czasie rzeczywistym aktualizują pamięć cache zamiast tworzyć trzecią bazę danych. Zdarzenia Socket.io i Hocuspocus modyfikują cache Query lub dokumenty Y.js. Nie uzasadniają one posiadania duplikatu tej samej listy w Zustand tylko dlatego, że zmienia się ona na żywo.
  • 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:

    1. Początkowa lista członków pochodzi z hooka typu Query, np. useWorkspaceMembers.
    2. 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.
  • Zmiany strukturalne, takie jak dołączenie nowego członka do przestrzeni roboczej, aktualizują lub unieważniają pamięć cache z zapytaniami, dokładnie tak jak to robiłaby mutacja REST.
  • 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ł documentKeys i 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 useSlashCommandStore obok 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'].
  • Zustand może przechowywać 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ą

    1. Zainstaluj TanStack Query i od samego początku zdefiniuj jasną fabrykę kluczy.
    2. Stwórz jeden magazyn Zustand dla szkieletu i elementów interfejsu.
    3. Połącz zdarzenia socket i Hocuspocus z metodami queryClient.setQueryData oraz invalidateQueries.
    4. Odrzucaj prośby o zmianę kodu, które pobierają dane wewnątrz Zustand.
    5. 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.
  • Zachowaj dane dotyczące szybko zmieniającej się pozycji kursora poza Reactem.
  • Literatura pokrewna