Las dos lecciones anteriores han usado un modelo sin explicarlo. Has escrito 'use client' porque hacía falta; has aceptado que un componente de servidor «no se envía al navegador» sin ver cómo es eso posible; has puesto un loading.jsx sabiendo únicamente que por debajo hay un <Suspense>; y 10-02 terminó prometiendo el patrón que resuelve casi todos los casos difíciles: una página estática con un hueco dinámico dentro. Esta lección va a ese modelo. Y de paso salda una deuda explícita del módulo 8: allí se dijo que «Suspense es mucho más que un fallback de carga perezosa y su historia completa está en 10-03». Aquí está. Vas a entender qué es un límite de suspensión y qué significa exactamente que un componente «se suspenda», cómo se suspende por datos y no solo por código, cómo el servidor envía el HTML por trozos, y qué son los React Server Components: dónde se ejecuta cada uno, qué viaja por el cable y qué puede y no puede cruzar la frontera entre servidor y cliente. El foco es el modelo, no las funciones de Next.js.

Contenido

  1. Qué es un límite de suspensión
  2. Qué significa que un componente se suspenda
  3. Anidar límites: quién muestra qué
  4. Suspense con lazy: lo que ya sabías
  5. Suspense para datos: el hook use de React 19
  6. useSuspenseQuery: Suspense en la SPA de Vite
  7. Streaming del HTML
  8. Streaming en Next.js: loading.jsx y <Suspense> a mano
  9. Suspense y LimiteDeError: cubrir carga y fallo
  10. Transiciones: evitar que el fallback borre lo visible
  11. React Server Components: qué son y en qué se diferencian del SSR
  12. El árbol mixto y la frontera 'use client'
  13. Qué cruza la frontera y qué no
  14. Colocar la frontera lo más abajo posible
  15. Acciones de servidor: 'use server'
  16. Qué cambia respecto a los módulos 5 a 7 y qué no

  1. Qué es un límite de suspensión

Un límite de suspensión es un componente <Suspense> colocado en el árbol. Su trabajo es sencillo de enunciar:

Si algún componente por debajo de mí anuncia que todavía no puede renderizarse, yo muestro mi fallback en lugar de todo mi subárbol. Cuando ese componente ya puede, muestro el contenido real.

<Suspense fallback={<EsqueletoPagina filas={5} />}>
  <ListaBicicletas />
</Suspense>

Es la misma idea que un límite de error de 04-05, con dos diferencias importantes:

LimiteDeError <Suspense>
Qué captura Un error lanzado abajo Una espera declarada abajo
Estado que muestra Interfaz de fallo fallback de carga
¿Es recuperable? Solo con un reset explícito Sí, automáticamente al resolverse
¿Hay que escribirlo como clase? No, es un componente de React

Y comparten la propiedad que los hace útiles: son declarativos y se colocan por zonas. No se pregunta «¿está cargando?» en cada componente; se declara una vez, arriba, qué se muestra mientras la zona no está lista. Eso es exactamente lo que elimina los if (isPending) return <IndicadorDeCarga /> repartidos por todas partes que tenía CicloUrbano en el módulo 7.

Dos propiedades del fallback que conviene fijar desde el principio:

  • No se pierde el estado del subárbol suspendido si ya se había montado. Al volver a suspender, React oculta el contenido en lugar de desmontarlo, y el estado se conserva.
  • El fallback debe ocupar aproximadamente lo mismo que el contenido real. Si no, al resolverse la página da un salto. Es la misma razón por la que en 08-04 preferimos EsqueletoPagina a un texto «Cargando…».

  1. Qué significa que un componente se suspenda

Aquí está el mecanismo, y merece precisión porque casi todo lo demás se deduce de él.

Un componente se suspende cuando, durante su render, intenta leer un recurso que todavía no está disponible. En lugar de devolver JSX, lo que hace es lanzar la promesa de ese recurso (en React 19, el mecanismo está encapsulado y no lo escribes tú). React captura esa señal, detiene el render de ese subárbol, sube hasta el <Suspense> más cercano y renderiza su fallback. Cuando la promesa se resuelve, React reintenta el render del componente, esta vez con el valor ya disponible.

sequenceDiagram
    participant R as React
    participant S as Suspense
    participant C as Componente
    participant P as Promesa

    R->>C: render()
    C->>P: leer recurso
    P-->>C: aun no esta listo
    C-->>R: SUSPENDE (lanza la promesa)
    R->>S: muestra el fallback
    Note over R: React se suscribe a la promesa
    P-->>R: resuelta
    R->>C: render() otra vez
    C-->>R: JSX con los datos
    R->>S: sustituye el fallback por el contenido

Cuatro consecuencias que suelen sorprender:

  • El componente se renderiza dos veces como mínimo: la primera suspende, la segunda produce contenido. Por eso el render debe seguir siendo puro, tal como se estableció en el módulo 4: no puede tener efectos secundarios.
  • No hay estado de carga en el componente. No hay isPending, no hay useState(true). Quien decide qué se ve durante la espera es un ancestro. El componente solo dice «todavía no».
  • El componente ni siquiera sabe que ha suspendido. No hay una API para preguntarlo. Esto es deliberado: separa la lógica de la presentación de la espera.
  • Un componente no puede suspenderse por sí solo si crea la promesa en su propio render. Ese es el error más común y lo veremos en el apartado 5: al reintentar, crearía una promesa nueva, suspendería otra vez y entraría en bucle infinito.

  1. Anidar límites: quién muestra qué

Los límites se anidan, y la regla es única: suspende el más cercano hacia arriba. Esa es toda la lógica, y define con precisión la zona que se sustituye por el fallback.

Considera la ficha de bicicleta del escaparate:

<Suspense fallback={<EsqueletoPagina />}>
  <Cabecera />
  <DatosBicicleta bicicletaId="bici-002" />

  <Suspense fallback={<p>Consultando disponibilidad…</p>}>
    <DisponibilidadEnVivo bicicletaId="bici-002" />
  </Suspense>

  <Suspense fallback={<p>Cargando estación…</p>}>
    <TarjetaEstacion estacionId="est-01" />
  </Suspense>
</Suspense>
flowchart TB
    S1["Suspense EXTERIOR<br/>fallback: EsqueletoPagina"]
    S1 --> CAB["Cabecera"]
    S1 --> DB["DatosBicicleta<br/>(puede suspender)"]
    S1 --> S2["Suspense<br/>fallback: 'Consultando disponibilidad'"]
    S1 --> S3["Suspense<br/>fallback: 'Cargando estacion'"]
    S2 --> DIS["DisponibilidadEnVivo<br/>(puede suspender)"]
    S3 --> TE["TarjetaEstacion<br/>(puede suspender)"]

Qué ocurre en cada caso:

Quién suspende Qué zona se sustituye Qué sigue visible
DatosBicicleta Todo, hasta la cabecera Nada de la ficha
DisponibilidadEnVivo Solo su bloque Cabecera, datos y estación
TarjetaEstacion Solo su bloque Cabecera, datos y disponibilidad
Los tres a la vez Todo (gana el exterior) Nada

De aquí sale la regla de diseño que gobierna esta técnica:

Un límite de suspensión define una unidad de espera. Pon límites alrededor de las partes que pueden tardar y quieres que no bloqueen al resto; deja fuera de ellos lo que es rápido y da estructura a la página.

Ponerlo todo bajo un único <Suspense> en la raíz equivale a volver a la pantalla en blanco: el usuario espera a lo más lento. Poner un límite alrededor de cada elemento es el extremo opuesto y produce una página que aparece a trocitos, con saltos constantes. El punto correcto suele estar en una unidad por bloque de contenido con sentido propio.

  1. Suspense con lazy: lo que ya sabías

Este es el caso de 08-04, y ahora se entiende por qué funcionaba.

import { lazy, Suspense } from 'react';

const PaginaTaller = lazy(() => import('./paginas/PaginaTaller.jsx'));

<Suspense fallback={<EsqueletoPagina />}>
  <PaginaTaller />
</Suspense>

lazy devuelve un componente que, al renderizarse por primera vez, comprueba si el módulo ya está descargado. Si no lo está, dispara el import() dinámico y se suspende con esa promesa. React muestra el fallback, y cuando el fragmento llega, reintenta el render con el componente real.

Es decir: lazy no es un mecanismo aparte, es el primer consumidor de Suspense. La espera es por código. Lo que viene ahora es la misma mecánica con espera por datos.

  1. Suspense para datos: el hook use de React 19

React 19 introduce use, que lee el valor de una promesa durante el render y suspende si aún no está resuelta.

import { use } from 'react';

function DisponibilidadEnVivo({ promesaDisponibilidad }) {
  // Si la promesa no está resuelta, este componente SUSPENDE aquí.
  const disponibilidad = use(promesaDisponibilidad);

  return (
    <p>
      {disponibilidad.libres} de {disponibilidad.total} unidades disponibles
      en {disponibilidad.estacion}
    </p>
  );
}

use rompe deliberadamente dos reglas de los hooks que se establecieron en 04-04, y conviene saberlo:

Regla de los hooks ¿La cumple use?
Solo en el nivel superior del componente No: puede ir dentro de un if o de un bucle
Solo en componentes o hooks personalizados
Mismo orden en cada render No aplica

Además de promesas, use puede leer un contexto (use(ContextoTema)), lo que permite consumirlo condicionalmente —algo que useContext no admite—.

Ahora, la trampa que mencionábamos en el apartado 2. Esto entra en bucle infinito:

// INCORRECTO: crea una promesa nueva en cada render
function DisponibilidadEnVivo({ bicicletaId }) {
  const disponibilidad = use(
    fetch(`/api/disponibilidad/${bicicletaId}`).then((r) => r.json())
  );
  return <p>{disponibilidad.libres} unidades</p>;
}

La secuencia del desastre: render 1 crea la promesa A y suspende → A se resuelve → React reintenta → el render 2 crea la promesa B, distinta, que tampoco está resuelta → suspende otra vez → y así indefinidamente.

La promesa tiene que crearse fuera del componente que la consume. Hay tres formas legítimas:

// A) La crea un componente de SERVIDOR y la pasa como prop, SIN await.
//    Esto es el patrón de Next.js: el servidor no espera, delega la espera.
export default async function PaginaFichaBicicleta({ params }) {
  const { bicicletaId } = await params;
  const bicicleta = await obtenerBicicleta(bicicletaId);      // sí se espera
  const promesaDisponibilidad = obtenerDisponibilidad(bicicletaId); // NO se espera

  return (
    <article>
      <h1>{bicicleta.modelo}</h1>
      <Suspense fallback={<p>Consultando disponibilidad…</p>}>
        <DisponibilidadEnVivo promesaDisponibilidad={promesaDisponibilidad} />
      </Suspense>
    </article>
  );
}
// B) La memoriza un ancestro de cliente con useMemo (uso legítimo del módulo 8).
function PanelDisponibilidad({ bicicletaId }) {
  const promesa = useMemo(() => obtenerDisponibilidad(bicicletaId), [bicicletaId]);
  return (
    <Suspense fallback={<p>Consultando…</p>}>
      <DisponibilidadEnVivo promesaDisponibilidad={promesa} />
    </Suspense>
  );
}
// C) La gestiona una caché externa: es lo que hace TanStack Query.

El patrón A es el importante y merece subrayarse: el componente de servidor no espera al dato lento; le pasa la promesa a un hijo envuelto en <Suspense> y sigue renderizando. Ese es, literalmente, el patrón de «página estática con hueco dinámico» que 10-02 dejó prometido.

  1. useSuspenseQuery: Suspense en la SPA de Vite

Todo lo anterior no es exclusivo de Next.js. En la aplicación de gestión con Vite, TanStack Query ofrece la misma integración con su caché, que resuelve por sí sola el problema de la identidad de la promesa.

Compara los dos estilos sobre el mismo componente:

// Estilo del módulo 7: el estado de carga vive DENTRO del componente.
function ListaBicicletas() {
  const { data, isPending, isError } = useQuery({
    queryKey: ['bicicletas'],
    queryFn: obtenerBicicletas,
  });

  if (isPending) return <IndicadorDeCarga />;
  if (isError) return <Aviso tipo="error">No se ha podido cargar.</Aviso>;

  return <ul>{data.map((b) => <TarjetaBicicleta key={b.id} bicicleta={b} />)}</ul>;
}
// Estilo con Suspense: el componente solo conoce el caso de éxito.
import { useSuspenseQuery } from '@tanstack/react-query';

function ListaBicicletas() {
  const { data } = useSuspenseQuery({
    queryKey: ['bicicletas'],
    queryFn: obtenerBicicletas,
  });

  return <ul>{data.map((b) => <TarjetaBicicleta key={b.id} bicicleta={b} />)}</ul>;
}

Y la espera y el fallo se declaran fuera, una sola vez:

// src/paginas/PaginaCatalogo.jsx
<LimiteDeError titulo="No se ha podido cargar el catálogo">
  <Suspense fallback={<EsqueletoPagina filas={5} />}>
    <ListaBicicletas />
  </Suspense>
</LimiteDeError>

Comparación honesta de los dos enfoques:

useQuery useSuspenseQuery
data puede ser undefined Sí, hay que comprobarlo No: siempre hay datos
Estados de carga y error Dentro del componente En límites externos
Ramas condicionales Tres (carga, error, éxito) Una
¿Se puede llamar condicionalmente? Sí, con enabled No: siempre se ejecuta
Riesgo de cascadas Bajo Alto: dos hijos hermanos que consultan en serie

Ese último punto es el peligro real. Si TarjetaBicicleta y PanelReserva usan cada uno su useSuspenseQuery y están dentro del mismo límite, la segunda consulta no arranca hasta que la primera termina, porque el segundo componente no llega a renderizarse. La solución es precargar en el ancestro con queryClient.prefetchQuery o usar useSuspenseQueries. Es la versión con Suspense del Promise.all que ya viste en 10-01.

  1. Streaming del HTML

Con SSR clásico, el servidor hace esto: espera todos los datos, renderiza todo el HTML y lo envía de golpe. Si la disponibilidad de bici-002 tarda 800 ms, el usuario mira una pantalla en blanco durante 800 ms. Hemos movido la espera del navegador al servidor, pero la espera sigue ahí.

El streaming convierte esa respuesta única en un flujo. El servidor abre la conexión, envía el marco de la página con los fallback en su sitio, y sigue enviando trozos a medida que cada <Suspense> se resuelve, sin cerrar la respuesta.

sequenceDiagram
    participant N as Navegador
    participant S as Servidor Next.js
    participant API as API

    N->>S: GET /bicicletas/bici-002
    S->>API: datos de la bicicleta (rapido)
    API-->>S: JSON
    S-->>N: TROZO 1 · cabecera + modelo + esqueleto de disponibilidad
    Note over N: Contenido VISIBLE a los ~200 ms
    S->>API: disponibilidad en vivo (lento)
    API-->>S: JSON (800 ms despues)
    S-->>N: TROZO 2 · HTML del bloque + script que lo coloca
    Note over N: El esqueleto se sustituye · sin recargar
    S-->>N: fin de la respuesta

El detalle técnico que hace que esto funcione y que suele generar incredulidad: el trozo 2 llega fuera de orden dentro del HTML, en un <template> al final del documento, acompañado de un pequeño script en línea que lo mueve al hueco correcto. Es un mecanismo del propio React, no de Next.js, y funciona aunque el JavaScript de la aplicación no se haya cargado todavía: el script en línea son unas pocas líneas independientes del paquete.

Lo que gana el usuario, en las métricas de 10-01:

Métrica SSR sin streaming SSR con streaming
TTFB Espera al dato más lento Inmediato
FCP Al final de todo Con el primer trozo
LCP Al final de todo En cuanto llega su bloque
Lo que ve el usuario Blanco, y de golpe todo Estructura, y luego se completa

Y una implicación menos obvia: la hidratación también es progresiva. React puede hidratar las partes que ya han llegado sin esperar al resto, y prioriza la zona con la que el usuario interactúa. Aquel «valle inquietante» entre ver y poder tocar que describimos en 10-01 se estrecha considerablemente.

  1. Streaming en Next.js: loading.jsx y <Suspense> a mano

Con el modelo entendido, las dos formas de activarlo en Next.js dejan de ser magia.

Opción 1: loading.jsx. Next.js envuelve automáticamente el page.jsx de esa carpeta en un <Suspense> cuyo fallback es lo que exporte el loading.jsx.

// src/app/bicicletas/[bicicletaId]/loading.jsx
import EsqueletoPagina from '@/componentes/EsqueletoPagina';

export default function Cargando() {
  return <EsqueletoPagina filas={3} />;
}

Es equivalente a escribir esto en el layout padre:

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

Grano grueso: la página entera espera. Sirve para la navegación general, pero no distingue lo rápido de lo lento.

Opción 2: <Suspense> a mano, que es la que da el patrón bueno.

// src/app/bicicletas/[bicicletaId]/page.jsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import EtiquetaEstado from '@/componentes/EtiquetaEstado';
import DisponibilidadEnVivo from '@/componentes/DisponibilidadEnVivo';

const API = 'http://localhost:3001';

async function obtenerBicicleta(id) {
  const r = await fetch(`${API}/bicicletas/${id}`, { next: { revalidate: 60 } });
  return r.ok ? r.json() : null;
}

// Componente de servidor que SÍ espera. Al suspenderse, activa el Suspense de arriba.
async function BloqueDisponibilidad({ bicicletaId }) {
  const r = await fetch(`${API}/disponibilidad/${bicicletaId}`, { cache: 'no-store' });
  const disponibilidad = await r.json();
  return (
    <p aria-live="polite">
      {disponibilidad.libres} unidades libres ahora mismo en {disponibilidad.estacion}
    </p>
  );
}

export default async function PaginaFichaBicicleta({ params }) {
  const { bicicletaId } = await params;
  const bicicleta = await obtenerBicicleta(bicicletaId);
  if (!bicicleta) notFound();

  return (
    <article>
      <h1>{bicicleta.modelo}</h1>
      <EtiquetaEstado estado={bicicleta.estado} />
      <p>{bicicleta.precioHora.toFixed(2)} €/h · {bicicleta.tipo}</p>

      <Suspense fallback={<p>Consultando disponibilidad…</p>}>
        <BloqueDisponibilidad bicicletaId={bicicletaId} />
      </Suspense>
    </article>
  );
}

Aquí está, ya completo, el patrón que 10-02 dejó pendiente:

  • Los datos cacheables de la bicicleta se piden con revalidate: 60, así que la mayor parte de la página se sirve prerenderizada.
  • La disponibilidad en vivo, que obligaría a toda la página a ser dinámica, queda aislada dentro del <Suspense>. Next.js prerenderiza el resto e inyecta ese hueco en cada petición.
  • Un componente async de servidor que espera un dato se suspende: ese es el vínculo entre el await de 10-01 y el mecanismo de esta lección. El await de un componente de servidor es una suspensión.

Resultado: TTFB de CDN, contenido principal inmediato, dato en vivo exacto, y todo indexable.

  1. Suspense y LimiteDeError: cubrir carga y fallo

<Suspense> cubre la espera. No cubre el fallo: si la petición de disponibilidad devuelve un 500, el fallback no se muestra eternamente, sino que el error sube buscando un límite de error. Sin uno, tumba el árbol entero.

Los dos se combinan envolviendo el Suspense con el LimiteDeError, en ese orden:

<LimiteDeError titulo="No se ha podido consultar la disponibilidad">
  <Suspense fallback={<p>Consultando disponibilidad…</p>}>
    <BloqueDisponibilidad bicicletaId="bici-002" />
  </Suspense>
</LimiteDeError>

Por qué ese orden y no el contrario:

Orden Qué ocurre si falla Qué ocurre mientras carga
Error fuera, Suspense dentro El error captura y sustituye toda la zona, incluido el fallback Se ve el fallback
Suspense fuera, error dentro ❌ El límite de error está dentro del subárbol suspendido: puede que ni se haya montado Comportamiento impredecible

Los tres estados de la zona quedan así, y coinciden exactamente con los tres de useQuery del módulo 7, solo que declarados fuera del componente:

flowchart LR
    A["Renderizando"] -->|"suspende"| B["fallback de Suspense"]
    B -->|"promesa resuelta"| C["Contenido real"]
    B -->|"promesa rechazada"| D["Interfaz de LimiteDeError"]
    A -->|"error sincrono"| D
    D -->|"reset()"| A

En Next.js esta pareja ya viene montada por convención: loading.jsx es el Suspense y error.jsx es el límite de error del mismo segmento, con Next.js encargándose del orden. El error.jsx debe llevar 'use client', porque un límite de error necesita estado y un manejador reset:

// src/app/bicicletas/[bicicletaId]/error.jsx
'use client';

import { useEffect } from 'react';
import { registrarError } from '@/utilidades/monitorizacion';

export default function ErrorFicha({ error, reset }) {
  useEffect(() => {
    registrarError(error, { zona: 'ficha-bicicleta' });
  }, [error]);

  return (
    <div role="alert">
      <h2>No hemos podido mostrar esta bicicleta</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Reintentar</button>
    </div>
  );
}

Reconocerás registrarError de utilidades/monitorizacion.js: es la misma función que usaba el LimiteDeError del módulo 4. La infraestructura de errores se reutiliza tal cual.

  1. Transiciones: evitar que el fallback borre lo visible

Hay un efecto desagradable que aparece en cuanto se usa Suspense para datos. El usuario está viendo el catálogo con las cinco bicicletas, cambia el filtro a «eléctrica», la nueva consulta suspende… y el catálogo entero desaparece, sustituido por el esqueleto. Se ha cambiado contenido útil por un indicador de carga: es un retroceso, no una mejora.

La solución es useTransition, que ya apareció en 08-03:

'use client';

import { useTransition } from 'react';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';

export default function SelectorTipo() {
  const [enTransicion, iniciarTransicion] = useTransition();
  const router = useRouter();
  const rutaActual = usePathname();
  const parametros = useSearchParams();

  function manejarCambio(evento) {
    const tipo = evento.target.value;
    const nuevos = new URLSearchParams(parametros);
    if (tipo === 'todos') nuevos.delete('tipo');
    else nuevos.set('tipo', tipo);

    // Dentro de la transición, React NO sustituye el contenido ya visible.
    iniciarTransicion(() => {
      router.push(`${rutaActual}?${nuevos}`);
    });
  }

  return (
    <label>
      Tipo de bicicleta
      <select
        value={parametros.get('tipo') ?? 'todos'}
        onChange={manejarCambio}
        disabled={enTransicion}
      />
      {enTransicion && <span aria-live="polite">Actualizando…</span>}
    </label>
  );
}

Qué hace exactamente iniciarTransicion: marca esa actualización como no urgente. Si un componente suspende dentro de ella, React mantiene visible el contenido anterior en lugar de mostrar el fallback, y avisa mediante enTransicion para que el desarrollador dé una señal más suave —un indicador pequeño, una opacidad reducida, el control deshabilitado—.

La distinción a retener:

Situación Qué muestra React
Primera carga: no hay nada anterior El fallback del Suspense
Actualización sin transición El fallback, borrando lo visible
Actualización dentro de una transición El contenido anterior + isPending

Regla práctica: fallback para la primera vez, transición para las siguientes. En Next.js, la navegación con <Link> ya usa transiciones internamente; el caso que hay que tratar a mano es el router.push programático, como en este ejemplo.

  1. React Server Components: qué son y en qué se diferencian del SSR

Llegamos al segundo bloque de la lección. Y hay que empezar deshaciendo una confusión muy extendida: RSC no es SSR con otro nombre.

  • SSR es un momento: renderizar el HTML en el servidor. Un componente renderizado por SSR también se ejecuta después en el navegador durante la hidratación, y su código forma parte del paquete.
  • RSC es un lugar: un componente que se ejecuta solo en el servidor y nunca en el navegador. Su código no se incluye en el paquete.

La tabla completa, que es el resumen de todo el bloque:

Componente de servidor Componente de cliente
Dónde se ejecuta Solo en el servidor En el servidor (SSR inicial) y en el cliente
Cuándo se ejecuta En build o en la petición En cada render del navegador
Qué viaja por el cable Su resultado (payload RSC) Su código (JavaScript)
¿Suma al paquete? No, nada
¿Se hidrata? No: no hay nada que hidratar
¿Puede tener estado? No (useState, useReducer)
¿Puede tener efectos? No (useEffect)
¿Puede tener eventos? No (onClick, onChange)
¿Puede ser async? No (pero puede usar use)
¿Puede leer ficheros o la base de datos? No
¿Puede leer secretos (process.env)? No: acabarían en el navegador
¿Puede usar window, localStorage? No
¿Se vuelve a renderizar? Solo con una nueva petición o navegación Con cada cambio de estado o props

La fila que más importa es «qué viaja por el cable». Un componente de servidor no envía HTML directamente al navegador: envía una descripción serializada de su árbol —el payload RSC— que React en el cliente sabe reconstruir y fundir con los componentes de cliente. Por eso una navegación entre páginas de Next.js no recarga la página: el servidor devuelve el nuevo payload y React actualiza el árbol conservando el estado de las islas de cliente.

Lo que esto significa en peso, aplicado a CicloUrbano:

Componente Tipo JavaScript enviado al navegador
PaginaFichaBicicleta Servidor 0 KB
EtiquetaEstado Servidor 0 KB
TarjetaBicicleta Servidor 0 KB
La biblioteca de formateo de fechas que usa Servidor 0 KB
SelectorTipo Cliente ~1 KB + React
BotonTema Cliente ~0,8 KB

Esa fila en negrita es el argumento decisivo. Una dependencia pesada usada solo en un componente de servidor —un formateador de Markdown, una biblioteca de sintaxis resaltada, un cliente de base de datos— no llega nunca al navegador. Es la solución más radical al problema de tamaño del módulo 8: no dividir el código, sino no enviarlo.

  1. El árbol mixto y la frontera 'use client'

Una aplicación real no es toda de servidor ni toda de cliente: es un árbol mixto donde los componentes de cliente son islas dentro de un mar de componentes de servidor.

La directiva 'use client', en la primera línea de un fichero, marca el punto de entrada a la parte de cliente. Y aquí está la regla que más confusión genera:

'use client' no marca un componente. Marca una frontera. Todo lo que ese módulo importe —y lo que importen sus importaciones— pasa también a ser código de cliente.

flowchart TB
    subgraph SERVIDOR["Zona de SERVIDOR · 0 KB al navegador"]
        L["layout.jsx"] --> P["page.jsx"]
        P --> LB["ListaBicicletas"]
        LB --> TB["TarjetaBicicleta"]
        TB --> EE["EtiquetaEstado"]
    end

    P --> ST["'use client'<br/>SelectorTipo"]
    L --> BT["'use client'<br/>BotonTema"]

    subgraph CLIENTE["Zona de CLIENTE · se empaqueta e hidrata"]
        ST --> UP["utilidades/parametros.js"]
        BT --> CT["contextos/ProveedorTema"]
    end

Consecuencia práctica muy importante: poner 'use client' en la plantilla raíz convierte toda la aplicación en cliente. Todo el árbol pasa a empaquetarse, y las ventajas del modelo desaparecen sin ningún aviso. Es el error número uno de quien empieza, y por eso el apartado 14 se dedica a colocar bien la frontera.

Dos precisiones para no exagerar en el sentido contrario:

  • Un componente de cliente también se renderiza en el servidor para producir el HTML inicial. «De cliente» significa «además se ejecuta en el cliente», no «solo en el cliente». Por eso las reglas de hidratación de 10-01 le siguen aplicando: nada de localStorage durante el render.
  • 'use client' no hay que repetirlo en cada fichero del subárbol. Basta ponerlo en el punto de entrada; lo importado hereda la condición. Ponerlo de más no rompe nada, pero enturbia dónde está la frontera real.

  1. Qué cruza la frontera y qué no

Cuando un componente de servidor renderiza uno de cliente, las props tienen que serializarse para viajar en el payload RSC. De ahí sale una regla estricta.

Tipo de prop ¿Cruza? Nota
string, number, boolean, null, undefined
Arrays y objetos planos Si su contenido también es serializable
Date, Map, Set, BigInt, TypedArray React los serializa
Promesas El cliente las consume con use (apartado 5)
Funciones Salvo las acciones de servidor (apartado 15)
Clases e instancias
Elementos JSX (<p>Hola</p>) Incluido children: la excepción clave
Símbolos

El caso que rompe más código es el de las funciones. Esto no funciona:

// ERROR: no se puede pasar una función de servidor a un componente de cliente
export default async function PaginaCatalogo() {
  const bicicletas = await obtenerBicicletas();

  function manejarSeleccion(id) {  // esta función vive en el servidor
    console.log(id);
  }

  return <ListaInteractiva bicicletas={bicicletas} alSeleccionar={manejarSeleccion} />;
}

ListaInteractiva es de cliente y manejarSeleccion es una función: no hay forma de serializarla. React lanza un error explícito. La solución es que el manejador se defina dentro del componente de cliente, que es donde tiene sentido: el servidor no puede reaccionar a un clic.

children: la excepción que lo cambia todo

Ahora la pieza más importante del apartado, y la que suele desbloquear la comprensión del modelo.

Parece que un componente de cliente solo puede contener componentes de cliente: si importa un componente, ese componente cruza a su lado de la frontera. Pero hay una vía de escape:

Un componente de servidor puede pasarse como children (o como cualquier prop de tipo JSX) a un componente de cliente.

Funciona porque quien lo renderiza es el padre de servidor, no el componente de cliente. Este último recibe el resultado ya renderizado y se limita a colocarlo en un hueco. Nunca importa su código, así que ese código no se empaqueta.

// src/componentes/Panel.jsx — DE CLIENTE: tiene estado (plegar/desplegar)
'use client';

import { useState } from 'react';

export default function Panel({ titulo, children }) {
  const [abierto, setAbierto] = useState(true);

  return (
    <section>
      <button onClick={() => setAbierto(!abierto)} aria-expanded={abierto}>
        {titulo}
      </button>
      {abierto && <div>{children}</div>}
    </section>
  );
}
// src/app/estaciones/[estacionId]/page.jsx — DE SERVIDOR
import Panel from '@/componentes/Panel';
import ListaBicicletas from '@/componentes/ListaBicicletas'; // ¡de servidor!

export default async function PestanaFlota({ params }) {
  const { estacionId } = await params;
  const bicicletas = await obtenerFlota(estacionId);

  return (
    <Panel titulo="Flota aparcada">
      {/* ListaBicicletas es de SERVIDOR y vive dentro de un componente de CLIENTE */}
      <ListaBicicletas bicicletas={bicicletas} />
    </Panel>
  );
}

Lo que se envía al navegador es Panel (1 KB con su useState) y el HTML ya renderizado de la lista. ListaBicicletas, TarjetaBicicleta, EtiquetaEstado y la lógica de formateo se quedan en el servidor. Con la aproximación ingenua —importar ListaBicicletas dentro de Panel.jsx— todo ese subárbol habría cruzado la frontera.

Y fíjate en que esto no es una técnica nueva: es la composición del módulo 4, «composición frente a herencia», con children como hueco. Aquella lección defendía el patrón por flexibilidad y desacoplamiento; en el modelo RSC pasa a tener además una consecuencia directa sobre el peso del paquete.

  1. Colocar la frontera lo más abajo posible

La regla se resume en una frase: 'use client' va tan cerca de la interactividad como sea posible.

Procedimiento para aplicarla a un componente cualquiera:

  1. ¿Usa useState, useReducer, useEffect, useRef o algún hook de cliente? → cliente.
  2. ¿Tiene manejadores de eventos (onClick, onChange, onSubmit)? → cliente.
  3. ¿Usa APIs del navegador (window, localStorage, IntersectionObserver)? → cliente.
  4. ¿Usa contexto? → cliente (tanto el proveedor como el consumidor).
  5. Si no es ninguna de las anteriores → servidor, aunque «parezca» un componente normal.

Aplicado al catálogo de CicloUrbano:

Componente Tipo Motivo
PaginaCatalogo Servidor Solo pide datos y compone
ListaBicicletas Servidor Solo recorre un array
TarjetaBicicleta Servidor Solo pinta; el enlace es un <Link>, que no necesita estado
EtiquetaEstado Servidor Solo mapea estado a color y texto
SelectorTipo Cliente onChange + useRouter
BotonTema Cliente useContext + onClick
MenuUsuario Cliente Estado de apertura + onClick fuera
Modal, DialogoReserva Cliente Estado, foco, tecla Escape
ResumenFlota Servidor Cálculo puro sobre los datos

El caso interesante es TarjetaBicicleta. Es tentador marcarla de cliente porque «es interactiva»: se puede pulsar. Pero lo que se pulsa es un <Link>, y <Link> gestiona su propia interactividad. La tarjeta en sí no tiene estado propio.

Si más adelante hiciera falta un botón de «guardar en favoritos», la solución correcta no es marcar la tarjeta entera como cliente, sino extraer el botón:

// src/componentes/TarjetaBicicleta.jsx — SERVIDOR
import Link from 'next/link';
import EtiquetaEstado from './EtiquetaEstado';
import BotonFavorito from './BotonFavorito'; // de cliente, isla mínima
import estilos from './TarjetaBicicleta.module.css';

export default function TarjetaBicicleta({ bicicleta }) {
  return (
    <article className={estilos.tarjeta}>
      <Link href={`/bicicletas/${bicicleta.id}`}>
        <h3>{bicicleta.modelo}</h3>
      </Link>
      <EtiquetaEstado estado={bicicleta.estado} />
      <p>{bicicleta.precioHora.toFixed(2)} €/h</p>
      <BotonFavorito bicicletaId={bicicleta.id} />
    </article>
  );
}
// src/componentes/BotonFavorito.jsx — CLIENTE, y solo esto
'use client';

import { useAlmacenLocal } from '@/hooks/useAlmacenLocal';

export default function BotonFavorito({ bicicletaId }) {
  const [favoritos, setFavoritos] = useAlmacenLocal('favoritos', []);
  const esFavorita = favoritos.includes(bicicletaId);

  function manejarClic() {
    setFavoritos(
      esFavorita
        ? favoritos.filter((id) => id !== bicicletaId)
        : [...favoritos, bicicletaId]
    );
  }

  return (
    <button onClick={manejarClic} aria-pressed={esFavorita}>
      {esFavorita ? '★ Guardada' : '☆ Guardar'}
    </button>
  );
}

Con veinte tarjetas en pantalla, al navegador llegan veinte instancias de un botón de 300 bytes en lugar de veinte tarjetas completas con sus estilos, su lógica y sus dependencias. Y useAlmacenLocal, el hook propio del módulo 5, se reutiliza tal cual: es de cliente, y ahora está en el lado correcto de la frontera.

  1. Acciones de servidor: 'use server'

Falta el camino de vuelta. Los componentes de servidor pintan datos, pero ¿cómo se envían datos desde el navegador sin escribir un endpoint, un fetch y su gestión de estado?

Una acción de servidor es una función async marcada con 'use server' que se define en el servidor y se puede invocar desde el cliente. React y el framework se encargan del transporte: el cliente recibe una referencia, no el código.

// src/acciones/reservas.js
'use server';

import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers';
import { validarReserva } from '@/utilidades/validarReserva';

export async function crearReserva(estadoPrevio, datosFormulario) {
  // 1. Autorización EN EL SERVIDOR. Nunca confíes en el cliente.
  const almacen = await cookies();
  const sesion = almacen.get('sesion_ciclourbano');
  if (!sesion) {
    return { ok: false, errores: { general: 'Debes iniciar sesión.' } };
  }

  // 2. Extraer los datos del FormData.
  const datos = {
    bicicletaId: datosFormulario.get('bicicletaId'),
    fechaInicio: datosFormulario.get('fechaInicio'),
    horas: Number(datosFormulario.get('horas')),
  };

  // 3. VALIDAR EN EL SERVIDOR, con la misma utilidad del módulo 3.
  const bicicletas = await fetch('http://localhost:3001/bicicletas').then((r) => r.json());
  const errores = validarReserva(datos, bicicletas);
  if (Object.keys(errores).length > 0) {
    return { ok: false, errores, datos };
  }

  // 4. Escribir.
  const respuesta = await fetch('http://localhost:3001/reservas', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ...datos, usuario: JSON.parse(sesion.value).id, estado: 'activa' }),
  });
  if (!respuesta.ok) {
    return { ok: false, errores: { general: 'No se ha podido crear la reserva.' } };
  }

  // 5. Invalidar la caché afectada (10-02).
  revalidateTag(`bicicleta-${datos.bicicletaId}`);
  revalidateTag('bicicletas');

  return { ok: true, errores: {} };
}

Y el formulario que la usa, con los dos hooks de React 19 pensados para esto:

// src/componentes/FormularioReserva.jsx
'use client';

import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { crearReserva } from '@/acciones/reservas';

function BotonEnviar() {
  // useFormStatus lee el estado del <form> ANCESTRO:
  // por eso tiene que estar en un componente hijo, no en el que declara el form.
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Reservando…' : 'Confirmar reserva'}
    </button>
  );
}

export default function FormularioReserva({ bicicleta }) {
  const [estado, accion, enviando] = useActionState(crearReserva, {
    ok: false,
    errores: {},
  });

  return (
    <form action={accion}>
      <input type="hidden" name="bicicletaId" value={bicicleta.id} />

      <label>
        Fecha de inicio
        <input type="datetime-local" name="fechaInicio" required />
      </label>
      {estado.errores.fechaInicio && (
        <p role="alert">{estado.errores.fechaInicio}</p>
      )}

      <label>
        Horas
        <input type="number" name="horas" min="1" max="8" defaultValue="1" />
      </label>
      {estado.errores.horas && <p role="alert">{estado.errores.horas}</p>}

      {estado.errores.general && <p role="alert">{estado.errores.general}</p>}
      {estado.ok && <p role="status">Reserva confirmada.</p>}

      <BotonEnviar />
    </form>
  );
}

Lo que hay que entender de este código:

  • action={accion} en lugar de onSubmit. React intercepta el envío, serializa el FormData y llama a la acción de servidor. Si el JavaScript aún no ha cargado, el formulario se envía como un formulario HTML de toda la vida y funciona igualmente: es mejora progresiva real.
  • useActionState(accion, estadoInicial) devuelve [estado, accionEnvuelta, enviando]. El estado es lo que la acción devuelve, y por eso la acción recibe estadoPrevio como primer parámetro.
  • useFormStatus se importa de react-dom y solo funciona en un componente hijo del <form>. Si lo llamas en FormularioReserva, pending será siempre false. Es el fallo más habitual con este hook.
  • La validación se hace en el servidor. Puedes duplicarla en el cliente para dar respuesta inmediata —y validarReserva del módulo 3 sirve en los dos sitios—, pero la del servidor no es opcional.

Advertencia de seguridad. Una acción de servidor es un endpoint HTTP público. React genera una URL para ella, y cualquiera puede invocarla con cualquier carga útil. Toda acción debe autenticar, autorizar y validar por su cuenta, exactamente como harías en una API REST. Que la función esté escrita al lado del componente no la protege de nada.

Comparación con lo que hacía la SPA en el módulo 7:

SPA (Vite) Acción de servidor
Definir el endpoint json-server o una API propia La propia función
Llamarla fetch en un mutationFn action={accion}
Estado de envío isPending de useMutation useFormStatus / useActionState
Invalidar la caché queryClient.invalidateQueries revalidateTag
Sin JavaScript No funciona Funciona
Dónde valida Cliente y servidor (dos códigos) Servidor (un código, reutilizable)

  1. Qué cambia respecto a los módulos 5 a 7 y qué no

Cierre honesto, porque a estas alturas es legítimo preguntarse qué queda en pie de lo aprendido.

Lo que no cambia en absoluto:

  • JSX, props, composición, listas y claves. Idénticos en los dos lados de la frontera.
  • Todos los hooks, dentro de componentes de cliente. useState, useEffect, useRef, useReducer, useContext, useMemo, useCallback y los hooks propios de CicloUrbano funcionan exactamente igual.
  • Los límites de error de 04-05, ahora emparejados con Suspense.
  • Las reglas de hidratación, que en realidad son más estrictas que antes.
  • La accesibilidad del módulo 3 y las pruebas del módulo 9: data-testid, roles y consultas de Testing Library siguen siendo la forma de probar la interfaz.
  • El rendimiento del módulo 8: memo, useMemo y useCallback siguen aplicando dentro de las islas de cliente.

Lo que cambia de sitio:

Módulo Herramienta Dónde queda en el modelo RSC
5 useEffect para pedir datos Sustituido por async/await en el servidor
6 React Router Sustituido por el enrutamiento por carpetas
7 Contexto Solo en componentes de cliente; el proveedor lleva 'use client'
7 Redux Toolkit Solo para estado de cliente; el estado del servidor deja de necesitarlo
7 TanStack Query Innecesario en componentes de servidor; imprescindible en las islas de cliente y en la SPA
8 lazy + Suspense Sigue vigente para el código de cliente; para el de servidor no aplica, porque no se envía

Y la lectura correcta de esa tabla: nada de lo aprendido se ha desperdiciado. En CicloUrbano, la SPA de Vite con Redux Toolkit, TanStack Query y React Router sigue siendo el proyecto principal, y el escaparate Next.js es una capa pública encima. Lo que aporta este módulo es criterio: saber que hay dos modelos, cuál conviene a cada pantalla, y que la mayor parte del conocimiento se transfiere entre ambos.

Errores Comunes y Consejos

  • Crear la promesa dentro del componente que hace use(). Bucle infinito garantizado. La promesa la crea un ancestro, un componente de servidor o una caché.
  • Poner 'use client' en layout.jsx para «arreglar» un error. Convierte toda la aplicación en cliente y anula el modelo. Busca el componente concreto que necesita interactividad y márcalo a él.
  • Pasar una función como prop de servidor a cliente. No es serializable. El manejador se define dentro del componente de cliente, o se convierte en una acción de servidor.
  • Importar un componente de servidor dentro de un fichero con 'use client'. Deja de ser de servidor. Pásalo como children o como prop JSX.
  • Llamar a useFormStatus en el mismo componente que declara el <form>. Devuelve siempre pending: false. Tiene que ir en un hijo.
  • Confiar en la validación del cliente en una acción de servidor. La acción es un endpoint público: autentica, autoriza y valida siempre en el servidor.
  • Poner el Suspense por fuera del LimiteDeError. El orden correcto es error fuera, suspensión dentro.
  • Usar un único <Suspense> en la raíz. Equivale a esperar a lo más lento y desaprovecha el streaming. Un límite por bloque con sentido propio.
  • Sustituir contenido visible por un fallback al filtrar. Envuelve la actualización en useTransition y da una señal suave con isPending.
  • Consejo: piensa el árbol en dos colores. Pinta mentalmente de un color lo que solo muestra datos y de otro lo que reacciona al usuario. La frontera está justo donde cambia el color, y casi siempre está más abajo de lo que parece.
  • Consejo: comprueba el resultado con el inspector de red. En la pestaña de red, un componente de servidor bien colocado hace que el JavaScript de la ruta baje de forma visible. Es el equivalente al Profiler de 08-05 para este modelo.

Ejercicios

Ejercicio 1. Para cada uno de estos fragmentos, di si es correcto y, si no lo es, explica el error y corrígelo.

// A
'use client';
import { cookies } from 'next/headers';

export default async function MenuUsuario() {
  const sesion = (await cookies()).get('sesion_ciclourbano');
  return <span>{sesion ? JSON.parse(sesion.value).nombre : 'Invitado'}</span>;
}
// B
export default async function PaginaCatalogo() {
  const bicicletas = await obtenerBicicletas();
  return (
    <ListaFiltrable
      bicicletas={bicicletas}
      alFiltrar={(tipo) => bicicletas.filter((b) => b.tipo === tipo)}
    />
  );
}
// C
function DatosEstacion({ estacionId }) {
  const estacion = use(fetch(`/api/estaciones/${estacionId}`).then((r) => r.json()));
  return <h2>{estacion.nombre}</h2>;
}

Ejercicio 2. La ficha de bici-002 tiene tres bloques con tiempos muy distintos: los datos de la bicicleta (30 ms, cacheados), la disponibilidad en vivo (800 ms) y las tres últimas valoraciones de usuarios (1.500 ms). Diseña la estructura de <Suspense> y LimiteDeError que dé la mejor experiencia posible, escribe el código de la página y explica en qué orden ve el usuario cada cosa.

Ejercicio 3. Este PanelReservas es de cliente y arrastra medio catálogo al navegador. Reorganízalo aplicando la regla de la frontera más baja y la excepción de children, e indica qué código deja de enviarse.

'use client';

import { useState } from 'react';
import ListaReservas from './ListaReservas';
import ResumenFlota from './ResumenFlota';
import EtiquetaEstado from './EtiquetaEstado';
import { formatearFechaLarga } from '../utilidades/fechas'; // 45 KB

export default function PanelReservas({ reservas, flota }) {
  const [pestana, setPestana] = useState('activas');
  const visibles = reservas.filter((r) =>
    pestana === 'activas' ? r.estado === 'activa' : r.estado !== 'activa'
  );

  return (
    <section>
      <button onClick={() => setPestana('activas')}>Activas</button>
      <button onClick={() => setPestana('historial')}>Historial</button>
      <ResumenFlota flota={flota} />
      <ListaReservas reservas={visibles} formatear={formatearFechaLarga} />
      <EtiquetaEstado estado="disponible" />
    </section>
  );
}

Soluciones

Solución 1.

A — Incorrecto. Dos errores encadenados: un componente de cliente no puede ser async y no puede usar cookies(), que es una API de servidor. La corrección es quitar 'use client' y dejarlo como componente de servidor; no necesita interactividad para nada.

// Sin 'use client': componente de servidor
import { cookies } from 'next/headers';

export default async function MenuUsuario() {
  const sesion = (await cookies()).get('sesion_ciclourbano');
  return <span>{sesion ? JSON.parse(sesion.value).nombre : 'Invitado'}</span>;
}

Si además necesitara un desplegable con estado, se extraería ese desplegable a un componente de cliente que reciba el nombre ya resuelto como prop.

B — Incorrecto. alFiltrar es una función definida en un componente de servidor y pasada a uno de cliente: no es serializable. Además el filtrado es lógica de interfaz y pertenece al cliente.

// Servidor: solo pasa datos serializables
export default async function PaginaCatalogo() {
  const bicicletas = await obtenerBicicletas();
  return <ListaFiltrable bicicletas={bicicletas} />;
}
// Cliente: el filtrado vive aquí
'use client';
import { useState } from 'react';

export default function ListaFiltrable({ bicicletas }) {
  const [tipo, setTipo] = useState('todos');
  const visibles = tipo === 'todos'
    ? bicicletas
    : bicicletas.filter((b) => b.tipo === tipo);
  // ...
}

Alternativa preferible en el escaparate: mantener el filtro en la URL con ?tipo= y filtrar en el servidor, como en 10-01.

C — Incorrecto. La promesa se crea dentro del propio componente: cada reintento crea una promesa nueva y el componente suspende para siempre. Además falta el <Suspense> que muestre el fallback.

// El padre crea la promesa y no la espera.
export default async function PaginaDetalleEstacion({ params }) {
  const { estacionId } = await params;
  const promesaEstacion = obtenerEstacion(estacionId); // sin await

  return (
    <Suspense fallback={<p>Cargando estación…</p>}>
      <DatosEstacion promesaEstacion={promesaEstacion} />
    </Suspense>
  );
}

function DatosEstacion({ promesaEstacion }) {
  const estacion = use(promesaEstacion);
  return <h2>{estacion.nombre}</h2>;
}

Solución 2. Cada bloque lento va en su propio límite, y cada límite se empareja con su límite de error para que un fallo de valoraciones no tumbe la disponibilidad.

// src/app/bicicletas/[bicicletaId]/page.jsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import LimiteDeError from '@/componentes/LimiteDeError';
import EtiquetaEstado from '@/componentes/EtiquetaEstado';

export default async function PaginaFichaBicicleta({ params }) {
  const { bicicletaId } = await params;
  const bicicleta = await obtenerBicicleta(bicicletaId); // 30 ms, cacheado
  if (!bicicleta) notFound();

  return (
    <article>
      <h1>{bicicleta.modelo}</h1>
      <EtiquetaEstado estado={bicicleta.estado} />
      <p>{bicicleta.precioHora.toFixed(2)} €/h · {bicicleta.tipo}</p>

      <LimiteDeError titulo="Disponibilidad no disponible ahora mismo">
        <Suspense fallback={<p>Consultando disponibilidad…</p>}>
          <BloqueDisponibilidad bicicletaId={bicicletaId} />
        </Suspense>
      </LimiteDeError>

      <LimiteDeError titulo="No se han podido cargar las valoraciones">
        <Suspense fallback={<EsqueletoValoraciones filas={3} />}>
          <BloqueValoraciones bicicletaId={bicicletaId} />
        </Suspense>
      </LimiteDeError>
    </article>
  );
}

Orden de aparición para el usuario:

Momento Qué se ve
~50 ms Título, estado, precio y los dos esqueletos
~850 ms Se rellena la disponibilidad; las valoraciones siguen en esqueleto
~1.550 ms Aparecen las valoraciones

Decisiones clave: los datos rápidos se esperan con await para que el marco de la página llegue completo; los lentos van cada uno en su límite, así el bloque de 800 ms no queda retenido por el de 1.500 ms; y dos límites de error separados garantizan aislamiento de fallos. Poner los dos bloques bajo un mismo <Suspense> haría que la disponibilidad esperase innecesariamente a las valoraciones.

Solución 3. Lo único que necesita el cliente es el estado de la pestaña. Todo lo demás puede quedarse en el servidor.

// src/componentes/PestanasReservas.jsx — CLIENTE, isla mínima
'use client';

import { useState } from 'react';

export default function PestanasReservas({ resumen, activas, historial }) {
  const [pestana, setPestana] = useState('activas');

  return (
    <section>
      <div role="tablist">
        <button role="tab" aria-selected={pestana === 'activas'}
                onClick={() => setPestana('activas')}>Activas</button>
        <button role="tab" aria-selected={pestana === 'historial'}
                onClick={() => setPestana('historial')}>Historial</button>
      </div>
      {resumen}
      {pestana === 'activas' ? activas : historial}
    </section>
  );
}
// src/app/reservas/page.jsx — SERVIDOR
import PestanasReservas from '@/componentes/PestanasReservas';
import ListaReservas from '@/componentes/ListaReservas';   // servidor
import ResumenFlota from '@/componentes/ResumenFlota';     // servidor

export default async function PanelReservas() {
  const [reservas, flota] = await Promise.all([obtenerReservas(), obtenerFlota()]);
  const activas = reservas.filter((r) => r.estado === 'activa');
  const historial = reservas.filter((r) => r.estado !== 'activa');

  return (
    <PestanasReservas
      resumen={<ResumenFlota flota={flota} />}
      activas={<ListaReservas reservas={activas} />}
      historial={<ListaReservas reservas={historial} />}
    />
  );
}

Qué deja de enviarse al navegador: ListaReservas, ResumenFlota, EtiquetaEstado y, sobre todo, los 45 KB de utilidades/fechas, que ahora se ejecuta solo en el servidor. Al cliente llegan unos cientos de bytes con el useState de la pestaña, más el HTML ya renderizado de los tres bloques.

Dos matices que merecen atención. Primero: las dos listas se renderizan siempre, aunque solo se vea una; si el historial fuera muy grande, convendría convertir cada pestaña en una ruta propia y dejar que Next.js pida solo la visible. Segundo: aquí children se usa a través de tres props JSX con nombre (resumen, activas, historial), no de un único children. La excepción de la frontera aplica igual a cualquier prop que contenga JSX ya renderizado, y esto es exactamente el patrón de «huecos con nombre» del módulo 4.

Conclusión

Esta lección ha explicado el modelo que sostenía las dos anteriores, y ha saldado la deuda que el módulo 8 dejó abierta.

De Suspense, lo esencial es el mecanismo: un componente que durante el render intenta leer algo que aún no está disponible se suspende, y el <Suspense> más cercano hacia arriba muestra su fallback hasta que el recurso llega, momento en el que React reintenta el render. De ahí se deduce todo lo demás: que el componente no tiene ni necesita un estado de carga; que quien decide la interfaz de espera es un ancestro; que un límite define una unidad de espera y por eso conviene uno por bloque de contenido con sentido propio; y que la promesa nunca puede crearse en el componente que la consume, so pena de bucle infinito. lazy de 08-04 era simplemente el primer consumidor de este mecanismo, con espera por código; el hook use de React 19 y useSuspenseQuery son los mismos con espera por datos, y en un componente de servidor el propio await es la suspensión.

Alrededor del mecanismo quedan fijadas tres prácticas: el streaming del HTML, por el que el servidor manda el marco y luego los trozos que faltan sin cerrar la conexión —mejorando TTFB, FCP y LCP a la vez, y permitiendo hidratación progresiva—; la pareja LimiteDeError fuera, Suspense dentro, que cubre los tres estados de una zona sin escribir un solo if; y useTransition para que una actualización posterior no borre contenido ya visible: fallback la primera vez, transición las siguientes.

De los React Server Components, lo que hay que retener es que RSC no es SSR. SSR es un momento; RSC es un lugar. Un componente de servidor se ejecuta solo en el servidor, envía su resultado por el cable y no suma nada al paquete de JavaScript, ni él ni sus dependencias —la solución más radical al problema de tamaño del módulo 8: no dividir el código, sino no enviarlo—. Un componente de cliente se ejecuta en los dos sitios, se hidrata, y es el único que puede tener estado, efectos, eventos y APIs del navegador. La directiva 'use client' no marca un componente sino una frontera: todo lo que ese módulo importa cruza con él, y de ahí la regla de colocarla lo más abajo posible, con BotonFavorito como isla en lugar de TarjetaBicicleta entera. Cruzan la frontera las props serializables y el JSX ya renderizado; no cruzan las funciones ni las clases. Y la excepción decisiva es children —o cualquier prop JSX con nombre—, que permite meter un componente de servidor dentro de uno de cliente: la composición del módulo 4, con una consecuencia nueva sobre el peso del paquete.

Por último, las acciones de servidor con 'use server' cierran el camino de vuelta: funciones async invocables desde el formulario con action={accion}, con useActionState para el resultado y useFormStatus —en un hijo del <form>, nunca en el mismo componente— para el estado de envío; funcionan sin JavaScript, y validarReserva del módulo 3 se reutiliza intacta. Con una advertencia que no admite matices: una acción de servidor es un endpoint público y debe autenticar, autorizar y validar por su cuenta.

Y el balance: nada de lo aprendido se ha desperdiciado. Los hooks, la composición, los límites de error, la accesibilidad, el rendimiento y las pruebas siguen valiendo; lo que cambia es dónde vive cada cosa. Recuerda además lo dicho en 10-02: el proyecto del Módulo 11 se construye con Vite + React Router, la aplicación que llevas montando desde el principio.

Queda una capa de la que se ha hablado dos veces sin desarrollarla. En 09-01 se colocó el análisis estático en la base de la pirámide de pruebas, como el nivel más barato, y se dijo que TypeScript se vería aquí. En este módulo, además, has visto contratos por todas partes: qué props cruzan una frontera, qué forma tiene el objeto que devuelve una acción, qué campos trae una Bicicleta. Todos esos contratos son hoy implícitos: viven en la cabeza de quien escribió el componente y solo se comprueban cuando algo falla en ejecución. La próxima lección los hace explícitos y los comprueba mientras escribes. La próxima lección es TypeScript con React.

Curso de React

Módulo 1: Introducción a React

Módulo 2: Componentes de React

Módulo 3: Trabajando con Eventos

Módulo 4: Conceptos Avanzados de Componentes

Módulo 5: Hooks de React

Módulo 6: Enrutamiento en React

Módulo 7: Gestión del Estado

Módulo 8: Optimización del Rendimiento

Módulo 9: Pruebas en React

Módulo 10: Temas Avanzados

Módulo 11: Proyecto: Construyendo una Aplicación Completa

© Copyright 2026. Todos los derechos reservados