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

  1. Què és un límit de suspensió
  2. Què significa que un component se suspengui
  3. Imbricar límits: qui mostra què
  4. Suspense amb lazy: el que ja sabies
  5. Suspense per a dades: el hook use de React 19
  6. useSuspenseQuery: Suspense a la SPA de Vite
  7. Streaming de l'HTML
  8. Streaming a Next.js: loading.jsx i <Suspense> a mà
  9. Suspense i LimitError: cobrir càrrega i fallada
  10. Transicions: evitar que el fallback esborri el que és visible
  11. React Server Components: què són i en què es diferencien del SSR
  12. L'arbre mixt i la frontera 'use client'
  13. Què creua la frontera i què no
  14. Col·locar la frontera tan avall com sigui possible
  15. Accions de servidor: 'use server'
  16. Què canvia respecte als mòduls 5 a 7 i què no

  1. 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 fallback en lloc de tot el meu subarbre. Quan aquest component ja pot, mostro el contingut real.

<Suspense fallback={<EsqueletPagina files={5} />}>
  <LlistaBicicletes />
</Suspense>

É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? 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 fallback ha 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íem EsqueletPagina a un text «Carregant…».

  1. 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 ha useState(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.

  1. 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.

  1. Suspense amb lazy: el que ja sabies

Aquest é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.

  1. Suspense per a dades: el hook use de React 19

React 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
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>
  );
}
// C) La gestiona una memoria cau externa: es el que fa TanStack Query.

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.

  1. useSuspenseQuery: Suspense a la SPA de Vite

Tot 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.

  1. 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.

  1. Streaming a Next.js: 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:

<Suspense fallback={<Carregant />}>
  <Page />
</Suspense>

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 async de servidor que espera una dada se suspèn: aquest és el vincle entre l'await de 10-01 i el mecanisme d'aquesta lliçó. L'await d'un component de servidor és una suspensió.

Resultat: TTFB de CDN, contingut principal immediat, dada en viu exacta, i tot indexable.

  1. 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.

  1. Transicions: evitar que el fallback esborri el que és visible

Hi 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.

  1. 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'hidrata? No: no hi ha res a hidratar
Pot tenir estat? No (useState, useReducer)
Pot tenir efectes? No (useEffect)
Pot tenir esdeveniments? No (onClick, onChange)
Pot ser async? No (però pot fer servir use)
Pot llegir fitxers o la base de dades? No
Pot llegir secrets (process.env)? No: acabarien al navegador
Pot fer servir window, localStorage? No
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.

  1. L'arbre mixt i la frontera '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 localStorage durant 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.

  1. 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.

  1. 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:

  1. Fa servir useState, useReducer, useEffect, useRef o algun hook de client? → client.
  2. Té gestors d'esdeveniments (onClick, onChange, onSubmit)? → client.
  3. Fa servir APIs del navegador (window, localStorage, IntersectionObserver)? → client.
  4. Fa servir context? → client (tant el proveïdor com el consumidor).
  5. 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.

  1. Accions de servidor: '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 de onSubmit. React intercepta l'enviament, serialitza el FormData i 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ó rep estatPrevi com a primer paràmetre.
  • useFormStatus s'importa de react-dom i només funciona en un component fill del <form>. Si el crides a FormulariReserva, pending serà sempre false. É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 validarReserva del 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)

  1. 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, useCallback i 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, useMemo i useCallback continuen 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' a layout.jsx per «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 a children o com a prop JSX.
  • Cridar useFormStatus en el mateix component que declara el <form>. Retorna sempre pending: 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 Suspense per fora del LimitError. 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 fallback en filtrar. Embolcalla l'actualització en useTransition i dona un senyal suau amb isPending.
  • 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

Mòdul 2: Components de React

Mòdul 3: Treballar amb Esdeveniments

Mòdul 4: Conceptes Avançats de Components

Mòdul 5: Hooks de React

Mòdul 6: Enrutament a React

Mòdul 7: Gestió de l'Estat

Mòdul 8: Optimització del Rendiment

Mòdul 9: Proves a React

Mòdul 10: Temes Avançats

Mòdul 11: Projecte: Construir una Aplicació Completa

© Copyright 2026. Tots els drets reservats