Les dues lliçons anteriors han fet servir un model sense explicar-lo. Has escrit 'use client' perquè calia; has acceptat que un component de servidor «no s'envia al navegador» sense veure com és possible; has posat un loading.jsx sabent només que per sota hi ha un <Suspense>; i 10-02 va acabar prometent el patró que resol gairebé tots els casos difícils: una pàgina estàtica amb un forat dinàmic a dins. Aquesta lliçó va a aquest model. I de passada salda un deute explícit del mòdul 8: allà es va dir que «Suspense és molt més que un fallback de càrrega mandrosa i la seva història completa és a 10-03». Aquí és. Entendràs què és un límit de suspensió i què significa exactament que un component «se suspengui», com se suspèn per dades i no només per codi, com el servidor envia l'HTML a trossos, i què són els React Server Components: on s'executa cadascun, què viatja pel cable i què pot i no pot creuar la frontera entre servidor i client. El focus és el model, no les funcions de Next.js.
Contingut
- Què és un límit de suspensió
- Què significa que un component se suspengui
- Imbricar límits: qui mostra què
- Suspense amb
lazy: el que ja sabies - Suspense per a dades: el hook
usede React 19 useSuspenseQuery: Suspense a la SPA de Vite- Streaming de l'HTML
- Streaming a Next.js:
loading.jsxi<Suspense>a mà SuspenseiLimitError: cobrir càrrega i fallada- Transicions: evitar que el
fallbackesborri el que és visible - React Server Components: què són i en què es diferencien del SSR
- L'arbre mixt i la frontera
'use client' - Què creua la frontera i què no
- Col·locar la frontera tan avall com sigui possible
- Accions de servidor:
'use server' - Què canvia respecte als mòduls 5 a 7 i què no
- Què és un límit de suspensió
Un límit de suspensió és un component <Suspense> col·locat a l'arbre. La seva feina és senzilla d'enunciar:
Si algun component per sota meu anuncia que encara no pot renderitzar-se, jo mostro el meu
fallbacken lloc de tot el meu subarbre. Quan aquest component ja pot, mostro el contingut real.
És la mateixa idea que un límit d'error de 04-05, amb dues diferències importants:
LimitError |
<Suspense> |
|
|---|---|---|
| Què captura | Un error llançat a baix | Una espera declarada a baix |
| Estat que mostra | Interfície de fallada | fallback de càrrega |
| És recuperable? | Només amb un reset explícit |
Sí, automàticament en resoldre's |
| Cal escriure'l com a classe? | Sí | No, és un component de React |
I comparteixen la propietat que els fa útils: són declaratius i es col·loquen per zones. No es pregunta «està carregant?» a cada component; es declara una vegada, a dalt, què es mostra mentre la zona no està llesta. Això és exactament el que elimina els if (isPending) return <IndicadorDeCarrega /> escampats per totes bandes que tenia CicloUrbano al mòdul 7.
Dues propietats del fallback que convé fixar des del principi:
- No es perd l'estat del subarbre suspès si ja s'havia muntat. En tornar a suspendre's, React amaga el contingut en lloc de desmuntar-lo, i l'estat es conserva.
- El
fallbackha d'ocupar aproximadament el mateix que el contingut real. Si no, en resoldre's la pàgina fa un salt. És la mateixa raó per la qual a 08-04 preferíemEsqueletPaginaa un text «Carregant…».
- Què significa que un component se suspengui
Aquí hi ha el mecanisme, i val la pena precisar-lo perquè gairebé tota la resta se'n dedueix.
Un component se suspèn quan, durant el seu render, intenta llegir un recurs que encara no està disponible. En lloc de retornar JSX, el que fa és llançar la promesa d'aquest recurs (a React 19, el mecanisme està encapsulat i no l'escrius tu). React captura aquest senyal, atura el render d'aquest subarbre, puja fins al <Suspense> més proper i renderitza el seu fallback. Quan la promesa es resol, React reintenta el render del component, aquesta vegada amb el valor ja disponible.
sequenceDiagram
participant R as React
participant S as Suspense
participant C as Component
participant P as Promesa
R->>C: render()
C->>P: llegir recurs
P-->>C: encara no esta llest
C-->>R: SE SUSPEN (llanca la promesa)
R->>S: mostra el fallback
Note over R: React se subscriu a la promesa
P-->>R: resolta
R->>C: render() una altra vegada
C-->>R: JSX amb les dades
R->>S: substitueix el fallback pel contingut
Quatre conseqüències que solen sorprendre:
- El component es renderitza dues vegades com a mínim: la primera se suspèn, la segona produeix contingut. Per això el render ha de continuar sent pur, tal com es va establir al mòdul 4: no pot tenir efectes secundaris.
- No hi ha estat de càrrega al component. No hi ha
isPending, no hi hauseState(true). Qui decideix què es veu durant l'espera és un ancestre. El component només diu «encara no». - El component ni tan sols sap que ha suspès. No hi ha cap API per preguntar-ho. Això és deliberat: separa la lògica de la presentació de l'espera.
- Un component no pot suspendre's per si sol si crea la promesa en el seu propi render. Aquest és l'error més habitual i el veurem a l'apartat 5: en reintentar, crearia una promesa nova, se suspendria una altra vegada i entraria en bucle infinit.
- Imbricar límits: qui mostra què
Els límits s'imbriquen, i la regla és única: se suspèn el més proper cap amunt. Aquesta és tota la lògica, i defineix amb precisió la zona que se substitueix pel fallback.
Considera la fitxa de bicicleta de l'aparador:
<Suspense fallback={<EsqueletPagina />}>
<Capcalera />
<DadesBicicleta bicicletaId="bici-002" />
<Suspense fallback={<p>Consultant disponibilitat…</p>}>
<DisponibilitatEnViu bicicletaId="bici-002" />
</Suspense>
<Suspense fallback={<p>Carregant estació…</p>}>
<TargetaEstacio estacionId="est-01" />
</Suspense>
</Suspense>flowchart TB
S1["Suspense EXTERIOR<br/>fallback: EsqueletPagina"]
S1 --> CAB["Capcalera"]
S1 --> DB["DadesBicicleta<br/>(pot suspendre's)"]
S1 --> S2["Suspense<br/>fallback: 'Consultant disponibilitat'"]
S1 --> S3["Suspense<br/>fallback: 'Carregant estacio'"]
S2 --> DIS["DisponibilitatEnViu<br/>(pot suspendre's)"]
S3 --> TE["TargetaEstacio<br/>(pot suspendre's)"]
Què passa en cada cas:
| Qui se suspèn | Quina zona se substitueix | Què continua visible |
|---|---|---|
DadesBicicleta |
Tot, fins a la capçalera | Res de la fitxa |
DisponibilitatEnViu |
Només el seu bloc | Capçalera, dades i estació |
TargetaEstacio |
Només el seu bloc | Capçalera, dades i disponibilitat |
| Els tres alhora | Tot (guanya l'exterior) | Res |
D'aquí surt la regla de disseny que governa aquesta tècnica:
Un límit de suspensió defineix una unitat d'espera. Posa límits al voltant de les parts que poden trigar i vols que no bloquegin la resta; deixa fora d'ells el que és ràpid i dona estructura a la pàgina.
Posar-ho tot sota un únic <Suspense> a l'arrel equival a tornar a la pantalla en blanc: l'usuari espera el més lent. Posar un límit al voltant de cada element és l'extrem oposat i produeix una pàgina que apareix a trossets, amb salts constants. El punt correcte sol ser una unitat per bloc de contingut amb sentit propi.
- Suspense amb
lazy: el que ja sabies
lazy: el que ja sabiesAquest és el cas de 08-04, i ara s'entén per què funcionava.
import { lazy, Suspense } from 'react';
const PaginaTaller = lazy(() => import('./pagines/PaginaTaller.jsx'));
<Suspense fallback={<EsqueletPagina />}>
<PaginaTaller />
</Suspense>lazy retorna un component que, en renderitzar-se per primera vegada, comprova si el mòdul ja s'ha descarregat. Si no ho està, dispara l'import() dinàmic i se suspèn amb aquesta promesa. React mostra el fallback, i quan el fragment arriba, reintenta el render amb el component real.
És a dir: lazy no és un mecanisme a part, és el primer consumidor de Suspense. L'espera és per codi. El que ve ara és la mateixa mecànica amb espera per dades.
- Suspense per a dades: el hook
use de React 19
use de React 19React 19 introdueix use, que llegeix el valor d'una promesa durant el render i se suspèn si encara no està resolta.
import { use } from 'react';
function DisponibilitatEnViu({ promesaDisponibilitat }) {
// Si la promesa no esta resolta, aquest component SE SUSPEN aqui.
const disponibilitat = use(promesaDisponibilitat);
return (
<p>
{disponibilitat.lliures} de {disponibilitat.total} unitats disponibles
a {disponibilitat.estacio}
</p>
);
}use trenca deliberadament dues regles dels hooks que es van establir a 04-04, i convé saber-ho:
| Regla dels hooks | La compleix use? |
|---|---|
| Només al nivell superior del component | No: pot anar dins d'un if o d'un bucle |
| Només en components o hooks personalitzats | Sí |
| Mateix ordre a cada render | No aplica |
A més de promeses, use pot llegir un context (use(ContextTema)), cosa que permet consumir-lo condicionalment —quelcom que useContext no admet—.
Ara, el parany que mencionàvem a l'apartat 2. Això entra en bucle infinit:
// INCORRECTE: crea una promesa nova a cada render
function DisponibilitatEnViu({ bicicletaId }) {
const disponibilitat = use(
fetch(`/api/disponibilidad/${bicicletaId}`).then((r) => r.json())
);
return <p>{disponibilitat.lliures} unitats</p>;
}La seqüència del desastre: el render 1 crea la promesa A i se suspèn → A es resol → React reintenta → el render 2 crea la promesa B, diferent, que tampoc està resolta → se suspèn una altra vegada → i així indefinidament.
La promesa s'ha de crear fora del component que la consumeix. Hi ha tres maneres legítimes:
// A) La crea un component de SERVIDOR i la passa com a prop, SENSE await.
// Aquest es el patro de Next.js: el servidor no espera, delega l'espera.
export default async function PaginaFitxaBicicleta({ params }) {
const { bicicletaId } = await params;
const bicicleta = await obtenirBicicleta(bicicletaId); // si que s'espera
const promesaDisponibilitat = obtenirDisponibilitat(bicicletaId); // NO s'espera
return (
<article>
<h1>{bicicleta.model}</h1>
<Suspense fallback={<p>Consultant disponibilitat…</p>}>
<DisponibilitatEnViu promesaDisponibilitat={promesaDisponibilitat} />
</Suspense>
</article>
);
}// B) La memoritza un ancestre de client amb useMemo (us legitim del modul 8).
function PanellDisponibilitat({ bicicletaId }) {
const promesa = useMemo(() => obtenirDisponibilitat(bicicletaId), [bicicletaId]);
return (
<Suspense fallback={<p>Consultant…</p>}>
<DisponibilitatEnViu promesaDisponibilitat={promesa} />
</Suspense>
);
}El patró A és l'important i mereix subratllar-se: el component de servidor no espera la dada lenta; la passa com a promesa a un fill embolcallat en <Suspense> i continua renderitzant. Aquest és, literalment, el patró de «pàgina estàtica amb forat dinàmic» que 10-02 va deixar promès.
useSuspenseQuery: Suspense a la SPA de Vite
useSuspenseQuery: Suspense a la SPA de ViteTot l'anterior no és exclusiu de Next.js. A l'aplicació de gestió amb Vite, TanStack Query ofereix la mateixa integració amb la seva memòria cau, que resol per si sola el problema de la identitat de la promesa.
Compara els dos estils sobre el mateix component:
// Estil del modul 7: l'estat de carrega viu DINS del component.
function LlistaBicicletes() {
const { data, isPending, isError } = useQuery({
queryKey: ['bicicletes'],
queryFn: obtenirBicicletes,
});
if (isPending) return <IndicadorDeCarrega />;
if (isError) return <Avis tipus="error">No s'ha pogut carregar.</Avis>;
return <ul>{data.map((b) => <TargetaBicicleta key={b.id} bicicleta={b} />)}</ul>;
}// Estil amb Suspense: el component nomes coneix el cas d'exit.
import { useSuspenseQuery } from '@tanstack/react-query';
function LlistaBicicletes() {
const { data } = useSuspenseQuery({
queryKey: ['bicicletes'],
queryFn: obtenirBicicletes,
});
return <ul>{data.map((b) => <TargetaBicicleta key={b.id} bicicleta={b} />)}</ul>;
}I l'espera i la fallada es declaren fora, una sola vegada:
// src/pagines/PaginaCataleg.jsx
<LimitError titol="No s'ha pogut carregar el cataleg">
<Suspense fallback={<EsqueletPagina files={5} />}>
<LlistaBicicletes />
</Suspense>
</LimitError>Comparació honesta dels dos enfocaments:
useQuery |
useSuspenseQuery |
|
|---|---|---|
data pot ser undefined |
Sí, cal comprovar-ho | No: sempre hi ha dades |
| Estats de càrrega i error | Dins del component | En límits externs |
| Branques condicionals | Tres (càrrega, error, èxit) | Una |
| Es pot cridar condicionalment? | Sí, amb enabled |
No: sempre s'executa |
| Risc de cascades | Baix | Alt: dos fills germans que consulten en sèrie |
Aquest últim punt és el perill real. Si TargetaBicicleta i PanellReserva fan servir cadascun el seu useSuspenseQuery i estan dins del mateix límit, la segona consulta no arrenca fins que la primera acaba, perquè el segon component no arriba a renderitzar-se. La solució és precarregar a l'ancestre amb queryClient.prefetchQuery o fer servir useSuspenseQueries. És la versió amb Suspense del Promise.all que ja vas veure a 10-01.
- Streaming de l'HTML
Amb SSR clàssic, el servidor fa això: espera totes les dades, renderitza tot l'HTML i l'envia de cop. Si la disponibilitat de bici-002 triga 800 ms, l'usuari mira una pantalla en blanc durant 800 ms. Hem mogut l'espera del navegador al servidor, però l'espera continua allà.
El streaming converteix aquesta resposta única en un flux. El servidor obre la connexió, envia el marc de la pàgina amb els fallback al seu lloc, i continua enviant trossos a mesura que cada <Suspense> es resol, sense tancar la resposta.
sequenceDiagram
participant N as Navegador
participant S as Servidor Next.js
participant API as API
N->>S: GET /bicicletas/bici-002
S->>API: dades de la bicicleta (rapid)
API-->>S: JSON
S-->>N: TROS 1 · capcalera + model + esquelet de disponibilitat
Note over N: Contingut VISIBLE als ~200 ms
S->>API: disponibilitat en viu (lent)
API-->>S: JSON (800 ms despres)
S-->>N: TROS 2 · HTML del bloc + script que el col·loca
Note over N: L'esquelet se substitueix · sense recarregar
S-->>N: fi de la resposta
El detall tècnic que fa que això funcioni i que sol generar incredulitat: el tros 2 arriba fora d'ordre dins de l'HTML, en un <template> al final del document, acompanyat d'un petit script en línia que el mou al forat correcte. És un mecanisme del propi React, no de Next.js, i funciona encara que el JavaScript de l'aplicació no s'hagi carregat encara: l'script en línia són poques línies independents del paquet.
El que guanya l'usuari, en les mètriques de 10-01:
| Mètrica | SSR sense streaming | SSR amb streaming |
|---|---|---|
| TTFB | Espera la dada més lenta | Immediat |
| FCP | Al final de tot | Amb el primer tros |
| LCP | Al final de tot | Tan bon punt arriba el seu bloc |
| El que veu l'usuari | Blanc, i de cop tot | Estructura, i després es completa |
I una implicació menys òbvia: la hidratació també és progressiva. React pot hidratar les parts que ja han arribat sense esperar la resta, i prioritza la zona amb la qual l'usuari interactua. Aquella «vall inquietant» entre veure i poder tocar que vam descriure a 10-01 s'estreny considerablement.
- Streaming a Next.js:
loading.jsx i <Suspense> a mà
loading.jsx i <Suspense> a màAmb el model entès, les dues maneres d'activar-lo a Next.js deixen de ser màgia.
Opció 1: loading.jsx. Next.js embolcalla automàticament el page.jsx d'aquesta carpeta en un <Suspense> el fallback del qual és el que exporti el loading.jsx.
// src/app/bicicletas/[bicicletaId]/loading.jsx
import EsqueletPagina from '@/components/EsqueletPagina';
export default function Carregant() {
return <EsqueletPagina files={3} />;
}És equivalent a escriure això al layout pare:
Gra gruixut: la pàgina sencera espera. Serveix per a la navegació general, però no distingeix el que és ràpid del que és lent.
Opció 2: <Suspense> a mà, que és la que dona el patró bo.
// src/app/bicicletas/[bicicletaId]/page.jsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import EtiquetaEstat from '@/components/EtiquetaEstat';
import DisponibilitatEnViu from '@/components/DisponibilitatEnViu';
const API = 'http://localhost:3001';
async function obtenirBicicleta(id) {
const r = await fetch(`${API}/bicicletas/${id}`, { next: { revalidate: 60 } });
return r.ok ? r.json() : null;
}
// Component de servidor que SI espera. En suspendre's, activa el Suspense de dalt.
async function BlocDisponibilitat({ bicicletaId }) {
const r = await fetch(`${API}/disponibilidad/${bicicletaId}`, { cache: 'no-store' });
const disponibilitat = await r.json();
return (
<p aria-live="polite">
{disponibilitat.lliures} unitats lliures ara mateix a {disponibilitat.estacio}
</p>
);
}
export default async function PaginaFitxaBicicleta({ params }) {
const { bicicletaId } = await params;
const bicicleta = await obtenirBicicleta(bicicletaId);
if (!bicicleta) notFound();
return (
<article>
<h1>{bicicleta.model}</h1>
<EtiquetaEstat estat={bicicleta.estat} />
<p>{bicicleta.preuHora.toFixed(2)} €/h · {bicicleta.tipus}</p>
<Suspense fallback={<p>Consultant disponibilitat…</p>}>
<BlocDisponibilitat bicicletaId={bicicletaId} />
</Suspense>
</article>
);
}Aquí és, ja complet, el patró que 10-02 va deixar pendent:
- Les dades cachejables de la bicicleta es demanen amb
revalidate: 60, així que la major part de la pàgina se serveix prerenderitzada. - La disponibilitat en viu, que obligaria tota la pàgina a ser dinàmica, queda aïllada dins del
<Suspense>. Next.js prerenderitza la resta i injecta aquest forat a cada petició. - Un component
asyncde servidor que espera una dada se suspèn: aquest és el vincle entre l'awaitde 10-01 i el mecanisme d'aquesta lliçó. L'awaitd'un component de servidor és una suspensió.
Resultat: TTFB de CDN, contingut principal immediat, dada en viu exacta, i tot indexable.
Suspense i LimitError: cobrir càrrega i fallada
Suspense i LimitError: cobrir càrrega i fallada<Suspense> cobreix l'espera. No cobreix la fallada: si la petició de disponibilitat retorna un 500, el fallback no es mostra eternament, sinó que l'error puja buscant un límit d'error. Sense un, fa caure tot l'arbre.
Els dos es combinen embolcallant el Suspense amb el LimitError, en aquest ordre:
<LimitError titol="No s'ha pogut consultar la disponibilitat">
<Suspense fallback={<p>Consultant disponibilitat…</p>}>
<BlocDisponibilitat bicicletaId="bici-002" />
</Suspense>
</LimitError>Per què aquest ordre i no el contrari:
| Ordre | Què passa si falla | Què passa mentre carrega |
|---|---|---|
| Error fora, Suspense dins ✅ | L'error captura i substitueix tota la zona, inclòs el fallback |
Es veu el fallback |
| Suspense fora, error dins ❌ | El límit d'error és dins del subarbre suspès: potser ni s'ha muntat | Comportament impredictible |
Els tres estats de la zona queden així, i coincideixen exactament amb els tres de useQuery del mòdul 7, només que declarats fora del component:
flowchart LR
A["Renderitzant"] -->|"se suspen"| B["fallback de Suspense"]
B -->|"promesa resolta"| C["Contingut real"]
B -->|"promesa rebutjada"| D["Interficie de LimitError"]
A -->|"error sincron"| D
D -->|"reset()"| A
A Next.js aquesta parella ja ve muntada per convenció: loading.jsx és el Suspense i error.jsx és el límit d'error del mateix segment, amb Next.js encarregant-se de l'ordre. L'error.jsx ha de portar 'use client', perquè un límit d'error necessita estat i un gestor reset:
// src/app/bicicletas/[bicicletaId]/error.jsx
'use client';
import { useEffect } from 'react';
import { registrarError } from '@/utilitats/monitoritzacio';
export default function ErrorFitxa({ error, reset }) {
useEffect(() => {
registrarError(error, { zona: 'fitxa-bicicleta' });
}, [error]);
return (
<div role="alert">
<h2>No hem pogut mostrar aquesta bicicleta</h2>
<p>{error.message}</p>
<button onClick={reset}>Reintentar</button>
</div>
);
}Reconeixeràs registrarError de utilitats/monitoritzacio.js: és la mateixa funció que feia servir el LimitError del mòdul 4. La infraestructura d'errors es reutilitza tal qual.
- Transicions: evitar que el
fallback esborri el que és visible
fallback esborri el que és visibleHi ha un efecte desagradable que apareix tan bon punt es fa servir Suspense per a dades. L'usuari està veient el catàleg amb les cinc bicicletes, canvia el filtre a «elèctrica», la nova consulta se suspèn… i el catàleg sencer desapareix, substituït per l'esquelet. S'ha canviat contingut útil per un indicador de càrrega: és un retrocés, no una millora.
La solució és useTransition, que ja va aparèixer a 08-03:
'use client';
import { useTransition } from 'react';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
export default function SelectorTipus() {
const [enTransicio, iniciarTransicio] = useTransition();
const router = useRouter();
const rutaActual = usePathname();
const parametres = useSearchParams();
function gestionarCanvi(esdeveniment) {
const tipus = esdeveniment.target.value;
const nous = new URLSearchParams(parametres);
if (tipus === 'todos') nous.delete('tipo');
else nous.set('tipo', tipus);
// Dins de la transicio, React NO substitueix el contingut ja visible.
iniciarTransicio(() => {
router.push(`${rutaActual}?${nous}`);
});
}
return (
<label>
Tipus de bicicleta
<select
value={parametres.get('tipo') ?? 'todos'}
onChange={gestionarCanvi}
disabled={enTransicio}
/>
{enTransicio && <span aria-live="polite">Actualitzant…</span>}
</label>
);
}Què fa exactament iniciarTransicio: marca aquesta actualització com no urgent. Si un component se suspèn dins seu, React manté visible el contingut anterior en lloc de mostrar el fallback, i avisa mitjançant enTransicio perquè el desenvolupador doni un senyal més suau —un indicador petit, una opacitat reduïda, el control desactivat—.
La distinció que cal retenir:
| Situació | Què mostra React |
|---|---|
| Primera càrrega: no hi ha res anterior | El fallback del Suspense |
| Actualització sense transició | El fallback, esborrant el que és visible |
| Actualització dins d'una transició | El contingut anterior + isPending |
Regla pràctica: fallback per a la primera vegada, transició per a les següents. A Next.js, la navegació amb <Link> ja fa servir transicions internament; el cas que cal tractar a mà és el router.push programàtic, com en aquest exemple.
- React Server Components: què són i en què es diferencien del SSR
Arribem al segon bloc de la lliçó. I cal començar desfent una confusió molt estesa: RSC no és SSR amb un altre nom.
- SSR és un moment: renderitzar l'HTML al servidor. Un component renderitzat per SSR també s'executa després al navegador durant la hidratació, i el seu codi forma part del paquet.
- RSC és un lloc: un component que s'executa només al servidor i mai al navegador. El seu codi no s'inclou al paquet.
La taula completa, que és el resum de tot el bloc:
| Component de servidor | Component de client | |
|---|---|---|
| On s'executa | Només al servidor | Al servidor (SSR inicial) i al client |
| Quan s'executa | En el build o en la petició |
A cada render del navegador |
| Què viatja pel cable | El seu resultat (payload RSC) | El seu codi (JavaScript) |
| Suma al paquet? | No, res | Sí |
| S'hidrata? | No: no hi ha res a hidratar | Sí |
| Pot tenir estat? | No (useState, useReducer) |
Sí |
| Pot tenir efectes? | No (useEffect) |
Sí |
| Pot tenir esdeveniments? | No (onClick, onChange) |
Sí |
Pot ser async? |
Sí | No (però pot fer servir use) |
| Pot llegir fitxers o la base de dades? | Sí | No |
Pot llegir secrets (process.env)? |
Sí | No: acabarien al navegador |
Pot fer servir window, localStorage? |
No | Sí |
| Es torna a renderitzar? | Només amb una nova petició o navegació | Amb cada canvi d'estat o props |
La fila que més importa és «què viatja pel cable». Un component de servidor no envia HTML directament al navegador: envia una descripció serialitzada del seu arbre —el payload RSC— que React al client sap reconstruir i fondre amb els components de client. Per això una navegació entre pàgines de Next.js no recarrega la pàgina: el servidor retorna el nou payload i React actualitza l'arbre conservant l'estat de les illes de client.
El que això significa en pes, aplicat a CicloUrbano:
| Component | Tipus | JavaScript enviat al navegador |
|---|---|---|
PaginaFitxaBicicleta |
Servidor | 0 KB |
EtiquetaEstat |
Servidor | 0 KB |
TargetaBicicleta |
Servidor | 0 KB |
| La biblioteca de formatatge de dates que fa servir | Servidor | 0 KB |
SelectorTipus |
Client | ~1 KB + React |
BotoTema |
Client | ~0,8 KB |
Aquesta fila en negreta és l'argument decisiu. Una dependència pesada usada només en un component de servidor —un formatador de Markdown, una biblioteca de ressaltat de sintaxi, un client de base de dades— no arriba mai al navegador. És la solució més radical al problema de mida del mòdul 8: no dividir el codi, sinó no enviar-lo.
- L'arbre mixt i la frontera
'use client'
'use client'Una aplicació real no és tota de servidor ni tota de client: és un arbre mixt on els components de client són illes dins d'un mar de components de servidor.
La directiva 'use client', a la primera línia d'un fitxer, marca el punt d'entrada a la part de client. I aquí hi ha la regla que més confusió genera:
'use client'no marca un component. Marca una frontera. Tot el que aquest mòdul importi —i el que importin les seves importacions— passa també a ser codi de client.
flowchart TB
subgraph SERVIDOR["Zona de SERVIDOR · 0 KB al navegador"]
L["layout.jsx"] --> P["page.jsx"]
P --> LB["LlistaBicicletes"]
LB --> TB["TargetaBicicleta"]
TB --> EE["EtiquetaEstat"]
end
P --> ST["'use client'<br/>SelectorTipus"]
L --> BT["'use client'<br/>BotoTema"]
subgraph CLIENT["Zona de CLIENT · s'empaqueta i s'hidrata"]
ST --> UP["utilitats/parametres.js"]
BT --> CT["contextos/ProveidorTema"]
end
Conseqüència pràctica molt important: posar 'use client' a la plantilla arrel converteix tota l'aplicació en client. Tot l'arbre passa a empaquetar-se, i els avantatges del model desapareixen sense cap avís. És l'error número u de qui comença, i per això l'apartat 14 es dedica a col·locar bé la frontera.
Dues precisions per no exagerar en el sentit contrari:
- Un component de client també es renderitza al servidor per produir l'HTML inicial. «De client» vol dir «a més s'executa al client», no «només al client». Per això les regles d'hidratació de 10-01 li continuen aplicant: res de
localStoragedurant el render. 'use client'no cal repetir-lo a cada fitxer del subarbre. N'hi ha prou de posar-lo al punt d'entrada; el que s'importa hereta la condició. Posar-lo de més no trenca res, però enterboleix on és la frontera real.
- Què creua la frontera i què no
Quan un component de servidor renderitza un de client, les props s'han de serialitzar per viatjar en el payload RSC. D'aquí surt una regla estricta.
| Tipus de prop | Creua? | Nota |
|---|---|---|
string, number, boolean, null, undefined |
✅ | |
| Arrays i objectes plans | ✅ | Si el seu contingut també és serialitzable |
Date, Map, Set, BigInt, TypedArray |
✅ | React els serialitza |
| Promeses | ✅ | El client les consumeix amb use (apartat 5) |
| Funcions | ❌ | Excepte les accions de servidor (apartat 15) |
| Classes i instàncies | ❌ | |
Elements JSX (<p>Hola</p>) |
✅ | Inclòs children: l'excepció clau |
| Símbols | ❌ |
El cas que trenca més codi és el de les funcions. Això no funciona:
// ERROR: no es pot passar una funcio de servidor a un component de client
export default async function PaginaCataleg() {
const bicicletes = await obtenirBicicletes();
function gestionarSeleccio(id) { // aquesta funcio viu al servidor
console.log(id);
}
return <LlistaInteractiva bicicletes={bicicletes} alSeleccionar={gestionarSeleccio} />;
}LlistaInteractiva és de client i gestionarSeleccio és una funció: no hi ha manera de serialitzar-la. React llança un error explícit. La solució és que el gestor es defineixi dins del component de client, que és on té sentit: el servidor no pot reaccionar a un clic.
children: l'excepció que ho canvia tot
Ara la peça més important de l'apartat, i la que sol desbloquejar la comprensió del model.
Sembla que un component de client només pot contenir components de client: si importa un component, aquest component creua al seu costat de la frontera. Però hi ha una via d'escapament:
Un component de servidor es pot passar com a
children(o com a qualsevol prop de tipus JSX) a un component de client.
Funciona perquè qui el renderitza és el pare de servidor, no el component de client. Aquest últim rep el resultat ja renderitzat i es limita a col·locar-lo en un forat. Mai importa el seu codi, així que aquest codi no s'empaqueta.
// src/components/Panell.jsx — DE CLIENT: te estat (plegar/desplegar)
'use client';
import { useState } from 'react';
export default function Panell({ titol, children }) {
const [obert, setObert] = useState(true);
return (
<section>
<button onClick={() => setObert(!obert)} aria-expanded={obert}>
{titol}
</button>
{obert && <div>{children}</div>}
</section>
);
}// src/app/estaciones/[estacionId]/page.jsx — DE SERVIDOR
import Panell from '@/components/Panell';
import LlistaBicicletes from '@/components/LlistaBicicletes'; // de servidor!
export default async function PestanyaFlota({ params }) {
const { estacionId } = await params;
const bicicletes = await obtenirFlota(estacionId);
return (
<Panell titol="Flota aparcada">
{/* LlistaBicicletes es de SERVIDOR i viu dins d'un component de CLIENT */}
<LlistaBicicletes bicicletes={bicicletes} />
</Panell>
);
}El que s'envia al navegador és Panell (1 KB amb el seu useState) i l'HTML ja renderitzat de la llista. LlistaBicicletes, TargetaBicicleta, EtiquetaEstat i la lògica de formatatge es queden al servidor. Amb l'aproximació ingènua —importar LlistaBicicletes dins de Panell.jsx— tot aquest subarbre hauria creuat la frontera.
I fixa't que això no és una tècnica nova: és la composició del mòdul 4, «composició enfront d'herència», amb children com a forat. Aquella lliçó defensava el patró per flexibilitat i desacoblament; en el model RSC passa a tenir a més una conseqüència directa sobre el pes del paquet.
- Col·locar la frontera tan avall com sigui possible
La regla es resumeix en una frase: 'use client' va tan a prop de la interactivitat com sigui possible.
Procediment per aplicar-la a un component qualsevol:
- Fa servir
useState,useReducer,useEffect,useRefo algun hook de client? → client. - Té gestors d'esdeveniments (
onClick,onChange,onSubmit)? → client. - Fa servir APIs del navegador (
window,localStorage,IntersectionObserver)? → client. - Fa servir context? → client (tant el proveïdor com el consumidor).
- Si no és cap de les anteriors → servidor, encara que «sembli» un component normal.
Aplicat al catàleg de CicloUrbano:
| Component | Tipus | Motiu |
|---|---|---|
PaginaCataleg |
Servidor | Només demana dades i compon |
LlistaBicicletes |
Servidor | Només recorre un array |
TargetaBicicleta |
Servidor | Només pinta; l'enllaç és un <Link>, que no necessita estat |
EtiquetaEstat |
Servidor | Només mapeja estat a color i text |
SelectorTipus |
Client | onChange + useRouter |
BotoTema |
Client | useContext + onClick |
MenuUsuari |
Client | Estat d'obertura + onClick fora |
Modal, DialegReserva |
Client | Estat, focus, tecla Escape |
ResumFlota |
Servidor | Càlcul pur sobre les dades |
El cas interessant és TargetaBicicleta. És temptador marcar-la de client perquè «és interactiva»: es pot prémer. Però el que es prem és un <Link>, i <Link> gestiona la seva pròpia interactivitat. La targeta en si no té estat propi.
Si més endavant calgués un botó de «desar als preferits», la solució correcta no és marcar la targeta sencera com a client, sinó extreure el botó:
// src/components/TargetaBicicleta.jsx — SERVIDOR
import Link from 'next/link';
import EtiquetaEstat from './EtiquetaEstat';
import BotoPreferida from './BotoPreferida'; // de client, illa minima
import estils from './TargetaBicicleta.module.css';
export default function TargetaBicicleta({ bicicleta }) {
return (
<article className={estils.targeta}>
<Link href={`/bicicletas/${bicicleta.id}`}>
<h3>{bicicleta.model}</h3>
</Link>
<EtiquetaEstat estat={bicicleta.estat} />
<p>{bicicleta.preuHora.toFixed(2)} €/h</p>
<BotoPreferida bicicletaId={bicicleta.id} />
</article>
);
}// src/components/BotoPreferida.jsx — CLIENT, i nomes aixo
'use client';
import { useMagatzemLocal } from '@/hooks/useMagatzemLocal';
export default function BotoPreferida({ bicicletaId }) {
const [preferides, setPreferides] = useMagatzemLocal('preferides', []);
const esPreferida = preferides.includes(bicicletaId);
function gestionarClic() {
setPreferides(
esPreferida
? preferides.filter((id) => id !== bicicletaId)
: [...preferides, bicicletaId]
);
}
return (
<button onClick={gestionarClic} aria-pressed={esPreferida}>
{esPreferida ? '★ Desada' : '☆ Desar'}
</button>
);
}Amb vint targetes en pantalla, al navegador arriben vint instàncies d'un botó de 300 bytes en lloc de vint targetes completes amb els seus estils, la seva lògica i les seves dependències. I useMagatzemLocal, el hook propi del mòdul 5, es reutilitza tal qual: és de client, i ara és al costat correcte de la frontera.
- Accions de servidor:
'use server'
'use server'Falta el camí de tornada. Els components de servidor pinten dades, però com s'envien dades des del navegador sense escriure un endpoint, un fetch i la seva gestió d'estat?
Una acció de servidor és una funció async marcada amb 'use server' que es defineix al servidor i es pot invocar des del client. React i el framework s'encarreguen del transport: el client rep una referència, no el codi.
// src/accions/reserves.js
'use server';
import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers';
import { validarReserva } from '@/utilitats/validarReserva';
export async function crearReserva(estatPrevi, dadesFormulari) {
// 1. Autoritzacio AL SERVIDOR. Mai et fiis del client.
const magatzem = await cookies();
const sessio = magatzem.get('sesion_ciclourbano');
if (!sessio) {
return { ok: false, errors: { general: 'Has d\'iniciar sessio.' } };
}
// 2. Extreure les dades del FormData.
const dades = {
bicicletaId: dadesFormulari.get('bicicletaId'),
dataInici: dadesFormulari.get('dataInici'),
hores: Number(dadesFormulari.get('hores')),
};
// 3. VALIDAR AL SERVIDOR, amb la mateixa utilitat del modul 3.
const bicicletes = await fetch('http://localhost:3001/bicicletas').then((r) => r.json());
const errors = validarReserva(dades, bicicletes);
if (Object.keys(errors).length > 0) {
return { ok: false, errors, dades };
}
// 4. Escriure.
const resposta = await fetch('http://localhost:3001/reservas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...dades, usuari: JSON.parse(sessio.value).id, estat: 'activa' }),
});
if (!resposta.ok) {
return { ok: false, errors: { general: 'No s\'ha pogut crear la reserva.' } };
}
// 5. Invalidar la memoria cau afectada (10-02).
revalidateTag(`bicicleta-${dades.bicicletaId}`);
revalidateTag('bicicletas');
return { ok: true, errors: {} };
}I el formulari que la fa servir, amb els dos hooks de React 19 pensats per a això:
// src/components/FormulariReserva.jsx
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { crearReserva } from '@/accions/reserves';
function BotoEnviar() {
// useFormStatus llegeix l'estat del <form> ANCESTRE:
// per aixo ha d'estar en un component fill, no en el que declara el form.
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Reservant…' : 'Confirmar reserva'}
</button>
);
}
export default function FormulariReserva({ bicicleta }) {
const [estat, accio, enviant] = useActionState(crearReserva, {
ok: false,
errors: {},
});
return (
<form action={accio}>
<input type="hidden" name="bicicletaId" value={bicicleta.id} />
<label>
Data d'inici
<input type="datetime-local" name="dataInici" required />
</label>
{estat.errors.dataInici && (
<p role="alert">{estat.errors.dataInici}</p>
)}
<label>
Hores
<input type="number" name="hores" min="1" max="8" defaultValue="1" />
</label>
{estat.errors.hores && <p role="alert">{estat.errors.hores}</p>}
{estat.errors.general && <p role="alert">{estat.errors.general}</p>}
{estat.ok && <p role="status">Reserva confirmada.</p>}
<BotoEnviar />
</form>
);
}El que cal entendre d'aquest codi:
action={accio}en lloc deonSubmit. React intercepta l'enviament, serialitza elFormDatai crida l'acció de servidor. Si el JavaScript encara no ha carregat, el formulari s'envia com un formulari HTML de tota la vida i funciona igualment: és millora progressiva real.useActionState(accio, estatInicial)retorna[estat, accioEmbolcallada, enviant]. L'estat és el que retorna l'acció, i per això l'acció repestatPrevicom a primer paràmetre.useFormStatuss'importa dereact-domi només funciona en un component fill del<form>. Si el crides aFormulariReserva,pendingserà semprefalse. És la fallada més habitual amb aquest hook.- La validació es fa al servidor. Pots duplicar-la al client per donar resposta immediata —i
validarReservadel mòdul 3 serveix als dos llocs—, però la del servidor no és opcional.
Avís de seguretat. Una acció de servidor és un endpoint HTTP públic. React genera una URL per a ella, i qualsevol la pot invocar amb qualsevol càrrega útil. Tota acció ha d'autenticar, autoritzar i validar pel seu compte, exactament com faries en una API REST. Que la funció estigui escrita al costat del component no la protegeix de res.
Comparació amb el que feia la SPA al mòdul 7:
| SPA (Vite) | Acció de servidor | |
|---|---|---|
| Definir l'endpoint | json-server o una API pròpia |
La pròpia funció |
| Cridar-la | fetch en un mutationFn |
action={accio} |
| Estat d'enviament | isPending de useMutation |
useFormStatus / useActionState |
| Invalidar la memòria cau | queryClient.invalidateQueries |
revalidateTag |
| Sense JavaScript | No funciona | Funciona |
| On valida | Client i servidor (dos codis) | Servidor (un codi, reutilitzable) |
- Què canvia respecte als mòduls 5 a 7 i què no
Tancament honest, perquè a aquestes altures és legítim preguntar-se què queda dret del que s'ha après.
El que no canvia gens:
- JSX, props, composició, llistes i claus. Idèntics als dos costats de la frontera.
- Tots els hooks, dins de components de client.
useState,useEffect,useRef,useReducer,useContext,useMemo,useCallbacki els hooks propis de CicloUrbano funcionen exactament igual. - Els límits d'error de 04-05, ara aparellats amb
Suspense. - Les regles d'hidratació, que en realitat són més estrictes que abans.
- L'accessibilitat del mòdul 3 i les proves del mòdul 9:
data-testid, rols i consultes de Testing Library continuen sent la manera de provar la interfície. - El rendiment del mòdul 8:
memo,useMemoiuseCallbackcontinuen aplicant dins de les illes de client.
El que canvia de lloc:
| Mòdul | Eina | On queda en el model RSC |
|---|---|---|
| 5 | useEffect per demanar dades |
Substituït per async/await al servidor |
| 6 | React Router | Substituït per l'enrutament per carpetes |
| 7 | Context | Només en components de client; el proveïdor porta 'use client' |
| 7 | Redux Toolkit | Només per a estat de client; l'estat del servidor deixa de necessitar-lo |
| 7 | TanStack Query | Innecessari en components de servidor; imprescindible a les illes de client i a la SPA |
| 8 | lazy + Suspense |
Continua vigent per al codi de client; per al de servidor no aplica, perquè no s'envia |
I la lectura correcta d'aquesta taula: res del que s'ha après s'ha malbaratat. A CicloUrbano, la SPA de Vite amb Redux Toolkit, TanStack Query i React Router continua sent el projecte principal, i l'aparador Next.js és una capa pública al damunt. El que aporta aquest mòdul és criteri: saber que hi ha dos models, quin convé a cada pantalla, i que la major part del coneixement es transfereix entre tots dos.
Errors Comuns i Consells
- Crear la promesa dins del component que fa
use(). Bucle infinit garantit. La promesa la crea un ancestre, un component de servidor o una memòria cau. - Posar
'use client'alayout.jsxper «arreglar» un error. Converteix tota l'aplicació en client i anul·la el model. Busca el component concret que necessita interactivitat i marca'l a ell. - Passar una funció com a prop de servidor a client. No és serialitzable. El gestor es defineix dins del component de client, o es converteix en una acció de servidor.
- Importar un component de servidor dins d'un fitxer amb
'use client'. Deixa de ser de servidor. Passa'l com achildreno com a prop JSX. - Cridar
useFormStatusen el mateix component que declara el<form>. Retorna semprepending: false. Ha d'anar en un fill. - Confiar en la validació del client en una acció de servidor. L'acció és un endpoint públic: autentica, autoritza i valida sempre al servidor.
- Posar el
Suspenseper fora delLimitError. L'ordre correcte és error fora, suspensió dins. - Fer servir un únic
<Suspense>a l'arrel. Equival a esperar el més lent i desaprofita el streaming. Un límit per bloc amb sentit propi. - Substituir contingut visible per un
fallbacken filtrar. Embolcalla l'actualització enuseTransitioni dona un senyal suau ambisPending. - Consell: pensa l'arbre en dos colors. Pinta mentalment d'un color el que només mostra dades i d'un altre el que reacciona a l'usuari. La frontera és just on canvia el color, i gairebé sempre és més avall del que sembla.
- Consell: comprova el resultat amb l'inspector de xarxa. A la pestanya de xarxa, un component de servidor ben col·locat fa que el JavaScript de la ruta baixi de manera visible. És l'equivalent al Profiler de 08-05 per a aquest model.
Exercicis
Exercici 1. Per a cadascun d'aquests fragments, digues si és correcte i, si no ho és, explica l'error i corregeix-lo.
// A
'use client';
import { cookies } from 'next/headers';
export default async function MenuUsuari() {
const sessio = (await cookies()).get('sesion_ciclourbano');
return <span>{sessio ? JSON.parse(sessio.value).nom : 'Convidat'}</span>;
}// B
export default async function PaginaCataleg() {
const bicicletes = await obtenirBicicletes();
return (
<LlistaFiltrable
bicicletes={bicicletes}
alFiltrar={(tipus) => bicicletes.filter((b) => b.tipus === tipus)}
/>
);
}// C
function DadesEstacio({ estacionId }) {
const estacio = use(fetch(`/api/estaciones/${estacionId}`).then((r) => r.json()));
return <h2>{estacio.nom}</h2>;
}Exercici 2. La fitxa de bici-002 té tres blocs amb temps molt diferents: les dades de la bicicleta (30 ms, cachejades), la disponibilitat en viu (800 ms) i les tres últimes valoracions d'usuaris (1.500 ms). Dissenya l'estructura de <Suspense> i LimitError que doni la millor experiència possible, escriu el codi de la pàgina i explica en quin ordre veu l'usuari cada cosa.
Exercici 3. Aquest PanellReserves és de client i arrossega mig catàleg al navegador. Reorganitza'l aplicant la regla de la frontera més baixa i l'excepció de children, i indica quin codi deixa d'enviar-se.
'use client';
import { useState } from 'react';
import LlistaReserves from './LlistaReserves';
import ResumFlota from './ResumFlota';
import EtiquetaEstat from './EtiquetaEstat';
import { formatarDataLlarga } from '../utilitats/dates'; // 45 KB
export default function PanellReserves({ reserves, flota }) {
const [pestanya, setPestanya] = useState('actives');
const visibles = reserves.filter((r) =>
pestanya === 'actives' ? r.estat === 'activa' : r.estat !== 'activa'
);
return (
<section>
<button onClick={() => setPestanya('actives')}>Actives</button>
<button onClick={() => setPestanya('historial')}>Historial</button>
<ResumFlota flota={flota} />
<LlistaReserves reserves={visibles} formatar={formatarDataLlarga} />
<EtiquetaEstat estat="disponible" />
</section>
);
}Solucions
Solució 1.
A — Incorrecte. Dos errors encadenats: un component de client no pot ser async i no pot fer servir cookies(), que és una API de servidor. La correcció és treure 'use client' i deixar-lo com a component de servidor; no necessita interactivitat per a res.
// Sense 'use client': component de servidor
import { cookies } from 'next/headers';
export default async function MenuUsuari() {
const sessio = (await cookies()).get('sesion_ciclourbano');
return <span>{sessio ? JSON.parse(sessio.value).nom : 'Convidat'}</span>;
}Si a més necessités un desplegable amb estat, s'extrauria aquest desplegable a un component de client que rebi el nom ja resolt com a prop.
B — Incorrecte. alFiltrar és una funció definida en un component de servidor i passada a un de client: no és serialitzable. A més el filtratge és lògica d'interfície i pertany al client.
// Servidor: nomes passa dades serialitzables
export default async function PaginaCataleg() {
const bicicletes = await obtenirBicicletes();
return <LlistaFiltrable bicicletes={bicicletes} />;
}// Client: el filtratge viu aqui
'use client';
import { useState } from 'react';
export default function LlistaFiltrable({ bicicletes }) {
const [tipus, setTipus] = useState('todos');
const visibles = tipus === 'todos'
? bicicletes
: bicicletes.filter((b) => b.tipus === tipus);
// ...
}Alternativa preferible a l'aparador: mantenir el filtre a la URL amb ?tipo= i filtrar al servidor, com a 10-01.
C — Incorrecte. La promesa es crea dins del propi component: cada reintent crea una promesa nova i el component se suspèn per sempre. A més falta el <Suspense> que mostri el fallback.
// El pare crea la promesa i no l'espera.
export default async function PaginaDetallEstacio({ params }) {
const { estacionId } = await params;
const promesaEstacio = obtenirEstacio(estacionId); // sense await
return (
<Suspense fallback={<p>Carregant estació…</p>}>
<DadesEstacio promesaEstacio={promesaEstacio} />
</Suspense>
);
}
function DadesEstacio({ promesaEstacio }) {
const estacio = use(promesaEstacio);
return <h2>{estacio.nom}</h2>;
}Solució 2. Cada bloc lent va al seu propi límit, i cada límit s'aparella amb el seu límit d'error perquè una fallada de valoracions no faci caure la disponibilitat.
// src/app/bicicletas/[bicicletaId]/page.jsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import LimitError from '@/components/LimitError';
import EtiquetaEstat from '@/components/EtiquetaEstat';
export default async function PaginaFitxaBicicleta({ params }) {
const { bicicletaId } = await params;
const bicicleta = await obtenirBicicleta(bicicletaId); // 30 ms, cachejada
if (!bicicleta) notFound();
return (
<article>
<h1>{bicicleta.model}</h1>
<EtiquetaEstat estat={bicicleta.estat} />
<p>{bicicleta.preuHora.toFixed(2)} €/h · {bicicleta.tipus}</p>
<LimitError titol="Disponibilitat no disponible ara mateix">
<Suspense fallback={<p>Consultant disponibilitat…</p>}>
<BlocDisponibilitat bicicletaId={bicicletaId} />
</Suspense>
</LimitError>
<LimitError titol="No s'han pogut carregar les valoracions">
<Suspense fallback={<EsqueletValoracions files={3} />}>
<BlocValoracions bicicletaId={bicicletaId} />
</Suspense>
</LimitError>
</article>
);
}Ordre d'aparició per a l'usuari:
| Moment | Què es veu |
|---|---|
| ~50 ms | Títol, estat, preu i els dos esquelets |
| ~850 ms | Es completa la disponibilitat; les valoracions continuen en esquelet |
| ~1.550 ms | Apareixen les valoracions |
Decisions clau: les dades ràpides s'esperen amb await perquè el marc de la pàgina arribi complet; les lentes van cadascuna al seu límit, així el bloc de 800 ms no queda retingut pel de 1.500 ms; i dos límits d'error separats garanteixen aïllament de fallades. Posar els dos blocs sota un mateix <Suspense> faria que la disponibilitat esperés innecessàriament les valoracions.
Solució 3. L'única cosa que necessita el client és l'estat de la pestanya. Tota la resta pot quedar-se al servidor.
// src/components/PestanyesReserves.jsx — CLIENT, illa minima
'use client';
import { useState } from 'react';
export default function PestanyesReserves({ resum, actives, historial }) {
const [pestanya, setPestanya] = useState('actives');
return (
<section>
<div role="tablist">
<button role="tab" aria-selected={pestanya === 'actives'}
onClick={() => setPestanya('actives')}>Actives</button>
<button role="tab" aria-selected={pestanya === 'historial'}
onClick={() => setPestanya('historial')}>Historial</button>
</div>
{resum}
{pestanya === 'actives' ? actives : historial}
</section>
);
}// src/app/reservas/page.jsx — SERVIDOR
import PestanyesReserves from '@/components/PestanyesReserves';
import LlistaReserves from '@/components/LlistaReserves'; // servidor
import ResumFlota from '@/components/ResumFlota'; // servidor
export default async function PanellReserves() {
const [reserves, flota] = await Promise.all([obtenirReserves(), obtenirFlota()]);
const actives = reserves.filter((r) => r.estat === 'activa');
const historial = reserves.filter((r) => r.estat !== 'activa');
return (
<PestanyesReserves
resum={<ResumFlota flota={flota} />}
actives={<LlistaReserves reserves={actives} />}
historial={<LlistaReserves reserves={historial} />}
/>
);
}Què deixa d'enviar-se al navegador: LlistaReserves, ResumFlota, EtiquetaEstat i, sobretot, els 45 KB de utilitats/dates, que ara s'executa només al servidor. Al client arriben uns quants centenars de bytes amb el useState de la pestanya, més l'HTML ja renderitzat dels tres blocs.
Dos matisos que mereixen atenció. Primer: les dues llistes es renderitzen sempre, encara que només se'n vegi una; si l'historial fos molt gran, convindria convertir cada pestanya en una ruta pròpia i deixar que Next.js demani només la visible. Segon: aquí children s'usa a través de tres props JSX amb nom (resum, actives, historial), no d'un únic children. L'excepció de la frontera aplica igual a qualsevol prop que contingui JSX ja renderitzat, i això és exactament el patró de «forats amb nom» del mòdul 4.
Conclusió
Aquesta lliçó ha explicat el model que sostenia les dues anteriors, i ha saldat el deute que el mòdul 8 va deixar obert.
De Suspense, l'essencial és el mecanisme: un component que durant el render intenta llegir alguna cosa que encara no està disponible se suspèn, i el <Suspense> més proper cap amunt mostra el seu fallback fins que el recurs arriba, moment en què React reintenta el render. D'aquí se'n dedueix tota la resta: que el component no té ni necessita un estat de càrrega; que qui decideix la interfície d'espera és un ancestre; que un límit defineix una unitat d'espera i per això convé un per bloc de contingut amb sentit propi; i que la promesa mai es pot crear en el component que la consumeix, sota pena de bucle infinit. lazy de 08-04 era simplement el primer consumidor d'aquest mecanisme, amb espera per codi; el hook use de React 19 i useSuspenseQuery són els mateixos amb espera per dades, i en un component de servidor el propi await és la suspensió.
Al voltant del mecanisme queden fixades tres pràctiques: el streaming de l'HTML, pel qual el servidor envia el marc i després els trossos que falten sense tancar la connexió —millorant TTFB, FCP i LCP alhora, i permetent hidratació progressiva—; la parella LimitError fora, Suspense dins, que cobreix els tres estats d'una zona sense escriure cap if; i useTransition perquè una actualització posterior no esborri contingut ja visible: fallback la primera vegada, transició les següents.
Dels React Server Components, el que cal retenir és que RSC no és SSR. SSR és un moment; RSC és un lloc. Un component de servidor s'executa només al servidor, envia el seu resultat pel cable i no suma res al paquet de JavaScript, ni ell ni les seves dependències —la solució més radical al problema de mida del mòdul 8: no dividir el codi, sinó no enviar-lo—. Un component de client s'executa als dos llocs, s'hidrata, i és l'únic que pot tenir estat, efectes, esdeveniments i APIs del navegador. La directiva 'use client' no marca un component sinó una frontera: tot el que aquest mòdul importa creua amb ell, i d'aquí la regla de col·locar-la tan avall com sigui possible, amb BotoPreferida com a illa en lloc de TargetaBicicleta sencera. Creuen la frontera les props serialitzables i el JSX ja renderitzat; no creuen les funcions ni les classes. I l'excepció decisiva és children —o qualsevol prop JSX amb nom—, que permet ficar un component de servidor dins d'un de client: la composició del mòdul 4, amb una conseqüència nova sobre el pes del paquet.
Finalment, les accions de servidor amb 'use server' tanquen el camí de tornada: funcions async invocables des del formulari amb action={accio}, amb useActionState per al resultat i useFormStatus —en un fill del <form>, mai en el mateix component— per a l'estat d'enviament; funcionen sense JavaScript, i validarReserva del mòdul 3 es reutilitza intacta. Amb un avís que no admet matisos: una acció de servidor és un endpoint públic i ha d'autenticar, autoritzar i validar pel seu compte.
I el balanç: res del que s'ha après s'ha malbaratat. Els hooks, la composició, els límits d'error, l'accessibilitat, el rendiment i les proves continuen valent; el que canvia és on viu cada cosa. Recorda a més el que es va dir a 10-02: el projecte del Mòdul 11 es construeix amb Vite + React Router, l'aplicació que portes muntant des del principi.
Queda una capa de la qual s'ha parlat dues vegades sense desenvolupar-la. A 09-01 es va col·locar l'anàlisi estàtica a la base de la piràmide de proves, com el nivell més barat, i es va dir que TypeScript es veuria aquí. En aquest mòdul, a més, has vist contractes per tot arreu: quines props creuen una frontera, quina forma té l'objecte que retorna una acció, quins camps porta una Bicicleta. Tots aquests contractes són avui implícits: viuen al cap de qui va escriure el component i només es comproven quan alguna cosa falla en execució. La pròxima lliçó els fa explícits i els comprova mentre escrius. La pròxima lliçó és TypeScript amb React.
Curs de React
Mòdul 1: Introducció a React
- Què és React?
- Configuració de l'Entorn de Desenvolupament
- Hola Món amb React
- JSX: Extensió de Sintaxi de JavaScript
- Com Renderitza React: Virtual DOM i Reconciliació
Mòdul 2: Components de React
- Entendre els Components
- Components Funcionals vs de Classe
- Props: Passar Dades als Components
- State: Gestió de l'Estat del Component
- Estils en els Components: CSS, Mòduls i Utilitats
Mòdul 3: Treballar amb Esdeveniments
- Gestió d'Esdeveniments a React
- Renderitzat Condicional
- Llistes i Claus
- Formularis i Components Controlats
- Validació de Formularis i Components No Controlats
- Accessibilitat en Components Interactius
Mòdul 4: Conceptes Avançats de Components
- Elevar l'Estat
- Composició vs Herència
- Mètodes del Cicle de Vida de React
- Hooks: Introducció i Ús Bàsic
- Límits d'Error: Capturar Fallades a la Interfície
Mòdul 5: Hooks de React
- Hook useState
- Hook useEffect
- Hook useRef i Accés al DOM
- Hook useContext
- Hook useReducer
- Hooks Personalitzats
Mòdul 6: Enrutament a React
- Introducció a React Router
- Configuració de React Router
- Rutes Imbricades
- Navegació Programàtica
- Rutes Protegides i Control d'Accés
Mòdul 7: Gestió de l'Estat
- Introducció a la Gestió de l'Estat
- API de Context
- Redux: Introducció i Configuració
- Redux: Accions i Reductors
- Redux: Connectar-lo a React
- Estat del Servidor: Peticions, Memòria Cau i Sincronització
Mòdul 8: Optimització del Rendiment
- Tècniques d'Optimització del Rendiment a React
- Memoïtzació amb React.memo
- Hooks useMemo i useCallback
- Divisió de Codi i Càrrega Mandrosa
- Mesurar el Rendiment amb React DevTools Profiler
Mòdul 9: Proves a React
- Introducció a les Proves
- Proves Unitàries amb Jest
- Proves de Components amb React Testing Library
- Proves de Codi Asíncron i Simulació d'APIs
- Proves d'Extrem a Extrem amb Cypress
Mòdul 10: Temes Avançats
- Renderitzat al Servidor (SSR) amb Next.js
- Generació de Llocs Estàtics (SSG) amb Next.js
- Suspense i React Server Components
- TypeScript amb React
- React Native: Creació d'Aplicacions Mòbils
