La arquitectura de React Server Components (RSC) representa un replanteamiento fundamental del modelo de ejecución de las interfaces de usuario orientadas a componentes. Antes de RSC, las arquitecturas tradicionales SPA y SSR imponían un compromiso inevitable: cualquier componente escrito por un desarrollador se empaquetaba irremediablemente en el bundle JavaScript del cliente. Si una página requería procesar un documento Markdown o formatear fechas con amplios conjuntos de localización, bibliotecas de cientos de kilobytes (marked, date-fns, highlight.js) se enviaban al navegador, ralentizando las fases de descarga (Download), parseo (Parse) y compilación (Compile) del motor V8.
React 19 consolida la especificación de componentes con tamaño de bundle cero (Zero-Bundle-Size):
Separar los componentes en entornos de servidor y cliente resuelve cuatro cuellos de botella fundamentales en la ingeniería frontend:
pg), criptografía, parseo de Markdown o validaciones de dominio complejas se ejecutan en el backend y quedan físicamente excluidas del navegador.async function ProductPage()) y consultar bases de datos directamente mediante await db.query(), eliminando el boilerplate de cientos de endpoints intermedios.A diferencia del SSR clásico, cuyo resultado es una cadena HTML plana y estática, el renderizador RSC genera un flujo de instrucciones tipo JSONL: el React Flight Stream.
El protocolo Flight transmite la estructura semántica del árbol DOM virtual junto con puntos de anclaje explícitos para componentes interactivos (Client References).
El aislamiento de entornos se garantiza mediante Conditional Exports en la especificación package.json de Node.js. Al empaquetar la capa de servidor, los bundlers activan la condición de exportación "react-server", la cual elimina del ámbito de importación los hooks de estado cliente (useState, useEffect, useLayoutEffect), convirtiendo su uso accidental en un error inmediato en tiempo de compilación.
El navegador envía la petición HTTP. El servidor activa el entorno react-server e inicia el renderizado asíncrono del RSC raíz.
Al encontrar un componente cliente, el servidor emite un chunk M con metadatos del bundle e inserta un marcador $L.
Los chunks J0, J1 y las promesas @ se envían al navegador a medida que están listos, sin esperar consultas lentas.
El navegador lee las líneas M e inicia la descarga de los archivos JS de cliente antes de que termine el flujo completo.
El motor cliente deserializa el grafo Flight y lo monta de manera transparente sobre el árbol Fiber activo.
¿Cuál es la diferencia fundamental entre los datos transmitidos por el protocolo Flight y la respuesta de un SSR clásico?
El procesamiento matemático de grandes volúmenes de datos, el parseo de fórmulas y la generación de grafos se ejecutan en el servidor. Librerías de decenas de megabytes quedan completamente excluidas del bundle de cliente.
Al pasar a otra ruta, solo se solicita el flujo Flight de la nueva pantalla. El React de cliente compara el nuevo árbol con el actual y actualiza exclusivamente los nodos DOM modificados, sin interrumpir reproducción multimedia, scroll o estados de formularios.
Un componente de servidor puede pasar una promesa no resuelta directamente como prop a un componente cliente. El cliente desenvuelve la promesa mediante el hook use(promise) dentro de un límite <Suspense>, mostrando esqueletos de carga de inmediato.
Un componente cliente (como un modal o un sistema de pestañas) gestiona la interactividad de apertura/cierre, mientras que el contenido pesado interior se pasa como slot children de servidor sin filtrar dependencias al bundle.
Ejecución de funciones asíncronas de servidor. Consultas directas a BD. Construcción del árbol virtual con marcas de Client Reference.
Generación del flujo de texto: nodos JSX con prefijo J, módulos cliente con M y promesas diferidas con @.
Transmisión de chunks por HTTP con Transfer-Encoding: chunked. El navegador inicia el parseo antes de que finalice la página.
Reconstrucción del árbol virtual. Resolución de referencias $L mediante el manifiesto del bundler. Montaje en Fiber.
El protocolo Flight transmite datos línea a línea separados por saltos de línea \n. Cada registro comienza con un identificador y tipo de operación:
| Prefijo / Símbolo | Propósito | Ejemplo de registro |
| :--- | :--- | :--- |
| M (Module) | Client Reference: ruta del archivo, exportación y chunks JS necesarios | M1:{"id":"./components/Cart.tsx","name":"default","chunks":["client1"]} |
| J (JSON Tree) | Árbol virtual serializado con nodos JSX y propiedades | J0:["$","div",null,{"className":"container","children":"$@2"}] |
| $ (React Element) | Marcador de elemento virtual JSX (análogo a Symbol.for('react.transitional.element')) | ["$","span",null,{"children":"Hello"}] |
| $L (Lazy Client) | Sustitución de componente cliente por su identificador en el registro M | ["$","$L1",null,{"count":0}] |
| $S (Suspense) | Límite de espera Suspense | "$Sreact.suspense" |
| @ (Promise) | Referencia a valor diferido o promesa (Thenable) | J0:{"analytics":"$@3"} |
| P (Promise Resolve) | Resolución de promesa con el valor definitivo | 3:{"views":1240,"conversions":42} |
Cuando el compilador detecta la directiva 'use client', sustituye el módulo original por un Client Reference Proxy.
Componente interactivo original:
'use client';
import React, { useState } from 'react';
export default function InteractiveCounter({ step = 1 }: { step?: number }) {
const [count, setCount] = useState(0);
return (
<button
onClick={() => setCount(c => c + step)}
className="px-3 py-1 bg-accent-violet text-white font-mono rounded cursor-pointer"
>
Clics: {count}
</button>
);
}El componente de servidor lo importa, pero el empaquetador (Turbopack o Webpack) reemplaza en el entorno de servidor el cuerpo del componente por un objeto proxy:
// Objeto proxy generado por el bundler en el entorno react-server
export default {
$$typeof: Symbol.for('react.client.reference'),
$$id: './src/components/InteractiveCounter.tsx#default',
$$async: false,
};Cuando el renderizador de servidor procesa un elemento con Symbol.for('react.client.reference'), no ejecuta la función del componente. En su lugar, emite al flujo Flight la línea de manifiesto M con la URL del chunk cliente y coloca el marcador $L en la estructura virtual.
Importar un Server Component directamente dentro de un Client Component está terminantemente prohibido (el bundler convertiría recursivamente el archivo importado en cliente). La arquitectura canónica es la inyección mediante props (Slot Pattern):
// 1. Client Component: contenedor interactivo tipo acordeón
'use client';
import React, { useState } from 'react';
interface AccordionProps {
title: string;
children: React.ReactNode; // Slot de servidor
}
export function Accordion({ title, children }: AccordionProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="border border-border rounded-lg overflow-hidden my-4">
<button
onClick={() => setIsOpen(prev => !prev)}
className="w-full text-left p-4 bg-bg-elevated font-semibold flex justify-between items-center cursor-pointer"
>
<span>{title}</span>
<span>{isOpen ? '▲' : '▼'}</span>
</button>
{isOpen && <div className="p-4 bg-bg-surface">{children}</div>}
</div>
);
}
// 2. Server Component: contenido pesado generado en react-server
import { readFile } from 'node:fs/promises';
async function HeavyServerReport({ filename }: { filename: string }) {
// Acceso directo al sistema de archivos en el backend
const rawData = await readFile(`./reports/${filename}`, 'utf-8');
return <div className="prose font-mono text-sm">{rawData}</div>;
}
// 3. Composición en servidor: Donut Pattern
export async function ReportPage() {
return (
<main className="p-8">
<h1 className="text-2xl font-bold mb-4">Informe Financiero</h1>
{/*
Accordion se ejecuta en el cliente, pero HeavyServerReport
se renderiza en el servidor y se pasa como slot Flight children!
*/}
<Accordion title="Mostrar auditoría detallada">
<HeavyServerReport filename="q3-audit.log" />
</Accordion>
</main>
);
}¿Por qué pasar una función de flecha onClick={() => alert('ok')} desde un Server Component a un Client Component provoca un error fatal?
Para depurar respuestas de red y verificar los datos transmitidos a los componentes cliente, implementamos una utilidad de captura e inspección de chunks Flight:
interface DecodedFlightRecord {
type: 'CLIENT_MODULE' | 'JSX_TREE' | 'PROMISE_RESOLVE' | 'UNKNOWN';
id: string;
rawPayload: string;
sizeBytes: number;
}
export async function inspectFlightStream(
stream: ReadableStream<Uint8Array>
): Promise<DecodedFlightRecord[]> {
const reader = stream.getReader();
const decoder = new TextDecoder();
const records: DecodedFlightRecord[] = [];
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunkText = decoder.decode(value, { stream: true });
const lines = chunkText.split('\n').filter(l => l.trim().length > 0);
for (const line of lines) {
const colonPos = line.indexOf(':');
if (colonPos === -1) continue;
const tag = line.slice(0, colonPos);
const payload = line.slice(colonPos + 1);
let type: DecodedFlightRecord['type'] = 'UNKNOWN';
if (tag.startsWith('M')) type = 'CLIENT_MODULE';
else if (tag.startsWith('J')) type = 'JSX_TREE';
else if (!isNaN(Number(tag))) type = 'PROMISE_RESOLVE';
records.push({
type,
id: tag,
rawPayload: payload,
sizeBytes: new TextEncoder().encode(line).length,
});
}
}
} finally {
reader.releaseLock();
}
return records;
}Dado que la frontera entre RSC y Client Components se comunica a través del protocolo Flight por la red, todas las props deben ser serializables. Intentar pasar funciones anónimas (onClick={() => {}}), instancias de clases complejas con métodos o símbolos (Symbol) provocará un fallo irrecuperable en el serializador de Flight.
// ❌ ERROR: pasar una clausura anónima desde RSC a un componente cliente
export async function ServerPage() {
const handleAction = () => {
console.log('Click');
};
return <ClientButton onClick={handleAction} />; // Fallo: Las funciones no pueden enviarse directamente a Client Components
}
// ✅ CORRECTO: pasar una Server Action marcada con 'use server' o valores primitivos
export async function ServerPage() {
async function serverAction() {
'use server';
// Ejecución segura en el servidor vía RPC
}
return <ClientButton action={serverAction} />;
}Si un módulo que contiene credenciales privadas de API o conexiones a bases de datos es importado por una cadena de componentes cliente sin la protección del paquete server-only, el compilador podría incluir involuntariamente secretos en el bundle público o fallar por ausencia de APIs de Node.js en el navegador.
// ❌ PELIGROSO: módulo desprotegido, puede importarse accidentalmente en cliente
import { db } from '@/lib/db';
export async function getPrivateUserData(userId: string) {
return db.user.findUnique({ where: { id: userId } });
}
// ✅ CORRECTO: uso del paquete poison server-only
import 'server-only'; // Detiene la compilación si se importa en el grafo de cliente
import { db } from '@/lib/db';
export async function getPrivateUserData(userId: string) {
return db.user.findUnique({ where: { id: userId } });
}Un error común es asumir que RSC reemplaza a SSR. En realidad, son complementarios. RSC genera el grafo Flight (la estructura de componentes). El SSR clásico toma ese grafo Flight en el servidor y lo traduce en HTML inicial para su entrega inmediata a motores de búsqueda y usuarios. No distinguir ambos conceptos provoca intentos erróneos de procesar el flujo Flight con parsers HTML tradicionales.
Por defecto en React 19, los componentes se compilan en el entorno react-server. Invocar useState, useEffect, useReducer o useRef en estos componentes provocará un error de compilación, puesto que el paquete react en react-server no expone dichos hooks.