Zarządzanie stanem w next.js app router

Wstęp
App router wprowadza zmiany w zarządaniu stanem w aplikacji, ponieważ wprowadza rozróżnienie na komponenty klienckie i serwerowe. Ten podział wprowadza trzy nowe zasady w zarządzaniu stanem:
- Brak global stores
- React Server Components nie mogą mieć dostępu do danych ze store
- React Server Components używamy do niezmiennych danych (immutable data), a komponentów klienckich do zmiennych danych (mutable data)
Najważniejsze to podążać za tymi trzema zasadami, wtedy nie ważne jaką blibliotekę wybierzesz osiągniesz sukces!
W zarządzaniu stanem należy też pamiętać o tym, że komponenty serwerowe moga zawierać klienckie jako dzieci, ale komponent kliencki może przyjąć serwerowy tylko jako children. Jest to związane z hydracją.
Context
Context to wbudowane narzędzie do zarządzania stanem w React. W next.js z app router musimy oznaczyć Context jako komponent kliencki za pomocą dyrektywy "use client". W next.js z app routerem musimy synchronizować stan kliencki z serwerowym, aby uniknąć problemów z hydracją. W tym celu do komponentu Providera podajemy jako prop stan z serwera.
"use client";
import React, { createContext, useState } from "react";
import type { Cart } from "@/api/types";
type CartContextProps={
cart: Cart | null
}
export const CartContext = createContext<CartContextProps | null>(null);
const CartProvider = ({
initialCart, //inital cart aby przekazać stan z serwera
children,
}: {
initialCart: Cart,
children: React.ReactNode,
}) => {
const [cart, setCart] = useState<Cart|null>(initialCart);
return (
<CartContext.Provider value={{cart, setCart}}>
{children}
</CartContext.Provider>
);
};
//hook zabezpieczający
export const useCart = () => {
const cart = React.useContext(CartContext);
if (!cart) {
throw new Error("useCart must be used within a CartProvider");
}
return cart;
};
Podłączamy context w layout aplikacji. Może to być na poziomie całej aplikacji lub grupy kilku komponentów.
import CartProvider from "./contexts/CartContext";
export default function Layout(){
const cart = await getCart()
return (<CartProvider cart={cart}>
<main className="mx-auto max-w-3xl">{children}</main>
</CartProvider>)
}
W komponentach klienckich korzystamy z contextu za pomocą stworzonego wcześniej pomocniczego hooka:
'use client';
export const SomeClientComponent = () => {
const { cart, setCart } = useCart();
};
Taki context może się przydać żeby uniknąć props drillingu w aplikacji. Moim zdaniem, jeśli aplikacja będzie dobrze zaprojektowana to context na potrzeby koszyka to tylko duplikowanie stanu. Lepiej cały ten stan trzymać po stronie serwera, odpowiednio to tam cacheować i zwiększyć wydajność w tamtym miejscu.
Możemy do celów zarządzania stanem użyć innych bibliotek, takich jak np. Redux, zustand, jotai lub recoil. Wszędzie spotkamy się z podobym problemem, czyli synchronizacją stanu serwerowego i klienckiego. Musimy się starać unikać takich sytuacji, bo stan może nie być wtedy spójny i złamiemy zasadę Singe Source of True (jedno źródło prawdy).
Inne rozwiązanie, które może przyjść Ci do głowy to trzymanie stanu w ciasteczkach, wtedy mamy jedno źródło prawdy dostępne po stronie serwera i klienta. To rozwiązanie natomiast ma taką wadę, że odczytywanie headers i cookies w komponencie serwerowym wyłącza cache i włącza pełny SSR, czyli strona jest budowana na serwerze na każde żądanie klienta, co może obciążyć serwer.
Rozwiązanie po stronie serwera, które dobrze się sprawdza to rewalidacja, którą omówimy w następnych akapitach.
Zarządzanie stanem za pomocą rewalidacji
W tej sekcji przyjąłem założenie, że stosujemy bardzo agresywne cachowanie - wszystkie GET requesty są cachowane na zawsze. Wyobraź sobie konfigurację tanstack query, w której cacheTime jest ustawiony na Infinity jako odpowiednik tego zachowania. Dopiero po poinformowaniu cache o konieczności pobrania danych na nowo dane się pobiorą, jeśli nie poinformujemy cache o tym, że dane są przestarzałe po prostu ich nie pobierze i pozostawi poprzednią wartość.
Twórcy next nadpisali wbudowanego w przeglądarke fetcha i zastosowali tam taki rodzaj cache (force-cache to domyślna opcja). W wersji 15 wycofali się z tego pomysłu na prośbę społeczności. Osobiście przeszkadzało mi, że to rozwiązanie źle współgrało to z różnymi bibliotekami korzystającymi pod spodem z fetcha.
fetch('url', { cache: 'force-cache' });
Wyobraźmy sobie, stronę z postami na blogu, która posiada formularz z komentarzami. Wszystkie posty renderowane są techniką SSG (Static site generation). Strona ta będzie miała ścieżkę: app/blog/[slug]/page.tsx
export default function Page({ params }: { params: { slug: string } }) {
const { slug } = params
// ...
}
export async function generateStaticParams() {
const posts = await fetch('https://.../posts').then((res) => res.json())
return posts.map((post) => ({
slug: post.slug,
}))
}
Strona ta pobiera komentarze do posta za pomocą funkcji getCommentsForPostSlug, która ma włączony cache jako force-cache. Nie chcemy żeby komentarze pobierały się po każdym wejściu na stronę, tylko aby cała strona była generowana.
const getCommentsForPostSlug=async (slug:string)=>{
const data=await fetch(..., {cache: 'force-cache'}) //
}
Teraz pojawia się problem przy stworzeniu formularza i wysłaniu danych na serwer strona się nie aktualizuje. Winny jest cache.
Możemy sobie z tym poradzić na kilka sposobów:
- api route do rewalidacji,
- server action z revalidatePath,
- server action z revalidateTag.
Api route do rewalidacji:
Tworzymy api route. Może być w pliku app/api/revalidate/route.ts. Pamiętaj o konwencji nazewniczej plików w next! 😄
import { revalidatePath } from 'next/cache'
import { NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
const path = request.nextUrl.searchParams.get('path')
if (path) {
revalidatePath(path)
return Response.json({ revalidated: true, now: Date.now() })
}
return Response.json({
revalidated: false,
now: Date.now(),
message: 'Missing path to revalidate',
})
}
Po dodaniu danych do bazy możemy uderzyć na nasz endpoint w następujący sposób:
fetch(`/api/revalidate?path=/blog/[slug]`);
Spowoduje to przeładowanie strony - cache się odświeży. W tej metodzie mamy jedną pułapkę - nie da się odświeżyć KONKRETNEJ strony generowanej statycznie. Musimy odświeżyć wszystkie. Z tego powodu ścieżka to nie /blog/post-1 tylko /blog/[slug]
Server action z revalidatePath
Server action możemy przekazać do submitowania formularza, a w akcji serwerowej możemy bezpośrednio użyć funkcji revalidatePath z next/cache.
'use server';
import { revalidatePath } from 'next/cache';
export default async function action() {
revalidatePath('/blog/[slug]');
}
Jeśli wywoływanie server actions z formularza jest Ci obce to spójrz na poprzedni wpis na blogu:
formularze w next.js app router
Server action z revalidateTag
Na początek musimy oznaczyć zapytanie fetch tagiem. To podobne do używania kluczy cacheowania w tanstack-query. Oznaczenie to wygląda w taki sposób:
const res = await fetch('https://...', { next: { tags: ['comments'] } });
Później w akcji do rewalidacji taga wywołujemy funkcję revalidateTag z next/cache z odpowiednią nazwą.
'use server';
import { revalidateTag } from 'next/cache';
export default async function action() {
revalidateTag('comments');
}
Uważam, że twórcy next'a zaczerpnęli ten pomysł z dobrej biblioteki jaką jest tanstack-query.
Podsumowanie
Jak widać stanem możemy zarządzać na wiele sposobów. W zależności od projektu trzeba wiedzieć, który sposób zastosować i wybrać. Na pewno znajdziesz też swój ulubiony sposób zarządzania stanem. Mi najbardziej podoba się sposób z tagami przy zarządzaniu stanem po stronie serwera, a po stronie klienta lubię stary dobry ContextAPI.
W branży IT nauczyłem się już, że najlepszą odpowiedzią na każde pytanie są słowa: "to zależy". Wybór odpowiedniego rozwiązania zależy, od problemu z jakim się aktualnie mierzymy. W niektórych sytuacjach, dobrze zaprojektowane endpointy i cache wykluczą używanie contextu, bo po prostu nie bedzie to potrzebne. Wtedy zarządzanie stanem załatwimy za pomocą rewalidacji. W innych sytuacjach ciastka będą dobrym rozwiązaniem, a w innym context.