Wizards of code logoWizards of code logo

Zarządzanie stanem w next.js app router

zarzadzanie-stanem-w-nextjs-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:

  1. Brak global stores
  2. React Server Components nie mogą mieć dostępu do danych ze store
  3. 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 routerhttps://wizardsofcode.pl/blog/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.