Patrones de Composición
Mezcla Server Components y Client Components de forma efectiva utilizando el patrón children, slots y arquitectura consciente de límites.
Receta
Tarjeta de referencia rápida -- lista para copiar y pegar.
// Patrón 1: Pasar Server Components como children a Client Components
// app/components/client-sidebar.tsx
"use client";
import { useState } from "react";
export function Sidebar({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(true);
return (
<aside className={open ? "w-64" : "w-0"}>
<button onClick={() => setOpen(!open)}>Alternar</button>
{open && children}
</aside>
);
}
// app/page.tsx (Server Component)
import { Sidebar } from "./components/client-sidebar";
import { ServerNav } from "./components/server-nav";
export default function Page() {
return (
<Sidebar>
{/* ServerNav se renderiza en el servidor, pasado como JSX pre-renderizado */}
<ServerNav />
</Sidebar>
);
}Cuándo usarlo: Necesitas un Client Component (para interactividad) que envuelva o contenga Server Components (para obtención de datos o renderizado sin JS).
Ejemplo Práctico
// app/components/accordion.tsx
"use client";
import { useState } from "react";
type AccordionProps = {
title: string;
children: React.ReactNode;
};
export function Accordion({ title, children }: AccordionProps) {
const [expanded, setExpanded] = useState(false);
return (
<div className="border rounded mb-2">
<button
onClick={() => setExpanded(!expanded)}
className="w-full text-left px-4 py-3 font-medium flex justify-between"
>
{title}
<span>{expanded ? "-" : "+"}</span>
</button>
{expanded && <div className="px-4 pb-4">{children}</div>}
</div>
);
}// app/components/product-details.tsx (Server Component -- sin directiva)
import { db } from "@/lib/db";
export async function ProductDetails({ productId }: { productId: string }) {
const product = await db.product.findUnique({
where: { id: productId },
include: { specs: true },
});
if (!product) return <p>Producto no encontrado</p>;
return (
<dl className="grid grid-cols-2 gap-2 text-sm">
{product.specs.map((spec) => (
<div key={spec.id}>
<dt className="font-medium text-gray-600">{spec.label}</dt>
<dd>{spec.value}</dd>
</div>
))}
</dl>
);
}// app/components/product-reviews.tsx (Server Component)
import { db } from "@/lib/db";
export async function ProductReviews({ productId }: { productId: string }) {
const reviews = await db.review.findMany({
where: { productId },
orderBy: { createdAt: "desc" },
take: 5,
});
return (
<ul className="space-y-3">
{reviews.map((r) => (
<li key={r.id} className="border-b pb-3">
<p className="font-medium">{r.author}</p>
<p className="text-gray-600">{r.body}</p>
</li>
))}
</ul>
);
}// app/products/[id]/page.tsx (Server Component orquesta todo)
import { Suspense } from "react";
import { Accordion } from "@/app/components/accordion";
import { ProductDetails } from "@/app/components/product-details";
import { ProductReviews } from "@/app/components/product-reviews";
import { AddToCartButton } from "@/app/components/add-to-cart";
type Props = { params: Promise<{ id: string }> };
export default async function ProductPage({ params }: Props) {
const { id } = await params;
return (
<main className="max-w-2xl mx-auto p-6">
<Accordion title="Especificaciones">
{/* Server Component renderizado en el servidor, pasado como children */}
<Suspense fallback={<p>Cargando especificaciones...</p>}>
<ProductDetails productId={id} />
</Suspense>
</Accordion>
<Accordion title="Reseñas">
<Suspense fallback={<p>Cargando reseñas...</p>}>
<ProductReviews productId={id} />
</Suspense>
</Accordion>
{/* Client Component para interactividad */}
<AddToCartButton productId={id} />
</main>
);
}// app/components/add-to-cart.tsx
"use client";
import { useTransition } from "react";
import { addToCart } from "@/app/actions/cart";
export function AddToCartButton({ productId }: { productId: string }) {
const [isPending, startTransition] = useTransition();
return (
<button
onClick={() => startTransition(() => addToCart(productId))}
disabled={isPending}
className="mt-4 w-full bg-blue-600 text-white py-3 rounded font-medium disabled:opacity-50"
>
{isPending ? "Agregando..." : "Agregar al Carrito"}
</button>
);
}Lo que esto demuestra:
- El patrón children: Un Client Component (
Accordion) envuelve Server Components (ProductDetails,ProductReviews) a través de propschildren - Los Server Components obtienen datos con
awaity se renderan en el servidor; su salida se pasa como JSX pre-renderizado al Client Component - Cada límite
Suspensepermite que los datos del servidor hagan streaming de forma independiente - La página orquestadora es un Server Component que compone todo
Inmersión Profunda
Cómo Funciona
- Un Client Component no puede importar un Server Component porque el límite
"use client"hace que todo en su árbol de dependencias sea solo del cliente. - Sin embargo, un Client Component sí puede recibir la salida de un Server Component como
childreno cualquier otra propReact.ReactNode. El Server Component ya está renderizado en el servidor; el Client Component recibe JSX pre-renderizado, no una referencia de módulo. - El límite servidor-cliente está en la directiva
"use client". Todo lo anterior (en el árbol de importación) es servidor; todo lo posterior es cliente. - Puedes pasar múltiples Server Components a través de diferentes slots de props (no solo
children).
Variaciones
Patrón de múltiples slots:
// Client Component con slots nombrados
"use client";
export function DashboardLayout({
sidebar,
header,
children,
}: {
sidebar: React.ReactNode;
header: React.ReactNode;
children: React.ReactNode;
}) {
const [collapsed, setCollapsed] = useState(false);
return (
<div className="flex">
<aside className={collapsed ? "w-16" : "w-64"}>{sidebar}</aside>
<div className="flex-1">
<header>{header}</header>
<main>{children}</main>
</div>
</div>
);
}// Orquestador de Server Component
import { DashboardLayout } from "./dashboard-layout";
import { ServerSidebar } from "./server-sidebar";
import { ServerHeader } from "./server-header";
export default async function DashboardPage() {
return (
<DashboardLayout
sidebar={<ServerSidebar />}
header={<ServerHeader />}
>
<ServerMainContent />
</DashboardLayout>
);
}Patrón de context provider:
// app/providers.tsx
"use client";
import { ThemeProvider } from "next-themes";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class">
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</ThemeProvider>
);
}// app/layout.tsx (Server Component)
import { Providers } from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}Extrayendo partes interactivas en un Client Component delgado:
// En lugar de hacer toda la tarjeta un Client Component...
// Extrae solo la parte interactiva
"use client";
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? "Liked" : "Like"}
</button>
);
}
// Mantén la tarjeta como Server Component
export default async function PostCard({ id }: { id: string }) {
const post = await fetchPost(id);
return (
<article>
<h2>{post.title}</h2>
<p>{post.body}</p>
<LikeButton postId={id} />
</article>
);
}Notas de TypeScript
// Usa React.ReactNode para cualquier prop que reciba JSX del servidor
type LayoutProps = {
children: React.ReactNode;
sidebar: React.ReactNode;
modal: React.ReactNode;
};
// La salida de Server Component es JSX ya renderizado -- no una referencia de componente
// TypeScript lo trata como ReactNode, que incluye JSX.Element, string, number, etc.
// Las props de Server Action son válidas a través del límite
type FormProps = {
submitAction: (formData: FormData) => Promise<{ error?: string }>;
};Problemas Comunes
-
Importar un Server Component en un archivo
"use client"-- La importación silenciosamente convierte el Server Component en un Client Component. No se lanza error, pero pierde el comportamiento solo del servidor. Solución: Pásalo comochildreno una prop JSX desde un padre Server Component. -
Los context providers deben ser Client Components -- React Context requiere
"use client". Pero poner providers en el layout hace que el layout sea un componente cliente y todos los children se convierten en cliente también. Solución: Crea un archivoproviders.tsxseparado con"use client"y envuelve{children}en el layout del Server Component. -
Usar demasiado
"use client"-- Marcar un componente de alto nivel como"use client"tira todos los children al bundle del cliente. Solución: Empuja el límite"use client"lo más bajo posible en el árbol de componentes. Extrae solo las piezas interactivas. -
Pasar props no serializables -- Pasar una función, instancia de clase, o Symbol desde un Server Component a un Client Component falla silenciosamente o lanza error. Solución: Pasa solo datos serializables. Usa Server Actions para comportamiento similar a funciones.
-
Estado compartido entre servidor y cliente -- No hay estado compartido. Los Server Components se ejecutan en el servidor; los Client Components se hidratan en el cliente. Solución: Pasa datos iniciales como props del servidor al cliente. Usa Server Actions para sincronizar estado de vuelta.
Alternativas
| Patrón | Úsalo Cuando | No Lo Uses Cuando |
|---|---|---|
| Patrón children | Client Component envuelve salida de Server Component | Todos los children son solo del cliente de todas formas |
| Props de slot (sidebar, header) | Múltiples regiones independientes de Server Component | Una única prop children es suficiente |
| Wrapper de context providers | Necesitas React Context en la raíz sin hacer que el layout sea un Client Component | No se necesita contexto |
dynamic(import, { ssr: false }) | Una librería de terceros no puede renderizar en el servidor en absoluto | SSR + hidratación normal funciona bien |
| Server Actions como props | Un Client Component necesita desencadenar lógica del lado del servidor | La interacción es puramente del lado del cliente |
Preguntas Frecuentes
¿Qué es el "patrón children" y por qué se necesita?
- Un Client Component no puede importar un Server Component directamente.
- El patrón children permite que un padre Server Component pase la salida pre-renderizada del Server Component como
children(u otra prop JSX) a un Client Component. - El Client Component recibe JSX ya renderizado, no una referencia de módulo.
¿Por qué un Client Component no puede importar un Server Component?
El límite "use client" hace que todo en su árbol de dependencias sea solo del cliente. Importar un Server Component dentro de un archivo "use client" silenciosamente lo convierte en un Client Component, perdiendo todo el comportamiento solo del servidor.
¿Cómo funciona el patrón de múltiples slots?
Un Client Component acepta múltiples props React.ReactNode (por ejemplo, sidebar, header, children). Un orquestador de Server Component pasa diferentes Server Components en cada slot:
<DashboardLayout
sidebar={<ServerSidebar />}
header={<ServerHeader />}
>
<ServerMainContent />
</DashboardLayout>¿Cómo debo configurar los context providers sin hacer que el layout sea un Client Component?
Crea un archivo providers.tsx separado con "use client" que envuelva {children}. Impórtalo en tu layout del Server Component:
// app/layout.tsx (Server Component)
import { Providers } from "./providers";
export default function RootLayout({ children }) {
return (
<html><body>
<Providers>{children}</Providers>
</body></html>
);
}¿Qué sucede si marco un componente de alto nivel como "use client"?
Todos los children e importaciones de ese componente se tiran al bundle del cliente. Esto derrota el propósito de los Server Components. Solución: empuja el límite "use client" lo más bajo posible y extrae solo las piezas interactivas.
Problema común: Importé un Server Component dentro de un archivo "use client" y no obtuve error. ¿Qué salió mal?
- La importación silenciosamente convierte el Server Component en un Client Component.
- No se lanza error, pero pierde el comportamiento solo del servidor (acceso directo a BD, renderizado sin JS, etc.).
- Solución: pásalo como
childreno una prop JSX desde un padre Server Component en su lugar.
Problema común: ¿Puedo pasar una función regular desde un Server Component a un Client Component?
No. Las funciones regulares, instancias de clase, y Symbols no son serializables y fallarán. Usa Server Actions (funciones async con "use server") para comportamiento similar a funciones a través del límite.
¿Cómo tipeo props de slot en TypeScript para un Client Component que recibe JSX renderizado en el servidor?
Usa React.ReactNode para cualquier prop que reciba JSX del servidor:
type LayoutProps = {
children: React.ReactNode;
sidebar: React.ReactNode;
modal: React.ReactNode;
};¿Cuál es el tipo correcto de TypeScript para una prop de Server Action pasada a un Client Component?
type FormProps = {
submitAction: (formData: FormData) => Promise<{ error?: string }>;
};Las Server Actions son las únicas funciones que pueden cruzar el límite servidor-cliente como props.
¿Por qué usar límites Suspense alrededor de Server Components dentro del patrón children?
- Cada límite
Suspensepermite que su contenido haga streaming de forma independiente. - El shell del Client Component se renderiza inmediatamente con fallbacks, y la salida de cada Server Component hace streaming conforme se resuelve.
¿Hay estado compartido entre Server Components y Client Components?
No. Los Server Components se ejecutan en el servidor; los Client Components se hidratan en el cliente. Pasa datos iniciales como props del servidor al cliente. Usa Server Actions para sincronizar estado de vuelta al servidor.
¿Cómo extraigo solo la parte interactiva de un componente?
Mantén el componente que obtiene datos como Server Component y extrae solo la pieza interactiva (por ejemplo, un botón) en un pequeño Client Component:
// Server Component
export default async function PostCard({ id }) {
const post = await fetchPost(id);
return (
<article>
<h2>{post.title}</h2>
<LikeButton postId={id} /> {/* Client Component */}
</article>
);
}Relacionado
- Server Components -- Renderizado servidor por defecto
- Client Components -- Agregar interactividad
- Streaming -- Suspense con componentes compuestos
- Partial Prerendering -- Shells estáticos con agujeros dinámicos