Vai al contenuto principale
Frontend

React Server Components: Architettura Streaming, Costi Prestazionali e Pattern di Migrazione

7 min lettura
LD
Lucio Durán
Engineering Manager & AI Solutions Architect
Disponibile anche in: English, Español

Ciclo di Vita di una Richiesta RSC

Ecco cosa succede effettivamente quando un browser richiede una pagina con RSC:

  1. Il server riceve la richiesta e inizia a renderizzare l'albero dei componenti
  2. I Server Components si eseguono sul server — possono fare await di query al database, letture file, chiamate API direttamente
  3. Quando il server incontra un boundary 'use client', smette di renderizzare quel sottoalbero e emette un riferimento
  4. Il server fa lo streaming dell'output come formato speciale chiamato RSC payload
  5. Il runtime React lato client riceve questo payload, idrata i client components e assembla tutto

L'intuizione chiave: l'RSC payload viene streamato. Il server non aspetta che tutti i dati si risolvano prima di inviare il primo byte. I Suspense boundaries definiscono i chunk dello streaming.

Il Costo di 'use client'

Ecco cosa sfugge alla gente: 'use client' non significa solo "questo componente gira sul client." Significa questo componente E ogni componente che importa diventa parte del bundle client. È una dichiarazione di boundary, non un toggle per componente.

// Questo singolo 'use client' trascina tutto nel bundle client
'use client';

import { HeavyChartLibrary } from 'heavy-charts'; // 200KB
import { DateFormatter } from './utils'; // 2KB
import { UserAvatar } from './UserAvatar'; // 5KB

export function Dashboard({ data }) {
 const [filter, setFilter] = useState('all');
 // ...
}

La soluzione è decomposizione chirurgica:

// ServerDashboard.jsx (server component)
import { HeavyChartLibrary } from 'heavy-charts'; // resta sul server!
import { DateFormatter } from './utils'; // resta sul server!
import { DashboardFilter } from './DashboardFilter'; // boundary client

export async function ServerDashboard() {
 const data = await db.metrics.getAll(); // accesso diretto al DB

 return (
 <div>
 <h1>Dashboard</h1>
 <DashboardFilter />
 <HeavyChartLibrary data={data} interactive={false} />
 <p>Ultimo aggiornamento: {DateFormatter.relative(data.updatedAt)}</p>
 </div>
 );
}
// DashboardFilter.jsx
'use client';

import { useState } from 'react';

export function DashboardFilter() {
 const [filter, setFilter] = useState('all');
 return (
 <select value={filter} onChange={(e) => setFilter(e.target.value)}>
 <option value="all">Tutti</option>
 <option value="active">Attivi</option>
 <option value="archived">Archiviati</option>
 </select>
 );
}

Quella HeavyChartLibrary non arriva mai al client. In una migrazione di produzione reale, questo pattern ha tagliato il JS client-side della dashboard principale da 340KB a 89KB (gzipped). Il First Contentful Paint è migliorato di 1.2 secondi su connessione 4G mediana.

Streaming SSR con Suspense: Il Setup Pratico

// app/page.jsx (server component di default in Next.js App Router)
import { Suspense } from 'react';

export default async function ProductPage({ params }) {
 const product = await getProduct(params.id);

 return (
 <main>
 <h1>{product.name}</h1>
 <p>{product.description}</p>
 <price>{product.price}</price>

 <Suspense fallback={<ReviewsSkeleton />}>
 <ProductReviews productId={params.id} />
 </Suspense>

 <Suspense fallback={<RecommendationsSkeleton />}>
 <RecommendationsPanel productId={params.id} />
 </Suspense>
 </main>
 );
}

async function ProductReviews({ productId }) {
 const reviews = await getReviews(productId);

 return (
 <section>
 <h2>Recensioni ({reviews.length})</h2>
 {reviews.map(r => (
 <ReviewCard key={r.id} review={r} />
 ))}
 </section>
 );
}

Cosa vede l'utente:

  1. Istantaneo: Nome prodotto, descrizione, prezzo + placeholder skeleton
  2. ~800ms: Le recensioni appaiono, sostituendo lo skeleton
  3. ~1500ms: Il pannello raccomandazioni si riempie

Server Actions: RPC Integrato per React

// actions.js
'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export async function createComment(formData) {
 const content = formData.get('content');
 const postId = formData.get('postId');

 if (!content || content.length > 5000) {
 return { error: 'Il commento deve essere tra 1 e 5000 caratteri' };
 }

 const user = await getCurrentUser();
 if (!user) {
 redirect('/login');
 }

 await db.comments.create({
 content,
 postId,
 authorId: user.id,
 createdAt: new Date(),
 });

 revalidatePath(`/posts/${postId}`);
 return { success: true };
}
// CommentForm.jsx
'use client';

import { useActionState } from 'react';
import { createComment } from './actions';

export function CommentForm({ postId }) {
 const [state, formAction, isPending] = useActionState(createComment, null);

 return (
 <form action={formAction}>
 <input type="hidden" name="postId" value={postId} />
 <textarea
 name="content"
 placeholder="Scrivi un commento..."
 disabled={isPending}
 />
 <button type="submit" disabled={isPending}>
 {isPending ? 'Pubblicando...' : 'Pubblica'}
 </button>
 {state?.error && <p className="error">{state.error}</p>}
 </form>
 );
}

La cosa bella: questo form funziona senza JavaScript. Il <form action={formAction}> fa progressive enhancement — senza JS, invia come un POST normale. Con JS, React lo intercetta, manda la richiesta in background e aggiorna la UI ottimisticamente.

Tre form sono stati messi in produzione con questo pattern e la storia del progressive enhancement è reale. Su connessioni lente dove il JS non ha ancora caricato, gli utenti possono comunque inviare.

Pattern di Data Fetching Lato Server

Dimenticate useEffect per il data fetching. Dimenticate react-query per i caricamenti iniziali:

async function UserProfile({ userId }) {
 const [user, posts, stats] = await Promise.all([
 db.users.findById(userId),
 db.posts.findByAuthor(userId, { limit: 10 }),
 analytics.getUserStats(userId),
 ]);

 return (
 <div>
 <h1>{user.name}</h1>
 <StatsBanner stats={stats} />
 <PostList posts={posts} />
 <Suspense fallback={<ActivitySkeleton />}>
 <ActivityFeed userId={userId} />
 </Suspense>
 </div>
 );
}

Nessun loading state per il contenuto principale. Nessun waterfall request. Nessun boilerplate useEffect + useState + isLoading.

Il Promise.all è importante — senza di esso, le tre query girano in sequenza. Questo errore appare costantemente nelle code review.

Strategia di Migrazione: L'Approccio Strangler Fig

Non riscrivete la vostra app. Spostate gradualmente i componenti al server:

Passo 1: Identificate le dipendenze client-side più pesanti con npx @next/bundle-analyzer.

Passo 2: Per ogni componente pesante, chiedetevi: "Questo ha bisogno di interattività?"

Passo 3: Estraete le parti interattive in client components minimali.

// Prima: componente intero client-side
'use client';
import { marked } from 'marked'; // 35KB
import DOMPurify from 'dompurify'; // 20KB

export function Article({ markdown }) {
 const [fontSize, setFontSize] = useState(16);
 const html = DOMPurify.sanitize(marked.parse(markdown));
 return (
 <div style={{ fontSize }}>
 <FontSizeControl value={fontSize} onChange={setFontSize} />
 <div dangerouslySetInnerHTML={{ __html: html }} />
 </div>
 );
}
// Dopo: processing pesante sul server, wrapper interattivo minimo
import { marked } from 'marked'; // non arriva mai al client
import DOMPurify from 'dompurify'; // non arriva mai al client
import { FontSizeWrapper } from './FontSizeWrapper';

export function ArticleContent({ markdown }) {
 const html = DOMPurify.sanitize(marked.parse(markdown));
 return (
 <FontSizeWrapper>
 <div dangerouslySetInnerHTML={{ __html: html }} />
 </FontSizeWrapper>
 );
}

// FontSizeWrapper.jsx (client component — 0.5KB)
'use client';
import { useState } from 'react';

export function FontSizeWrapper({ children }) {
 const [fontSize, setFontSize] = useState(16);
 return (
 <div style={{ fontSize }}>
 <button onClick={() => setFontSize(f => f - 2)}>A-</button>
 <button onClick={() => setFontSize(f => f + 2)}>A+</button>
 {children}
 </div>
 );
}

Metriche di Performance Misurate

Su una pagina prodotto e-commerce reale (Next.js 15, App Router, deployed su Vercel):

Metrica Pages Router (CSR) App Router (RSC + Streaming)
FCP 2.1s 0.8s
LCP 3.4s 1.2s
TTI 4.2s 1.8s
Client JS (gzip) 287KB 112KB
TTFB 180ms 210ms

Il TTFB è leggermente peggiore con RSC perché il server fa più lavoro prima di inviare il primo byte. Ma tutto il resto è drammaticamente migliore.

I Bordi Taglienti

Serialization boundary: Le props passate da server a client components devono essere serializzabili. Niente funzioni, classi, oggetti Date (usate stringhe ISO), Map/Set.

Librerie di terze parti: La maggior parte delle librerie React assume un ambiente client. Se importate una libreria che accede a window o document in un server component, otterrete un errore di build.

Debugging: Il formato RSC payload non è leggibile. Quando qualcosa va storto nel passaggio server-to-client, i messaggi di errore sono criptici.

Quando NON Usare RSC

Non tutto dovrebbe essere un server component. UI altamente interattive — builder drag-and-drop, editor collaborativi real-time, tool basati su canvas — restano migliori come applicazioni client-side. RSC brilla per pagine ricche di contenuto con isole di interattività, che è francamente la maggior parte del web.

Il modello mentale chiave: se si stanno renderizzando dati, usare un server component. Se si sta gestendo stato, usare un client component. Il confine tra loro è dove l'architettura diventa interessante, e azzeccare quel confine è ciò che separa un'app RSC veloce da una lenta che sta semplicemente facendo SSR con passi extra.

reactrscserver-componentsstreamingssrsuspensenext.jsfrontend-architecture

Strumenti menzionati in questo articolo

VercelProva Vercel
NetlifyProva Netlify
Divulgazione: Alcuni link in questo articolo sono link di affiliazione. Se ti registri tramite questi, potrei guadagnare una commissione senza costi aggiuntivi per te. Raccomando solo strumenti che uso e di cui mi fido personalmente.
Condividi
Seguime