React Server Components: Modele mentalne, wzorce i pułapki
Praktyczny przewodnik po React Server Components, który chciałbym mieć na początku. Modele mentalne, realne wzorce, problem granicy i błędy, które popełniłem, żebyś ty nie musiał.
Przez pierwsze trzy miesiące z React Server Components byłem przekonany, że je rozumiem. Czytałem RFC, oglądałem prezentacje konferencyjne, zbudowałem kilka demo. Byłem pewny siebie i myliłem się w prawie wszystkim.
Problem nie polega na tym, że RSC jest skomplikowane. Chodzi o to, że model mentalny jest naprawdę inny od czegokolwiek, co robiliśmy wcześniej w React, a wszyscy — w tym ja — próbują wpasować go w stare ramy. „To jak SSR." Nie jest. „To jak PHP." Bliżej, ale nie. „To po prostu komponenty działające na serwerze." Technicznie prawda, praktycznie bezużyteczne.
Poniżej znajdziesz wszystko, czego faktycznie potrzebowałem, napisane tak, jak chciałbym, żeby ktoś mi to wyjaśnił. Nie wersja teoretyczna. Ta, w której gopisz się w błąd serializacji o 23:00 i musisz zrozumieć dlaczego.
Model mentalny, który faktycznie działa#
Zapomnij na chwilę o wszystkim, co wiesz o renderowaniu React. Oto nowy obraz.
W tradycyjnym React (po stronie klienta) całe drzewo komponentów jest wysyłane do przeglądarki jako JavaScript. Przeglądarka pobiera je, parsuje, wykonuje i renderuje wynik. Każdy komponent — czy to 200-liniowy interaktywny formularz, czy statyczny akapit tekstu — przechodzi przez ten sam pipeline.
React Server Components dzielą to na dwa światy:
Server Components działają na serwerze. Wykonują się raz, produkują swoje wyjście i wysyłają wynik do klienta — nie kod. Przeglądarka nigdy nie widzi funkcji komponentu, nigdy nie pobiera jego zależności, nigdy go nie re-renderuje.
Client Components działają jak tradycyjny React. Są wysyłane do przeglądarki, hydrowane, utrzymują stan, obsługują zdarzenia. To React, który już znasz.
Kluczowa obserwacja, którą zawstydzająco długo internalizowałem: Server Components są domyślne. W Next.js App Router każdy komponent jest Server Component, chyba że jawnie zdecydujesz się na klienta za pomocą "use client". To odwrotność tego, do czego przywykliśmy, i zmienia sposób myślenia o kompozycji.
Kaskada renderowania#
Oto co faktycznie dzieje się, kiedy użytkownik żąda strony:
1. Żądanie trafia na serwer
2. Serwer wykonuje Server Components od góry do dołu
3. Kiedy Server Component napotyka granicę "use client",
zatrzymuje się — to poddrzewo będzie renderowane na kliencie
4. Server Components produkują RSC Payload (specjalny format)
5. RSC Payload jest strumieniowane do klienta
6. Klient renderuje Client Components, wszywając je w
drzewo wyrenderowane na serwerze
7. Hydracja czyni Client Components interaktywnymi
Krok 4 to miejsce, gdzie tkwi większość zamieszania. RSC Payload to nie HTML. To specjalny format strumieniowy, który opisuje drzewo komponentów — co serwer wyrenderował, gdzie klient musi przejąć kontrolę i jakie propsy przekazać przez granicę.
Wygląda mniej więcej tak (uproszczenie):
M1:{"id":"./src/components/Counter.tsx","chunks":["272:static/chunks/272.js"],"name":"Counter"}
S0:"$Sreact.suspense"
J0:["$","div",null,{"children":[["$","h1",null,{"children":"Welcome"}],["$","$L1",null,{"initialCount":0}]]}]
Nie musisz zapamiętywać tego formatu. Ale zrozumienie, że istnieje — że między serwerem a klientem jest warstwa serializacji — zaoszczędzi ci godzin debugowania. Za każdym razem, gdy dostajesz błąd „Props must be serializable", to dlatego, że coś, co przekazujesz, nie przetrwa tej translacji.
Co „działa na serwerze" naprawdę oznacza#
Kiedy mówię, że Server Component „działa na serwerze", mam na myśli dosłownie. Funkcja komponentu wykonuje się w Node.js (lub Edge runtime). Oznacza to, że możesz:
// app/dashboard/page.tsx — this is a Server Component by default
import { db } from "@/lib/database";
import { headers } from "next/headers";
export default async function DashboardPage() {
const headerList = await headers();
const userId = headerList.get("x-user-id");
// Direct database query. No API route needed.
const user = await db.user.findUnique({
where: { id: userId },
});
const recentOrders = await db.order.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
take: 10,
});
return (
<div>
<h1>Welcome back, {user.name}</h1>
<OrderList orders={recentOrders} />
</div>
);
}Żadnego useEffect. Żadnego zarządzania stanem ładowania. Żadnej route API do sklejania rzeczy. Komponent jest warstwą danych. To największa zaleta RSC i to, co na początku było najbardziej niekomfortowe, bo ciągle myślałem: „ale gdzie jest separacja?"
Separacją jest granica "use client". Wszystko powyżej to serwer. Wszystko poniżej to klient. To jest twoja architektura.
Granica serwer/klient#
Tu rozpada się rozumienie większości ludzi i tu spędziłem najwięcej czasu na debugowaniu w pierwszych miesiącach.
Dyrektywa "use client"#
Dyrektywa "use client" na górze pliku oznacza wszystko eksportowane z tego pliku jako Client Component. To adnotacja na poziomie modułu, nie komponentu.
// src/components/Counter.tsx
"use client";
import { useState } from "react";
// This entire file is now "client territory"
export function Counter({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
// This is ALSO a Client Component because it's in the same file
export function ResetButton({ onReset }: { onReset: () => void }) {
return <button onClick={onReset}>Reset</button>;
}Częsty błąd: umieszczenie "use client" w pliku barrel (index.ts), który re-eksportuje wszystko. Gratulacje, właśnie zrobiłeś całą swoją bibliotekę komponentów kliencką. Widziałem zespoły, które przypadkowo wysłały 200KB JavaScriptu w ten sposób.
Co przechodzi przez granicę#
Oto zasada, która cię uratuje: wszystko, co przechodzi przez granicę serwer-klient, musi być serializowalne do JSON.
Co jest serializowalne:
- Stringi, liczby, booleany, null, undefined
- Tablice i proste obiekty (zawierające serializowalne wartości)
- Daty (serializowane jako stringi ISO)
- Server Components (jako JSX — do tego dojdziemy)
- FormData
- Typed arrays, ArrayBuffer
Co NIE jest serializowalne:
- Funkcje (w tym handlery zdarzeń)
- Klasy (instancje niestandardowych klas)
- Symbole
- Węzły DOM
- Strumienie (w większości kontekstów)
Oznacza to, że nie możesz tego zrobić:
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList"; // Client Component
export default async function Page() {
const items = await getItems();
return (
<ItemList
items={items}
// ERROR: Functions are not serializable
onItemClick={(id) => console.log(id)}
// ERROR: Class instances are not serializable
formatter={new Intl.NumberFormat("en-US")}
/>
);
}Rozwiązaniem nie jest uczynienie strony Client Component. Rozwiązaniem jest zepchnięcie interaktywności w dół, a pobierania danych w górę:
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList";
export default async function Page() {
const items = await getItems();
// Only pass serializable data
return <ItemList items={items} locale="en-US" />;
}// src/components/ItemList.tsx (Client Component)
"use client";
import { useState, useMemo } from "react";
interface Item {
id: string;
name: string;
price: number;
}
export function ItemList({ items, locale }: { items: Item[]; locale: string }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
// Create the formatter on the client side
const formatter = useMemo(
() => new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }),
[locale]
);
return (
<ul>
{items.map((item) => (
<li
key={item.id}
onClick={() => setSelectedId(item.id)}
className={selectedId === item.id ? "selected" : ""}
>
{item.name} — {formatter.format(item.price)}
</li>
))}
</ul>
);
}Mylne pojęcie „wyspy"#
Początkowo myślałem o Client Components jako „wyspach" — małych interaktywnych elementach w morzu treści renderowanych na serwerze. To częściowo prawda, ale pomija kluczowy szczegół: Client Component może renderować Server Components, jeśli są przekazane jako children lub propsy.
Oznacza to, że granica to nie twardy mur. To bardziej membrana. Treść wyrenderowana na serwerze może przepływać przez Client Components za pomocą wzorca children. Zagłębimy się w to w sekcji o kompozycji.
Wzorce pobierania danych#
RSC fundamentalnie zmienia pobieranie danych. Koniec z useEffect + useState + stanami ładowania dla danych znanych w momencie renderowania. Ale nowe wzorce mają swoje pułapki.
Podstawowe fetch z cachowaniem#
W Server Component po prostu robisz fetch. Next.js rozszerza globalny fetch, dodając cachowanie:
// app/products/page.tsx
export default async function ProductsPage() {
// Cached by default — same URL returns cached result
const res = await fetch("https://api.example.com/products");
const products = await res.json();
return (
<div>
{products.map((product: Product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}Kontroluj zachowanie cachowania jawnie:
// Revalidate every 60 seconds (ISR-like behavior)
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 60 },
});
// No caching — always fresh data
const res = await fetch("https://api.example.com/user/profile", {
cache: "no-store",
});
// Cache with tags for on-demand revalidation
const res = await fetch("https://api.example.com/products", {
next: { tags: ["products"] },
});Następnie możesz rewalidować po tagu z Server Action:
"use server";
import { revalidateTag } from "next/cache";
export async function refreshProducts() {
revalidateTag("products");
}Równoległe pobieranie danych#
Najczęstszy błąd wydajnościowy, który widzę: sekwencyjne pobieranie danych, gdy równoległe byłoby wystarczające.
Źle — sekwencyjne (wodospady):
// app/dashboard/page.tsx — DON'T DO THIS
export default async function Dashboard() {
const user = await getUser(); // 200ms
const orders = await getOrders(); // 300ms
const notifications = await getNotifications(); // 150ms
// Total: 650ms — each waits for the previous one
return (
<div>
<UserInfo user={user} />
<OrderList orders={orders} />
<NotificationBell notifications={notifications} />
</div>
);
}Dobrze — równolegle:
// app/dashboard/page.tsx — DO THIS
export default async function Dashboard() {
// All three fire simultaneously
const [user, orders, notifications] = await Promise.all([
getUser(), // 200ms
getOrders(), // 300ms (runs in parallel)
getNotifications(), // 150ms (runs in parallel)
]);
// Total: ~300ms — limited by the slowest request
return (
<div>
<UserInfo user={user} />
<OrderList orders={orders} />
<NotificationBell notifications={notifications} />
</div>
);
}Jeszcze lepiej — równolegle z niezależnymi granicami Suspense:
// app/dashboard/page.tsx — BEST
import { Suspense } from "react";
export default function Dashboard() {
// Note: this component is NOT async — it delegates to children
return (
<div>
<Suspense fallback={<UserInfoSkeleton />}>
<UserInfo />
</Suspense>
<Suspense fallback={<OrderListSkeleton />}>
<OrderList />
</Suspense>
<Suspense fallback={<NotificationsSkeleton />}>
<Notifications />
</Suspense>
</div>
);
}
// Each component fetches its own data
async function UserInfo() {
const user = await getUser();
return <div>{user.name}</div>;
}
async function OrderList() {
const orders = await getOrders();
return <ul>{orders.map(o => <li key={o.id}>{o.title}</li>)}</ul>;
}
async function Notifications() {
const notifications = await getNotifications();
return <span>({notifications.length})</span>;
}Ten ostatni wzorzec jest najpotężniejszy, bo każda sekcja ładuje się niezależnie. Użytkownik widzi treść w miarę, jak staje się dostępna, nie w trybie „wszystko albo nic". Szybkie sekcje nie czekają na wolne.
Deduplikacja żądań#
Next.js automatycznie deduplikuje wywołania fetch z tym samym URL i opcjami podczas pojedynczego przebiegu renderowania. Oznacza to, że nie musisz wyciągać pobierania danych w górę, żeby uniknąć nadmiarowych żądań:
// Both of these components can fetch the same URL
// and Next.js will only make ONE actual HTTP request
async function Header() {
const user = await fetch("/api/user").then(r => r.json());
return <nav>Welcome, {user.name}</nav>;
}
async function Sidebar() {
// Same URL — automatically deduped, not a second request
const user = await fetch("/api/user").then(r => r.json());
return <aside>Role: {user.role}</aside>;
}Ważne zastrzeżenie: to działa tylko z fetch. Jeśli używasz ORM lub klienta bazy danych bezpośrednio, musisz użyć funkcji cache() z React:
import { cache } from "react";
import { db } from "@/lib/database";
// Wrap your data function with cache()
// Now multiple calls in the same render = one actual query
export const getUser = cache(async (userId: string) => {
return db.user.findUnique({ where: { id: userId } });
});cache() deduplikuje na czas życia pojedynczego żądania serwerowego. To nie jest trwały cache — to memoizacja per-żądanie. Po zakończeniu żądania zcachowane wartości są zbierane przez garbage collector.
Wzorce kompozycji komponentów#
Tu RSC staje się naprawdę eleganckie, kiedy zrozumiesz wzorce. I naprawdę zagmatwane, dopóki ich nie zrozumiesz.
Wzorzec „children jako otwór"#
To najważniejszy wzorzec kompozycji w RSC i potrzebowałem tygodni, żeby go w pełni docenić. Oto problem: masz Client Component, który dostarcza układ lub interaktywność, i chcesz renderować Server Components wewnątrz niego.
Nie możesz importować Server Component do pliku Client Component. W momencie, gdy dodasz "use client", wszystko w tym module jest po stronie klienta. Ale możesz przekazać Server Components jako children:
// src/components/Sidebar.tsx — Client Component
"use client";
import { useState } from "react";
export function Sidebar({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(true);
return (
<aside className={isOpen ? "w-64" : "w-0"}>
<button onClick={() => setIsOpen(!isOpen)}>
{isOpen ? "Close" : "Open"}
</button>
{isOpen && (
<div className="sidebar-content">
{/* These children can be Server Components! */}
{children}
</div>
)}
</aside>
);
}// app/dashboard/layout.tsx — Server Component
import { Sidebar } from "@/components/Sidebar";
import { NavigationLinks } from "@/components/NavigationLinks"; // Server Component
import { UserProfile } from "@/components/UserProfile"; // Server Component
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex">
<Sidebar>
{/* These are Server Components, passed through a Client Component */}
<UserProfile />
<NavigationLinks />
</Sidebar>
<main>{children}</main>
</div>
);
}Dlaczego to działa? Ponieważ Server Components (UserProfile, NavigationLinks) są renderowane najpierw na serwerze, a ich wynik (RSC payload) jest przekazywany jako children do Client Component. Client Component nigdy nie musi wiedzieć, że to były Server Components — po prostu otrzymuje wstępnie wyrenderowane węzły React.
Pomyśl o children jako „otworze" w Client Component, przez który może przepływać treść wyrenderowana na serwerze.
Przekazywanie Server Components jako propsów#
Wzorzec children uogólnia się na dowolny prop, który akceptuje React.ReactNode:
// src/components/TabLayout.tsx — Client Component
"use client";
import { useState } from "react";
interface TabLayoutProps {
tabs: { label: string; content: React.ReactNode }[];
}
export function TabLayout({ tabs }: TabLayoutProps) {
const [activeTab, setActiveTab] = useState(0);
return (
<div>
<div className="tab-bar" role="tablist">
{tabs.map((tab, i) => (
<button
key={i}
role="tab"
aria-selected={i === activeTab}
onClick={() => setActiveTab(i)}
className={i === activeTab ? "active" : ""}
>
{tab.label}
</button>
))}
</div>
<div role="tabpanel">{tabs[activeTab].content}</div>
</div>
);
}// app/settings/page.tsx — Server Component
import { TabLayout } from "@/components/TabLayout";
import { ProfileSettings } from "./ProfileSettings"; // Server Component — can fetch data
import { BillingSettings } from "./BillingSettings"; // Server Component — can fetch data
import { SecuritySettings } from "./SecuritySettings"; // Server Component — can fetch data
export default function SettingsPage() {
return (
<TabLayout
tabs={[
{ label: "Profile", content: <ProfileSettings /> },
{ label: "Billing", content: <BillingSettings /> },
{ label: "Security", content: <SecuritySettings /> },
]}
/>
);
}Każdy komponent ustawień może być asynchronicznym Server Component, który pobiera własne dane. Client Component (TabLayout) tylko obsługuje przełączanie zakładek. To niesamowicie potężny wzorzec.
Asynchroniczne Server Components#
Server Components mogą być async. To ogromna sprawa, bo oznacza, że pobieranie danych dzieje się podczas renderowania, nie jako efekt uboczny:
// This is valid and beautiful
async function BlogPost({ slug }: { slug: string }) {
const post = await db.post.findUnique({ where: { slug } });
if (!post) return notFound();
const author = await db.user.findUnique({ where: { id: post.authorId } });
return (
<article>
<h1>{post.title}</h1>
<p>By {author.name}</p>
<div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
</article>
);
}Client Components nie mogą być async. Jeśli spróbujesz uczynić komponent "use client" asynchronicznym, React wyrzuci błąd. To twarde ograniczenie.
Granice Suspense: prymityw strumieniowania#
Suspense to sposób na uzyskanie strumieniowania w RSC. Bez granic Suspense cała strona czeka na najwolniejszy komponent asynchroniczny. Z nimi każda sekcja strumieniuje się niezależnie:
// app/page.tsx
import { Suspense } from "react";
import { HeroSection } from "@/components/HeroSection";
import { ProductGrid } from "@/components/ProductGrid";
import { ReviewCarousel } from "@/components/ReviewCarousel";
import { RecommendationEngine } from "@/components/RecommendationEngine";
export default function HomePage() {
return (
<main>
{/* Static — renders immediately */}
<HeroSection />
{/* Fast data — shows quickly */}
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid />
</Suspense>
{/* Medium speed — shows when ready */}
<Suspense fallback={<ReviewsSkeleton />}>
<ReviewCarousel />
</Suspense>
{/* Slow (ML-powered) — shows last, doesn't block the rest */}
<Suspense fallback={<RecommendationsSkeleton />}>
<RecommendationEngine />
</Suspense>
</main>
);
}Użytkownik widzi HeroSection natychmiast, potem ProductGrid się strumieniuje, potem recenzje, potem rekomendacje. Każda granica Suspense to niezależny punkt strumieniowania.
Kiedy użyć czego: drzewo decyzyjne#
Po zbudowaniu kilku produkcyjnych aplikacji z RSC wypracowałem jasny framework decyzyjny. Oto faktyczny proces myślowy, przez który przechodzę dla każdego komponentu:
Zacznij od Server Components (domyślnie)#
Każdy komponent powinien być Server Component, chyba że istnieje konkretny powód, żeby nie mógł. To najważniejsza zasada.
Uczyń go Client Component, kiedy:#
1. Używa API dostępnych tylko w przeglądarce
"use client";
// window, document, navigator, localStorage, etc.
function GeoLocation() {
const [coords, setCoords] = useState<GeolocationCoordinates | null>(null);
useEffect(() => {
navigator.geolocation.getCurrentPosition(
(pos) => setCoords(pos.coords)
);
}, []);
return coords ? <p>Lat: {coords.latitude}</p> : <p>Loading location...</p>;
}2. Używa hooków React wymagających stanu lub efektów
useState, useEffect, useReducer, useRef (dla mutowalnych refów), useContext — każdy z nich wymaga "use client".
3. Dołącza handlery zdarzeń
onClick, onChange, onSubmit, onMouseEnter — każde interaktywne zachowanie oznacza stronę kliencką.
4. Używa bibliotek po stronie klienta
Framer Motion, React Hook Form, Zustand, React Query (do pobierania danych po stronie klienta), każda biblioteka wykresów renderująca interaktywnie do canvas lub SVG.
Rzeczywista decyzja w praktyce#
Oto konkretny przykład. Buduję stronę produktu:
ProductPage (Server)
├── ProductBreadcrumbs (Server) — statyczna nawigacja, brak interaktywności
├── ProductImageGallery (Client) — zoom, swipe, lightbox
├── ProductInfo (Server)
│ ├── ProductTitle (Server) — po prostu tekst
│ ├── ProductPrice (Server) — sformatowana liczba, brak interakcji
│ └── AddToCartButton (Client) — onClick, zarządza stanem koszyka
├── ProductDescription (Server) — wyrenderowany markdown
├── Suspense
│ └── RelatedProducts (Server) — asynchroniczne pobieranie danych, wolne API
└── Suspense
└── ProductReviews (Server)
└── ReviewForm (Client) — formularz z walidacją
Zwróć uwagę na wzorzec: szkielet strony i części intensywne pod względem danych to Server Components. Interaktywne wyspy (ImageGallery, AddToCartButton, ReviewForm) to Client Components. Wolne sekcje (RelatedProducts, ProductReviews) są opakowane w Suspense.
Częste błędy (popełniłem je wszystkie)#
Błąd 1: Uczynienie wszystkiego Client Component#
Droga najmniejszego oporu przy migracji z Pages Router lub Create React App to wrzucenie "use client" na wszystko. Działa! Nic się nie psuje! Wysyłasz też całe drzewo komponentów jako JavaScript i nie czerpiesz żadnych korzyści z RSC.
Rozwiązanie: Zacznij od Server Components. Dodawaj "use client" dopiero, gdy kompilator ci powie, że jest potrzebne (bo użyłeś hooka lub handlera zdarzeń). Pchaj "use client" jak najniżej w drzewie.
Błąd 2: Prop drilling przez granicę#
Każdy fragment danych, który przekazujesz przez granicę, jest serializowany do RSC payload. Przekażesz ten sam obiekt pięć razy? Jest w payloadzie pięć razy. Widziałem RSC payload rosnące do megabajtów z tego powodu.
Rozwiązanie: Użyj kompozycji. Przekazuj Server Components jako children zamiast danych jako propsy:
// GOOD: Server Components fetch their own data, pass through as children
// app/page.tsx (Server)
export default function Page() {
return (
<ClientShell>
<UserInfo /> {/* Server Component — fetches its own data */}
<Settings /> {/* Server Component — fetches its own data */}
<ClientWidget>
<UserAvatar /> {/* Server Component — fetches its own data */}
</ClientWidget>
</ClientShell>
);
}Błąd 3: Nieużywanie Suspense#
Bez Suspense TTFB twojej strony jest ograniczony przez najwolniejsze pobieranie danych. Miałem stronę dashboardu, która ładowała się 4 sekundy, bo jedno zapytanie analityczne było wolne, chociaż reszta danych strony była gotowa w 200ms.
// GOOD: analytics loads independently
export default function Dashboard() {
return (
<div>
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
<Suspense fallback={<AnalyticsSkeleton />}>
<Analytics /> {/* Takes 4s but doesn't block the rest */}
</Suspense>
</div>
);
}Błąd 4: Błędy serializacji w runtime#
Ten jest szczególnie bolesny, bo często łapiesz go dopiero na produkcji. Przekazujesz coś nieserializowalnego przez granicę i dostajesz kryptyczny błąd.
Częste przyczyny:
- Przekazywanie obiektów Date (użyj
.toISOString()) - Przekazywanie Map lub Set (konwertuj na tablice/obiekty)
- Przekazywanie instancji klas z ORM (użyj
.toJSON()lub spread do zwykłych obiektów) - Przekazywanie funkcji (przenieś logikę do Client Component lub użyj Server Actions)
- Przekazywanie wyników modeli Prisma z polami
Decimal(konwertuj nanumberlubstring)
Błąd 5: Używanie kontekstu do wszystkiego#
useContext działa tylko w Client Components. Widziałem ludzi, którzy uczynili całą aplikację Client Component tylko po to, żeby użyć kontekstu motywu.
Rozwiązanie: Dla motywów i innego globalnego stanu używaj zmiennych CSS ustawianych po stronie serwera lub funkcji cookies() / headers().
Dla naprawdę klienckiego stanu (tokeny auth, koszyki, dane real-time) stwórz cienki provider Client Component na odpowiednim poziomie — nie w korzeniu.
Strategia cachowania#
Cachowanie w Next.js 15+ zostało znacznie uproszczone w porównaniu z wcześniejszymi wersjami, ale nadal istnieją odrębne warstwy do zrozumienia.
Funkcja cache() (React)#
cache() z React służy do deduplikacji per-żądanie, nie trwałego cachowania:
import { cache } from "react";
export const getCurrentUser = cache(async () => {
const session = await getSession();
if (!session) return null;
return db.user.findUnique({ where: { id: session.userId } });
});
// Call this anywhere in your component tree during a single request.
// Only one actual database query will execute.To jest ograniczone do pojedynczego żądania serwerowego. Kiedy żądanie się kończy, zcachowana wartość znika. To memoizacja, nie cachowanie.
unstable_cache (Next.js)#
Dla trwałego cachowania między żądaniami użyj unstable_cache:
import { unstable_cache } from "next/cache";
const getCachedProducts = unstable_cache(
async (categoryId: string) => {
return db.product.findMany({
where: { categoryId },
include: { images: true },
});
},
["products-by-category"], // cache key prefix
{
revalidate: 3600, // revalidate every hour
tags: ["products"], // for on-demand revalidation
}
);Hierarchia cachowania#
Myśl o cachowaniu w warstwach:
1. React cache() — per-żądanie, w pamięci, automatyczna deduplikacja
2. fetch() cache — między żądaniami, automatyczny dla żądań GET
3. unstable_cache() — między żądaniami, dla operacji nie-fetchowych
4. Full Route Cache — wyrenderowany HTML cachowany przy budowaniu/rewalidacji
5. Router Cache (klient) — cache w przeglądarce odwiedzonych tras
Server Actions: most powrotny#
Server Actions uzupełniają historię RSC. To sposób, w jaki Client Components komunikują się z powrotem do serwera bez route API.
// app/actions/newsletter.ts
"use server";
import { db } from "@/lib/database";
import { z } from "zod";
const emailSchema = z.string().email();
export async function subscribeToNewsletter(formData: FormData) {
const email = formData.get("email");
const result = emailSchema.safeParse(email);
if (!result.success) {
return { error: "Invalid email address" };
}
try {
await db.subscriber.create({
data: { email: result.data },
});
return { success: true };
} catch (e) {
if (e instanceof Error && e.message.includes("Unique constraint")) {
return { error: "Already subscribed" };
}
return { error: "Something went wrong" };
}
}Server Actions to odpowiedź na „jak zmutować dane?" w świecie RSC. Zastępują większość route API dla submitów formularzy, mutacji i efektów ubocznych.
Kluczowe zasady dla Server Actions:
- Zawsze waliduj wejście (funkcja jest wywoływalna z klienta — traktuj ją jak endpoint API)
- Zawsze zwracaj serializowalne dane
- Działają na serwerze, więc możesz uzyskać dostęp do baz danych, systemów plików, sekretów
- Mogą wywołać
revalidatePath()lubrevalidateTag(), żeby zaktualizować zcachowane dane po mutacjach
Podsumowanie#
React Server Components to nie tylko nowe API. To inny sposób myślenia o tym, gdzie kod się wykonuje, gdzie żyją dane i jak elementy się łączą. Zmiana modelu mentalnego jest realna i wymaga czasu.
Ale kiedy to kliknie — kiedy przestaniesz walczyć z granicą i zaczniesz projektować wokół niej — kończysz z aplikacjami, które są szybsze, prostsze i łatwiejsze w utrzymaniu niż to, co budowaliśmy wcześniej. Mniej JavaScriptu trafia do klienta. Pobieranie danych nie wymaga ceremonii. Drzewo komponentów staje się architekturą.
Przejście jest tego warte. Wiedz tylko, że pierwsze projekty będą niekomfortowe, i to normalne. Nie masz trudności, bo RSC jest złe. Masz trudności, bo to jest naprawdę nowe.
Zacznij od Server Components wszędzie. Pchaj "use client" w dół do liści. Opakowuj wolne rzeczy w Suspense. Pobieraj dane tam, gdzie są renderowane. Komponuj przez children.
To cały playbook. Reszta to szczegóły.