Vai al contenuto
·30 min di lettura

React Server Components: Modelli Mentali, Pattern e Insidie

La guida pratica ai React Server Components che avrei voluto avere quando ho iniziato. Modelli mentali, pattern reali, il problema del confine, e gli errori che ho fatto io perché tu non debba farli.

Condividi:X / TwitterLinkedIn

Ho passato i primi tre mesi con i React Server Components convinto di averli capiti. Avevo letto le RFC, guardato i talk alle conferenze, costruito un paio di app demo. Ero sicuro di me e sbagliavo praticamente su tutto.

Il problema non è che gli RSC siano complicati. È che il modello mentale è genuinamente diverso da qualsiasi cosa abbiamo fatto prima in React, e tutti — me compreso — provano a farlo rientrare nella vecchia scatola. "È come l'SSR." Non lo è. "È come PHP." Ci si avvicina, ma no. "Sono semplicemente componenti che girano sul server." Tecnicamente vero, praticamente inutile.

Quello che segue è tutto ciò che dovevo davvero sapere, scritto nel modo in cui avrei voluto che qualcuno me lo avesse spiegato. Non la versione teorica. Quella in cui stai fissando un errore di serializzazione alle 11 di sera e hai bisogno di capire perché.

Il Modello Mentale Che Funziona Davvero#

Dimentica per un momento tutto quello che sai sul rendering di React. Ecco il nuovo quadro.

Nel React tradizionale (lato client), l'intero albero dei componenti viene spedito al browser come JavaScript. Il browser lo scarica, lo analizza, lo esegue e renderizza il risultato. Ogni componente — che sia un form interattivo da 200 righe o un paragrafo di testo statico — passa attraverso la stessa pipeline.

I React Server Components dividono tutto in due mondi:

Server Components girano sul server. Vengono eseguiti una volta, producono il loro output e inviano il risultato al client — non il codice. Il browser non vede mai la funzione del componente, non scarica mai le sue dipendenze, non lo ri-renderizza mai.

Client Components funzionano come il React tradizionale. Vengono spediti al browser, si idratano, mantengono lo stato, gestiscono gli eventi. Sono il React che già conosci.

L'intuizione chiave che ho impiegato un tempo imbarazzante per interiorizzare: i Server Components sono il default. In Next.js App Router, ogni componente è un Server Component a meno che tu non lo includa esplicitamente nel client con "use client". Questo è l'opposto di ciò a cui siamo abituati, e cambia il modo in cui pensi alla composizione.

La Cascata del Rendering#

Ecco cosa succede davvero quando un utente richiede una pagina:

1. La richiesta arriva al server
2. Il server esegue i Server Components dall'alto verso il basso
3. Quando un Server Component incontra un confine "use client",
   si ferma — quel sottoalbero verrà renderizzato sul client
4. I Server Components producono l'RSC Payload (un formato speciale)
5. L'RSC Payload viene trasmesso in streaming al client
6. Il client renderizza i Client Components, inserendoli
   nell'albero renderizzato dal server
7. L'idratazione rende interattivi i Client Components

Il passo 4 è dove vive la maggior parte della confusione. L'RSC Payload non è HTML. È un formato di streaming speciale che descrive l'albero dei componenti — cosa ha renderizzato il server, dove il client deve prendere il controllo e quali props passare attraverso il confine.

Ha un aspetto più o meno così (semplificato):

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}]]}]

Non devi memorizzare questo formato. Ma capire che esiste — che c'è un livello di serializzazione tra server e client — ti risparmierà ore di debug. Ogni volta che ottieni un errore "Props must be serializable", è perché qualcosa che stai passando non può sopravvivere a questa traduzione.

Cosa Significa Davvero "Gira sul Server"#

Quando dico che un Server Component "gira sul server", lo intendo letteralmente. La funzione del componente viene eseguita in Node.js (o Edge runtime). Questo significa che puoi:

tsx
// app/dashboard/page.tsx — questo è un Server Component di 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");
 
  // Query diretta al database. Nessuna API route necessaria.
  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>
  );
}

Nessun useEffect. Nessuna gestione dello stato di caricamento. Nessuna API route per collegare le cose. Il componente è il livello dati. Questa è la più grande vittoria degli RSC ed è la cosa che mi ha messo più a disagio all'inizio, perché continuavo a pensare "ma dov'è la separazione?"

La separazione è il confine "use client". Tutto ciò che sta sopra è server. Tutto ciò che sta sotto è client. Quella è la tua architettura.

Il Confine Server/Client#

È qui che la comprensione della maggior parte delle persone si blocca, ed è dove ho passato la maggior parte del mio tempo di debug nei primi mesi.

La Direttiva "use client"#

La direttiva "use client" in cima a un file segna tutto ciò che viene esportato da quel file come Client Component. È un'annotazione a livello di modulo, non a livello di componente.

tsx
// src/components/Counter.tsx
"use client";
 
import { useState } from "react";
 
// L'intero file è ora "territorio client"
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>
  );
}
 
// Anche QUESTO è un Client Component perché è nello stesso file
export function ResetButton({ onReset }: { onReset: () => void }) {
  return <button onClick={onReset}>Reset</button>;
}

Errore comune: mettere "use client" in un barrel file (index.ts) che ri-esporta tutto. Complimenti, hai appena reso l'intera libreria di componenti lato client. Ho visto team spedire accidentalmente 200KB di JavaScript in questo modo.

Cosa Attraversa il Confine#

Ecco la regola che ti salverà: tutto ciò che attraversa il confine server-client deve essere serializzabile in JSON.

Cosa è serializzabile:

  • Stringhe, numeri, booleani, null, undefined
  • Array e oggetti semplici (contenenti valori serializzabili)
  • Date (serializzate come stringhe ISO)
  • Server Components (come JSX — ci arriviamo)
  • FormData
  • Typed arrays, ArrayBuffer

Cosa NON è serializzabile:

  • Funzioni (inclusi gli event handler)
  • Classi (istanze di classi personalizzate)
  • Symbol
  • Nodi DOM
  • Stream (nella maggior parte dei contesti)

Questo significa che non puoi fare così:

tsx
// 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}
      // ERRORE: Le funzioni non sono serializzabili
      onItemClick={(id) => console.log(id)}
      // ERRORE: Le istanze di classi non sono serializzabili
      formatter={new Intl.NumberFormat("en-US")}
    />
  );
}

La soluzione non è rendere la pagina un Client Component. La soluzione è spingere l'interattività verso il basso e il data fetching verso l'alto:

tsx
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList";
 
export default async function Page() {
  const items = await getItems();
 
  // Passa solo dati serializzabili
  return <ItemList items={items} locale="en-US" />;
}
tsx
// 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);
 
  // Crea il formatter lato client
  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>
  );
}

L'Equivoco delle "Isole"#

Inizialmente pensavo ai Client Components come "isole" — piccoli pezzi interattivi in un mare di contenuto renderizzato dal server. È parzialmente corretto ma manca un dettaglio cruciale: un Client Component può renderizzare Server Components se gli vengono passati come children o props.

Questo significa che il confine non è un muro rigido. È più come una membrana. Il contenuto renderizzato dal server può fluire attraverso i Client Components tramite il pattern children. Approfondiremo nella sezione sulla composizione.

Pattern di Data Fetching#

Gli RSC cambiano il data fetching in modo fondamentale. Niente più useEffect + useState + stati di caricamento per dati noti al momento del rendering. Ma i nuovi pattern hanno le loro insidie.

Fetch Base con Caching#

In un Server Component, fai semplicemente fetch. Next.js estende il fetch globale per aggiungere il caching:

tsx
// app/products/page.tsx
export default async function ProductsPage() {
  // Cachato di default — lo stesso URL restituisce il risultato cachato
  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>
  );
}

Controlla il comportamento del caching in modo esplicito:

tsx
// Rivalidazione ogni 60 secondi (comportamento simile a ISR)
const res = await fetch("https://api.example.com/products", {
  next: { revalidate: 60 },
});
 
// Nessun caching — dati sempre freschi
const res = await fetch("https://api.example.com/user/profile", {
  cache: "no-store",
});
 
// Cache con tag per rivalidazione on-demand
const res = await fetch("https://api.example.com/products", {
  next: { tags: ["products"] },
});

Poi puoi rivalidare per tag da una Server Action:

tsx
"use server";
 
import { revalidateTag } from "next/cache";
 
export async function refreshProducts() {
  revalidateTag("products");
}

Data Fetching Parallelo#

L'errore di performance più comune che vedo: data fetching sequenziale quando quello parallelo andrebbe benissimo.

Male — sequenziale (waterfalls):

tsx
// app/dashboard/page.tsx — NON FARE COSÌ
export default async function Dashboard() {
  const user = await getUser();           // 200ms
  const orders = await getOrders();       // 300ms
  const notifications = await getNotifications(); // 150ms
  // Totale: 650ms — ciascuno aspetta il precedente
 
  return (
    <div>
      <UserInfo user={user} />
      <OrderList orders={orders} />
      <NotificationBell notifications={notifications} />
    </div>
  );
}

Bene — parallelo:

tsx
// app/dashboard/page.tsx — FAI COSÌ
export default async function Dashboard() {
  // Tutte e tre partono simultaneamente
  const [user, orders, notifications] = await Promise.all([
    getUser(),        // 200ms
    getOrders(),      // 300ms (gira in parallelo)
    getNotifications(), // 150ms (gira in parallelo)
  ]);
  // Totale: ~300ms — limitato dalla richiesta più lenta
 
  return (
    <div>
      <UserInfo user={user} />
      <OrderList orders={orders} />
      <NotificationBell notifications={notifications} />
    </div>
  );
}

Ancora meglio — parallelo con Suspense boundary indipendenti:

tsx
// app/dashboard/page.tsx — IL MIGLIORE
import { Suspense } from "react";
 
export default function Dashboard() {
  // Nota: questo componente NON è async — delega ai figli
  return (
    <div>
      <Suspense fallback={<UserInfoSkeleton />}>
        <UserInfo />
      </Suspense>
      <Suspense fallback={<OrderListSkeleton />}>
        <OrderList />
      </Suspense>
      <Suspense fallback={<NotificationsSkeleton />}>
        <Notifications />
      </Suspense>
    </div>
  );
}
 
// Ogni componente recupera i propri dati
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>;
}

Quest'ultimo pattern è il più potente perché ogni sezione si carica in modo indipendente. L'utente vede il contenuto man mano che diventa disponibile, non tutto-o-niente. Le sezioni veloci non aspettano quelle lente.

Deduplicazione delle Richieste#

Next.js deduplica automaticamente le chiamate fetch con lo stesso URL e le stesse opzioni durante un singolo passo di rendering. Questo significa che non devi sollevare il data fetching per evitare richieste ridondanti:

tsx
// Entrambi questi componenti possono fare fetch sullo stesso URL
// e Next.js farà solo UNA vera richiesta HTTP
 
async function Header() {
  const user = await fetch("/api/user").then(r => r.json());
  return <nav>Welcome, {user.name}</nav>;
}
 
async function Sidebar() {
  // Stesso URL — deduplicato automaticamente, non una seconda richiesta
  const user = await fetch("/api/user").then(r => r.json());
  return <aside>Role: {user.role}</aside>;
}

Avvertenza importante: questo funziona solo con fetch. Se stai usando un ORM o un client per il database direttamente, devi usare la funzione cache() di React:

tsx
import { cache } from "react";
import { db } from "@/lib/database";
 
// Avvolgi la tua funzione dati con cache()
// Ora chiamate multiple nello stesso rendering = una sola query effettiva
export const getUser = cache(async (userId: string) => {
  return db.user.findUnique({ where: { id: userId } });
});

cache() deduplica per la durata di una singola richiesta server. Non è una cache persistente — è una memoizzazione per-request. Dopo che la richiesta è terminata, i valori cachati vengono raccolti dal garbage collector.

Pattern di Composizione dei Componenti#

È qui che gli RSC diventano genuinamente eleganti, una volta che capisci i pattern. E genuinamente confusi, finché non li capisci.

Il Pattern "Children Come Buco"#

Questo è il pattern di composizione più importante negli RSC e mi ci sono volute settimane per apprezzarlo pienamente. Ecco il problema: hai un Client Component che fornisce un qualche layout o interattività, e vuoi renderizzare Server Components al suo interno.

Non puoi importare un Server Component in un file Client Component. Nel momento in cui aggiungi "use client", tutto in quel modulo è lato client. Ma puoi passare Server Components come children:

tsx
// 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">
          {/* Questi children possono essere Server Components! */}
          {children}
        </div>
      )}
    </aside>
  );
}
tsx
// 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>
        {/* Questi sono Server Components, passati attraverso un Client Component */}
        <UserProfile />
        <NavigationLinks />
      </Sidebar>
      <main>{children}</main>
    </div>
  );
}

Perché funziona? Perché i Server Components (UserProfile, NavigationLinks) vengono renderizzati prima sul server, poi il loro output (il payload RSC) viene passato come children al Client Component. Il Client Component non ha bisogno di sapere che erano Server Components — riceve semplicemente nodi React pre-renderizzati.

Pensa ai children come un "buco" nel Client Component dove il contenuto renderizzato dal server può fluire.

Passare Server Components come Props#

Il pattern children si generalizza a qualsiasi prop che accetti React.ReactNode:

tsx
// 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>
  );
}
tsx
// app/settings/page.tsx — Server Component
import { TabLayout } from "@/components/TabLayout";
import { ProfileSettings } from "./ProfileSettings";   // Server Component — può recuperare dati
import { BillingSettings } from "./BillingSettings";    // Server Component — può recuperare dati
import { SecuritySettings } from "./SecuritySettings";  // Server Component — può recuperare dati
 
export default function SettingsPage() {
  return (
    <TabLayout
      tabs={[
        { label: "Profile", content: <ProfileSettings /> },
        { label: "Billing", content: <BillingSettings /> },
        { label: "Security", content: <SecuritySettings /> },
      ]}
    />
  );
}

Ogni componente delle impostazioni può essere un Server Component async che recupera i propri dati. Il Client Component (TabLayout) gestisce solo il cambio di tab. Questo è un pattern incredibilmente potente.

Server Components Async#

I Server Components possono essere async. Questo è un cambiamento enorme perché significa che il data fetching avviene durante il rendering, non come effetto collaterale:

tsx
// Questo è valido e bellissimo
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>
  );
}

I Client Components non possono essere async. Se provi a rendere un componente "use client" async, React lancerà un errore. Questo è un vincolo rigido.

Suspense Boundaries: La Primitiva dello Streaming#

Suspense è il modo in cui ottieni lo streaming negli RSC. Senza Suspense boundary, l'intera pagina aspetta il componente async più lento. Con esse, ogni sezione viene trasmessa in modo indipendente:

tsx
// 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>
      {/* Statico — si renderizza immediatamente */}
      <HeroSection />
 
      {/* Dati veloci — appare rapidamente */}
      <Suspense fallback={<ProductGridSkeleton />}>
        <ProductGrid />
      </Suspense>
 
      {/* Velocità media — appare quando è pronto */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <ReviewCarousel />
      </Suspense>
 
      {/* Lento (basato su ML) — appare per ultimo, non blocca il resto */}
      <Suspense fallback={<RecommendationsSkeleton />}>
        <RecommendationEngine />
      </Suspense>
    </main>
  );
}

L'utente vede HeroSection istantaneamente, poi ProductGrid arriva in streaming, poi le recensioni, poi i suggerimenti. Ogni Suspense boundary è un punto di streaming indipendente.

Annidare le Suspense boundary è valido e utile:

tsx
<Suspense fallback={<DashboardSkeleton />}>
  <Dashboard>
    <Suspense fallback={<ChartSkeleton />}>
      <RevenueChart />
    </Suspense>
    <Suspense fallback={<TableSkeleton />}>
      <RecentTransactions />
    </Suspense>
  </Dashboard>
</Suspense>

Se Dashboard è veloce ma RevenueChart è lento, la Suspense esterna si risolve per prima (mostrando la shell della dashboard), e la Suspense interna per il grafico si risolve dopo.

Error Boundaries con Suspense#

Abbina Suspense con error.tsx per UI resilienti:

tsx
// app/dashboard/error.tsx — Client Component (deve esserlo)
"use client";
 
export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="p-8 text-center">
      <h2>Something went wrong loading the dashboard</h2>
      <p className="text-gray-500">{error.message}</p>
      <button onClick={reset} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded">
        Try again
      </button>
    </div>
  );
}

Il file error.tsx avvolge automaticamente il segmento di route corrispondente in un React Error Boundary. Se qualsiasi Server Component in quel segmento lancia un errore, viene mostrata la UI di errore invece di far crashare l'intera pagina.

Quando Usare Cosa: L'Albero Decisionale#

Dopo aver costruito diverse app in produzione con gli RSC, ho stabilito un framework decisionale chiaro. Ecco il processo mentale effettivo che seguo per ogni componente:

Parti con i Server Components (il Default)#

Ogni componente dovrebbe essere un Server Component a meno che non ci sia un motivo specifico per cui non può esserlo. Questa è la regola più importante in assoluto.

Rendilo un Client Component Quando:#

1. Usa API solo del browser

tsx
"use client";
// window, document, navigator, localStorage, ecc.
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. Usa hook React che richiedono stato o effetti

useState, useEffect, useReducer, useRef (per ref mutabili), useContext — qualsiasi di questi richiede "use client".

tsx
"use client";
function SearchBar() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 300);
 
  // Questo componente DEVE essere un Client Component perché
  // usa useState e gestisce l'input dell'utente
  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Search..."
    />
  );
}

3. Collega event handler

onClick, onChange, onSubmit, onMouseEnter — qualsiasi comportamento interattivo significa lato client.

4. Usa librerie lato client

Framer Motion, React Hook Form, Zustand, React Query (per fetching lato client), qualsiasi libreria di grafici che renderizza su canvas o SVG in modo interattivo.

Tienilo come Server Component Quando:#

  • Mostra solo dati (nessuna interazione utente)
  • Recupera dati da un database o API
  • Accede a risorse backend (file system, variabili d'ambiente con segreti)
  • Importa dipendenze pesanti che il client non ha bisogno (parser markdown, evidenziatori di sintassi, librerie di date per la formattazione)
  • Renderizza contenuto statico o semi-statico

La Decisione Reale in Pratica#

Ecco un esempio concreto. Sto costruendo una pagina prodotto:

ProductPage (Server)
├── ProductBreadcrumbs (Server) — navigazione statica, nessuna interattività
├── ProductImageGallery (Client) — zoom, swipe, lightbox
├── ProductInfo (Server)
│   ├── ProductTitle (Server) — solo testo
│   ├── ProductPrice (Server) — numero formattato, nessuna interazione
│   └── AddToCartButton (Client) — onClick, gestisce lo stato del carrello
├── ProductDescription (Server) — markdown renderizzato
├── Suspense
│   └── RelatedProducts (Server) — fetch dati async, API lenta
└── Suspense
    └── ProductReviews (Server)
        └── ReviewForm (Client) — form con validazione

Nota il pattern: la shell della pagina e le parti pesanti di dati sono Server Components. Le isole interattive (ImageGallery, AddToCartButton, ReviewForm) sono Client Components. Le sezioni lente (RelatedProducts, ProductReviews) sono avvolte in Suspense.

Questo non è teorico. È come appaiono davvero i miei alberi di componenti.

Errori Comuni (Li Ho Fatti Tutti)#

Errore 1: Rendere Tutto un Client Component#

La via di minor resistenza quando si migra da Pages Router o Create React App è schiaffare "use client" dappertutto. Funziona! Niente si rompe! Stai anche spedendo l'intero albero dei componenti come JavaScript e non stai ottenendo nessun beneficio dagli RSC.

Ho visto codebase dove il layout root ha "use client". A quel punto stai letteralmente eseguendo un'app React lato client con passi aggiuntivi.

La soluzione: Parti con i Server Components. Aggiungi "use client" solo quando il compilatore ti dice che è necessario (perché hai usato un hook o un event handler). Spingi "use client" il più in basso possibile nell'albero.

Errore 2: Prop Drilling Attraverso il Confine#

tsx
// MALE: recuperare dati in un Server Component, poi passarli attraverso
// più Client Components
 
// app/page.tsx (Server)
export default async function Page() {
  const user = await getUser();
  const settings = await getSettings();
  const theme = await getTheme();
 
  return (
    <ClientShell user={user} settings={settings} theme={theme}>
      <ClientContent user={user} settings={settings}>
        <ClientWidget user={user} />
      </ClientContent>
    </ClientShell>
  );
}

Ogni pezzo di dato che passi attraverso il confine viene serializzato nel payload RSC. Passi lo stesso oggetto cinque volte? È nel payload cinque volte. Ho visto payload RSC gonfiarsi fino a megabyte per questo motivo.

La soluzione: Usa la composizione. Passa Server Components come children invece di passare dati come props:

tsx
// BENE: i Server Components recuperano i propri dati, passano attraverso come children
 
// app/page.tsx (Server)
export default function Page() {
  return (
    <ClientShell>
      <UserInfo />      {/* Server Component — recupera i propri dati */}
      <Settings />      {/* Server Component — recupera i propri dati */}
      <ClientWidget>
        <UserAvatar />  {/* Server Component — recupera i propri dati */}
      </ClientWidget>
    </ClientShell>
  );
}

Errore 3: Non Usare Suspense#

Senza Suspense, il Time to First Byte (TTFB) della tua pagina è limitato dal tuo fetch più lento. Avevo una pagina dashboard che impiegava 4 secondi per caricarsi perché una query di analytics era lenta, anche se il resto dei dati della pagina era pronto in 200ms.

tsx
// MALE: tutto aspetta tutto
export default async function Dashboard() {
  const stats = await getStats();         // 200ms
  const chart = await getChartData();     // 300ms
  const analytics = await getAnalytics(); // 4000ms ← blocca tutto
 
  return (
    <div>
      <Stats data={stats} />
      <Chart data={chart} />
      <Analytics data={analytics} />
    </div>
  );
}
tsx
// BENE: analytics si carica in modo indipendente
export default function Dashboard() {
  return (
    <div>
      <Suspense fallback={<StatsSkeleton />}>
        <Stats />
      </Suspense>
      <Suspense fallback={<ChartSkeleton />}>
        <Chart />
      </Suspense>
      <Suspense fallback={<AnalyticsSkeleton />}>
        <Analytics /> {/* Impiega 4s ma non blocca il resto */}
      </Suspense>
    </div>
  );
}

Errore 4: Errori di Serializzazione a Runtime#

Questo è particolarmente doloroso perché spesso non lo scopri fino alla produzione. Passi qualcosa di non serializzabile attraverso il confine e ottieni un errore criptico:

Error: Only plain objects, and a few built-ins, can be passed to
Client Components from Server Components. Classes or null prototypes
are not supported.

Colpevoli comuni:

  • Passare oggetti Date (usa .toISOString() invece)
  • Passare Map o Set (converti in array/oggetti)
  • Passare istanze di classi dagli ORM (usa .toJSON() o fai spread in oggetti semplici)
  • Passare funzioni (sposta la logica nel Client Component o usa Server Actions)
  • Passare risultati di modelli Prisma con campi Decimal (converti in number o string)
tsx
// MALE
const user = await prisma.user.findUnique({ where: { id } });
// user potrebbe avere campi non serializzabili (Decimal, BigInt, ecc.)
return <ClientProfile user={user} />;
 
// BENE
const user = await prisma.user.findUnique({ where: { id } });
const serializedUser = {
  id: user.id,
  name: user.name,
  email: user.email,
  balance: user.balance.toNumber(), // Decimal → number
  createdAt: user.createdAt.toISOString(), // Date → string
};
return <ClientProfile user={serializedUser} />;

Errore 5: Usare Context per Tutto#

useContext funziona solo nei Client Components. Se provi a usare un context React in un Server Component, non funzionerà. Ho visto persone rendere l'intera app un Client Component solo per usare un context del tema.

La soluzione: Per i temi e altro stato globale, usa variabili CSS impostate lato server, oppure usa le funzioni cookies() / headers():

tsx
// app/layout.tsx (Server Component)
import { cookies } from "next/headers";
 
export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const cookieStore = await cookies();
  const theme = cookieStore.get("theme")?.value ?? "light";
 
  return (
    <html data-theme={theme} className={theme === "dark" ? "dark" : ""}>
      <body>{children}</body>
    </html>
  );
}

Per stato genuinamente lato client (token di autenticazione, carrelli della spesa, dati in tempo reale), crea un sottile Client Component provider al livello appropriato — non alla radice:

tsx
// src/providers/CartProvider.tsx
"use client";
 
import { createContext, useContext, useState } from "react";
 
const CartContext = createContext<CartContextType | null>(null);
 
export function CartProvider({ children }: { children: React.ReactNode }) {
  const [items, setItems] = useState<CartItem[]>([]);
 
  return (
    <CartContext.Provider value={{ items, setItems }}>
      {children}
    </CartContext.Provider>
  );
}
 
export function useCart() {
  const context = useContext(CartContext);
  if (!context) throw new Error("useCart must be used within CartProvider");
  return context;
}
tsx
// app/shop/layout.tsx (Server Component)
import { CartProvider } from "@/providers/CartProvider";
 
export default function ShopLayout({ children }: { children: React.ReactNode }) {
  // CartProvider è un Client Component, ma i children fluiscono come contenuto server
  return <CartProvider>{children}</CartProvider>;
}

Errore 6: Ignorare l'Impatto sulle Dimensioni del Bundle#

Una delle più grandi vittorie degli RSC è che il codice dei Server Components non arriva mai al client. Ma devi pensarci attivamente. Se hai un componente che usa un parser markdown da 50KB e mostra solo contenuto renderizzato — quello dovrebbe essere un Server Component. Il parser resta sul server, e solo l'output HTML va al client.

tsx
// Server Component — marked resta sul server
import { marked } from "marked"; // libreria da 50KB — non arriva mai al client
 
async function BlogContent({ slug }: { slug: string }) {
  const post = await getPost(slug);
  const html = marked(post.markdown);
 
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

Se rendessi questo un Client Component, marked verrebbe spedito al browser. Per niente. L'utente scaricherebbe 50KB di JavaScript solo per renderizzare contenuto che avrebbe potuto essere HTML fin dall'inizio.

Controlla il tuo bundle con @next/bundle-analyzer. I risultati potrebbero sorprenderti.

Strategia di Caching#

Il caching in Next.js 15+ è stato significativamente semplificato rispetto alle versioni precedenti, ma ci sono ancora livelli distinti da capire.

La Funzione cache() (React)#

La funzione cache() di React serve per la deduplicazione per-request, non per il caching persistente:

tsx
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 } });
});
 
// Chiamalo ovunque nel tuo albero di componenti durante una singola richiesta.
// Solo una vera query al database verrà eseguita.

Questo è limitato a una singola richiesta server. Quando la richiesta è terminata, il valore cachato è sparito. È memoizzazione, non caching.

unstable_cache (Next.js)#

Per il caching persistente tra le richieste, usa unstable_cache (il nome è stato "unstable" per sempre, ma funziona benissimo in produzione):

tsx
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"], // prefisso chiave cache
  {
    revalidate: 3600, // rivalida ogni ora
    tags: ["products"], // per rivalidazione on-demand
  }
);
 
// Utilizzo in un Server Component
async function ProductGrid({ categoryId }: { categoryId: string }) {
  const products = await getCachedProducts(categoryId);
  return <Grid items={products} />;
}

Per invalidare:

tsx
"use server";
 
import { revalidateTag } from "next/cache";
 
export async function onProductUpdate() {
  revalidateTag("products");
}

Rendering Statico vs Dinamico#

Next.js decide se una route è statica o dinamica in base a cosa usi al suo interno:

Statico (renderizzato al build time, cachato):

  • Nessuna funzione dinamica (cookies(), headers(), searchParams)
  • Tutte le chiamate fetch hanno il caching abilitato
  • Nessun export const dynamic = "force-dynamic"

Dinamico (renderizzato per ogni richiesta):

  • Usa cookies(), headers(), o searchParams
  • Usa fetch con cache: "no-store"
  • Ha export const dynamic = "force-dynamic"
  • Usa connection() o after() da next/server

Puoi controllare quali route sono statiche vs dinamiche eseguendo next build — mostra una legenda in basso:

Route (app)                    Size     First Load JS
┌ ○ /                          5.2 kB   92 kB
├ ○ /about                     1.1 kB   88 kB
├ ● /blog/[slug]               2.3 kB   89 kB
├ λ /dashboard                 4.1 kB   91 kB
└ λ /api/products              0 B      87 kB

○ Static   ● SSG   λ Dynamic

La Gerarchia del Caching#

Pensa al caching a livelli:

1. React cache()          — per-request, in-memory, deduplicazione automatica
2. fetch() cache          — cross-request, automatico per richieste GET
3. unstable_cache()       — cross-request, per operazioni non-fetch
4. Full Route Cache       — HTML renderizzato cachato al build/rivalidazione
5. Router Cache (client)  — cache nel browser delle route visitate

Ogni livello serve uno scopo diverso. Non hai sempre bisogno di tutti, ma capire quale è attivo aiuta a fare debug dei problemi "perché i miei dati non si aggiornano?".

Una Strategia di Caching Reale#

Ecco cosa faccio effettivamente in produzione:

tsx
// lib/data/products.ts
 
import { cache } from "react";
import { unstable_cache } from "next/cache";
import { db } from "@/lib/database";
 
// Deduplicazione per-request: chiamalo più volte in un rendering,
// solo una query DB viene eseguita
export const getProductById = cache(async (id: string) => {
  return db.product.findUnique({
    where: { id },
    include: { category: true, images: true },
  });
});
 
// Cache cross-request: i risultati persistono tra le richieste,
// rivalidazione ogni 5 minuti o on-demand tramite tag
export const getPopularProducts = unstable_cache(
  async () => {
    return db.product.findMany({
      orderBy: { salesCount: "desc" },
      take: 20,
      include: { images: true },
    });
  },
  ["popular-products"],
  { revalidate: 300, tags: ["products"] }
);
 
// Nessun caching: sempre fresco (per dati specifici dell'utente)
export const getUserCart = cache(async (userId: string) => {
  // cache() qui serve solo per la deduplicazione per-request, non per la persistenza
  return db.cart.findUnique({
    where: { userId },
    include: { items: { include: { product: true } } },
  });
});

La regola empirica: i dati pubblici che cambiano raramente ottengono unstable_cache. I dati specifici dell'utente ottengono cache() solo per la deduplicazione. I dati in tempo reale non ottengono nessun caching.

Server Actions: Il Ponte di Ritorno#

Le Server Actions meritano la propria sezione perché completano la storia degli RSC. Sono il modo in cui i Client Components comunicano con il server senza API routes.

tsx
// 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" };
  }
}
tsx
// src/components/NewsletterForm.tsx
"use client";
 
import { useActionState } from "react";
import { subscribeToNewsletter } from "@/app/actions/newsletter";
 
export function NewsletterForm() {
  const [state, formAction, isPending] = useActionState(
    async (_prev: { error?: string; success?: boolean } | null, formData: FormData) => {
      return subscribeToNewsletter(formData);
    },
    null
  );
 
  return (
    <form action={formAction}>
      <input
        type="email"
        name="email"
        required
        placeholder="you@example.com"
        disabled={isPending}
      />
      <button type="submit" disabled={isPending}>
        {isPending ? "Subscribing..." : "Subscribe"}
      </button>
      {state?.error && <p className="text-red-500">{state.error}</p>}
      {state?.success && <p className="text-green-500">Subscribed!</p>}
    </form>
  );
}

Le Server Actions sono la risposta a "come muto i dati?" nel mondo RSC. Sostituiscono la maggior parte delle API routes per invii di form, mutazioni ed effetti collaterali.

Regole chiave per le Server Actions:

  • Valida sempre l'input (la funzione è invocabile dal client — trattala come un endpoint API)
  • Restituisci sempre dati serializzabili
  • Girano sul server, quindi puoi accedere a database, file system, segreti
  • Possono chiamare revalidatePath() o revalidateTag() per aggiornare i dati cachati dopo le mutazioni

Pattern di Migrazione#

Se hai un'app React esistente (Pages Router, Create React App, Vite), il passaggio agli RSC non deve essere una riscrittura. Ecco come lo affronto.

Passo 1: Mappa i Tuoi Componenti#

Passa in rassegna il tuo albero di componenti e classifica tutto:

Componente           Stato?  Effetti?  Eventi?  → Decisione
─────────────────────────────────────────────────────────
Header               No      No        No       → Server
NavigationMenu       No      No        Sì       → Client (toggle mobile)
Footer               No      No        No       → Server
BlogPost             No      No        No       → Server
SearchBar            Sì      Sì        Sì       → Client
ProductCard          No      No        Sì       → Client (onClick) o split
UserAvatar           No      No        No       → Server
CommentForm          Sì      Sì        Sì       → Client
Sidebar              Sì      No        Sì       → Client (toggle chiusura)
MarkdownRenderer     No      No        No       → Server (grande vantaggio dipendenze)
DataTable            Sì      Sì        Sì       → Client (ordinamento, filtro)

Passo 2: Sposta il Data Fetching Verso l'Alto#

Il più grande cambiamento architetturale è spostare il data fetching da useEffect nei componenti a Server Components async. È qui che vive il vero sforzo di migrazione.

Prima:

tsx
// Vecchio pattern — data fetching in un Client Component
"use client";
 
function ProductPage({ id }: { id: string }) {
  const [product, setProduct] = useState<Product | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    fetch(`/api/products/${id}`)
      .then(res => res.json())
      .then(data => setProduct(data))
      .catch(err => setError(err.message))
      .finally(() => setLoading(false));
  }, [id]);
 
  if (loading) return <Spinner />;
  if (error) return <Error message={error} />;
  if (!product) return <NotFound />;
 
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton productId={product.id} />
    </div>
  );
}

Dopo:

tsx
// Nuovo pattern — il Server Component recupera, il Client Component interagisce
 
// app/products/[id]/page.tsx (Server Component)
import { db } from "@/lib/database";
import { notFound } from "next/navigation";
import { AddToCartButton } from "@/components/AddToCartButton";
 
export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await db.product.findUnique({ where: { id } });
 
  if (!product) notFound();
 
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton productId={product.id} price={product.price} />
    </div>
  );
}

Nessuna gestione dello stato di caricamento. Nessuno stato di errore. Nessun useEffect. Il framework gestisce tutto attraverso Suspense ed error boundary.

Passo 3: Dividi i Componenti ai Confini dell'Interazione#

Molti componenti sono prevalentemente statici con una piccola parte interattiva. Dividili:

Prima (un unico grande Client Component):

tsx
"use client";
 
function ProductCard({ product }: { product: Product }) {
  const [isFavorite, setIsFavorite] = useState(false);
 
  return (
    <div className="card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.description}</p>
      <span>${product.price}</span>
      <button onClick={() => setIsFavorite(!isFavorite)}>
        {isFavorite ? "♥" : "♡"}
      </button>
    </div>
  );
}

Dopo (Server Component con una piccola isola Client):

tsx
// ProductCard.tsx (Server Component)
import { FavoriteButton } from "./FavoriteButton";
 
function ProductCard({ product }: { product: Product }) {
  return (
    <div className="card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.description}</p>
      <span>${product.price}</span>
      <FavoriteButton productId={product.id} />
    </div>
  );
}
tsx
// FavoriteButton.tsx (Client Component)
"use client";
 
import { useState } from "react";
 
export function FavoriteButton({ productId }: { productId: string }) {
  const [isFavorite, setIsFavorite] = useState(false);
 
  return (
    <button
      onClick={() => setIsFavorite(!isFavorite)}
      aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
    >
      {isFavorite ? "♥" : "♡"}
    </button>
  );
}

L'immagine, il titolo, la descrizione e il prezzo sono ora renderizzati dal server. Solo il piccolo pulsante dei preferiti è un Client Component. Meno JavaScript, caricamenti più veloci.

Passo 4: Converti le API Routes in Server Actions#

Se hai API routes che esistono esclusivamente per servire il tuo frontend (non consumatori esterni), la maggior parte può diventare Server Actions:

Prima:

tsx
// app/api/contact/route.ts
export async function POST(request: Request) {
  const body = await request.json();
  // valida, salva nel DB, invia email
  return Response.json({ success: true });
}
 
// Client component
const response = await fetch("/api/contact", {
  method: "POST",
  body: JSON.stringify(formData),
});

Dopo:

tsx
// app/actions/contact.ts
"use server";
 
export async function submitContactForm(formData: FormData) {
  // valida, salva nel DB, invia email
  return { success: true };
}
 
// Client component — chiama direttamente la funzione
import { submitContactForm } from "@/app/actions/contact";

Mantieni le API routes per: webhook, consumatori API esterni, qualsiasi cosa che necessiti di header HTTP personalizzati o codici di stato, upload di file con streaming.

Passo 5: Testare i Componenti RSC#

Testare i Server Components richiede un approccio leggermente diverso dato che possono essere async:

tsx
// __tests__/ProductPage.test.tsx
import { render, screen } from "@testing-library/react";
import ProductPage from "@/app/products/[id]/page";
 
// Mock del database
vi.mock("@/lib/database", () => ({
  db: {
    product: {
      findUnique: vi.fn(),
    },
  },
}));
 
describe("ProductPage", () => {
  it("renders product details", async () => {
    const { db } = await import("@/lib/database");
    vi.mocked(db.product.findUnique).mockResolvedValue({
      id: "1",
      name: "Test Product",
      description: "A great product",
      price: 29.99,
    });
 
    // I Server Components sono async — attendi il JSX
    const jsx = await ProductPage({
      params: Promise.resolve({ id: "1" })
    });
    render(jsx);
 
    expect(screen.getByText("Test Product")).toBeInTheDocument();
    expect(screen.getByText("A great product")).toBeInTheDocument();
  });
});

La differenza chiave: fai await della funzione componente perché è async. Poi renderizzi il JSX risultante. Tutto il resto funziona come il tradizionale React Testing Library.

Struttura dei File per Progetti RSC#

Ecco la struttura su cui ho converguto dopo diversi progetti. È opinionata, ma funziona:

src/
├── app/
│   ├── layout.tsx              ← Root layout (Server Component)
│   ├── page.tsx                ← Home page (Server Component)
│   ├── (marketing)/            ← Gruppo di route per pagine marketing
│   │   ├── about/page.tsx
│   │   └── pricing/page.tsx
│   ├── (app)/                  ← Gruppo di route per app autenticata
│   │   ├── layout.tsx          ← Shell dell'app con controllo auth
│   │   ├── dashboard/
│   │   │   ├── page.tsx
│   │   │   ├── loading.tsx     ← Fallback Suspense per questa route
│   │   │   └── error.tsx       ← Error boundary per questa route
│   │   └── settings/
│   │       └── page.tsx
│   └── actions/                ← Server Actions
│       ├── auth.ts
│       └── products.ts
├── components/
│   ├── ui/                     ← Primitive UI condivise (prevalentemente Client)
│   │   ├── Button.tsx          ← "use client"
│   │   ├── Dialog.tsx          ← "use client"
│   │   └── Card.tsx            ← Server Component (solo stile)
│   └── features/               ← Componenti specifici per feature
│       ├── products/
│       │   ├── ProductGrid.tsx     ← Server (async, recupera dati)
│       │   ├── ProductCard.tsx     ← Server (presentazionale)
│       │   ├── ProductSearch.tsx   ← Client (useState, input)
│       │   └── AddToCart.tsx       ← Client (onClick, mutazione)
│       └── blog/
│           ├── PostList.tsx        ← Server (async, recupera dati)
│           ├── PostContent.tsx     ← Server (rendering markdown)
│           └── CommentSection.tsx  ← Client (form, tempo reale)
├── lib/
│   ├── data/                   ← Livello accesso dati
│   │   ├── products.ts         ← Query DB avvolte con cache()
│   │   └── users.ts
│   ├── database.ts
│   └── utils.ts
└── providers/
    ├── ThemeProvider.tsx        ← "use client" — avvolge le parti che necessitano del tema
    └── CartProvider.tsx         ← "use client" — avvolge solo la sezione shop

I principi chiave:

  • I Server Components non hanno una direttiva — sono il default
  • I Client Components sono esplicitamente marcati — si vede a colpo d'occhio
  • Il data fetching vive in lib/data/ — avvolto con cache() o unstable_cache
  • Le Server Actions vivono in app/actions/ — co-locate con l'app, chiaramente separate
  • I provider avvolgono il minimo necessario — non l'intera app

La Conclusione#

I React Server Components non sono solo una nuova API. Sono un modo diverso di pensare a dove gira il codice, dove vivono i dati e come i pezzi si collegano. Il cambio di modello mentale è reale e richiede tempo.

Ma una volta che scatta — una volta che smetti di combattere il confine e inizi a progettare attorno ad esso — ti ritrovi con app più veloci, più semplici e più mantenibili di quelle che costruivamo prima. Meno JavaScript arriva al client. Il data fetching non richiede cerimonia. L'albero dei componenti diventa l'architettura.

La transizione ne vale la pena. Sappi solo che i primi progetti ti sembreranno scomodi, ed è normale. Non stai faticando perché gli RSC sono brutti. Stai faticando perché sono genuinamente nuovi.

Parti con i Server Components ovunque. Spingi "use client" verso le foglie. Avvolgi le cose lente in Suspense. Recupera i dati dove vengono renderizzati. Componi attraverso i children.

Questo è l'intero playbook. Tutto il resto sono dettagli.

Articoli correlati