Para minimizar "use client" en React Server Components, extrae solo las partes interactivas en pequeñas "islas" de Client Components y mantén layouts, obtención de datos y contenido estático como Server Components. Usa el Composition Pattern — pasa contenido renderizado del servidor a través de props children — para que "use client" no se propague por el árbol de componentes.
Después de migrar a App Router, noté que "use client" se colaba en más y más archivos. Al final, casi todo era un Client Component, y me pregunté — ¿en qué se diferencia esto del Pages Router?
Sacar el máximo provecho de RSC (bundles más pequeños, acceso directo a datos del servidor, seguridad) requiere mantener los límites de "use client" lo más pequeños posible. Este artículo cubre Island Architecture y Composition Patterns para localizar "use client" donde realmente se necesita.
¿Por Qué "use client" Sigue Expandiéndose?
La razón para escribir "use client" siempre es clara: necesitas useState, necesitas onClick, necesitas useEffect. Cada funcionalidad interactiva lo requiere.
El problema es la propagación de "use client".
page.tsx (Server Component)
└── Layout.tsx ← "use client" added here
└── Header.tsx ← automatically becomes Client Component
└── Nav.tsx ← automatically becomes Client Component
└── NavItem.tsx ← automatically becomes Client Component
"use client" incluye ese componente y todos sus hijos en el bundle del cliente. Una sola directiva puede convertir un subárbol completo en Client Components.
El Patrón de Fallo Típico
// ❌ Todo el layout se convierte en Client Component
"use client"; // ← Añadido porque MobileMenu lo necesita
import { useState } from "react";
import { Header } from "./Header"; // Se suponía que era Server Component...
import { Footer } from "./Footer"; // Ahora también es client
export function Layout({ children }: { children: React.ReactNode }) {
const [menuOpen, setMenuOpen] = useState(false);
return (
<div>
<Header />
<MobileMenu isOpen={menuOpen} onClose={() => setMenuOpen(false)} />
<main>{children}</main>
<Footer />
</div>
);
}
Layout se convirtió en Client Component solo porque MobileMenu necesita useState. Ahora Header y Footer también están en el bundle del cliente.
Island Architecture
Island Architecture es la idea de que un "océano" de HTML estático tiene "islas" interactivas incrustadas en él.
┌─────────────────────────────────────────────────────┐
│ Server Component (static HTML) │
│ │
│ ┌──────────────┐ ┌──────────────────────┐ │
│ │ "use client" │ │ "use client" │ │
│ │ (Island) │ │ (Island) │ │
│ │ SearchBox │ │ LikeButton │ │
│ └──────────────┘ └──────────────────────┘ │
│ │
│ ┌──────────────┐ │
│ │ "use client" │ │
│ │ (Island) │ │
│ │ MobileMenu │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────┘
Solo los elementos interactivos se convierten en "use client". Todo lo demás permanece como Server Components. En lugar de convertir todo el árbol de React al cliente, aíslas las islas mínimas necesarias.
Patrones de Composición para Localizar "use client"
Patrón 1: Extraer Solo la Parte Interactiva
// ❌ Antes: todo el Layout es Client Component
"use client";
import { useState } from "react";
export function Layout({ children }: { children: React.ReactNode }) {
const [menuOpen, setMenuOpen] = useState(false);
return (
<div>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<button onClick={() => setMenuOpen(o => !o)}>Menu</button>
{menuOpen && <MobileMenuItems />}
</nav>
<main>{children}</main>
</div>
);
}
// ✅ Después: solo MobileMenu es "use client"
// components/MobileMenu.tsx
"use client"; // ← solo aquí
import { useState } from "react";
export function MobileMenu() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(o => !o)}>Menu</button>
{open && (
<div className="mobile-menu">
<a href="/">Home</a>
<a href="/about">About</a>
</div>
)}
</>
);
}
// app/layout.tsx — sigue siendo Server Component
import { MobileMenu } from "@/components/MobileMenu";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<MobileMenu /> {/* Client Component, pero Layout sigue siendo Server */}
</nav>
<main>{children}</main>
</div>
);
}
Patrón 2: Mantener los Padres como Servers con children
// ❌ Antes: el componente de obtención de datos se convierte en Client Component
"use client";
import { useEffect, useState } from "react";
export function ProductSection() {
const [products, setProducts] = useState([]);
const [cartOpen, setCartOpen] = useState(false);
useEffect(() => {
fetch("/api/products").then(r => r.json()).then(setProducts);
}, []);
return (
<section>
<div className="grid">
{products.map(p => <ProductCard key={p.id} product={p} />)}
</div>
<CartButton isOpen={cartOpen} onClick={() => setCartOpen(o => !o)} />
</section>
);
}
// ✅ Después: obtención de datos en el servidor, control de UI como isla pequeña
// components/CartButton.tsx
"use client";
import { useState } from "react";
export function CartButton() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(o => !o)}>
Cart {open ? "▲" : "▼"}
</button>
{open && <CartPanel />}
</>
);
}
// app/products/page.tsx — Server Component (data fetching)
import { db } from "@/lib/db";
import { ProductCard } from "@/components/ProductCard";
import { CartButton } from "@/components/CartButton";
export default async function ProductSection() {
const products = await db.product.findMany(); // Acceso directo a BD
return (
<section>
<div className="grid">
{products.map(p => <ProductCard key={p.id} product={p} />)}
</div>
<CartButton /> {/* Isla client, pero la página sigue siendo Server */}
</section>
);
}
Patrón 3: Pasar Contenido del Servidor a Través de Slots children
Los Client Components no pueden importar Server Components, pero pueden recibirlos como children:
// components/Accordion.tsx
"use client";
import { useState, ReactNode } from "react";
export function Accordion({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
<div className="border rounded-lg overflow-hidden">
<button
className="w-full text-left p-4 font-bold"
onClick={() => setOpen(o => !o)}
>
{title} {open ? "▲" : "▼"}
</button>
{open && <div className="p-4">{children}</div>}
</div>
);
}
// app/faq/page.tsx — Server Component
import { db } from "@/lib/db";
import { Accordion } from "@/components/Accordion";
export default async function FaqPage() {
const faqs = await db.faq.findMany({ orderBy: { order: "asc" } });
return (
<main>
<h1>FAQ</h1>
{faqs.map(faq => (
<Accordion key={faq.id} title={faq.question}>
{/* Este contenido vive en el mundo del Server Component */}
<p>{faq.answer}</p>
{faq.relatedLinks.map(link => (
<a key={link.url} href={link.url}>{link.label}</a>
))}
</Accordion>
))}
</main>
);
}
Accordion gestiona abrir/cerrar en el cliente, pero el contenido renderizado dentro de él se genera en el servidor. El acceso a la BD y las transformaciones pesadas permanecen en el servidor.
Refactorización Real: Página de Artículo de Blog
Antes: Toda la Página es Client
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
export default function BlogPostPage() {
const { slug } = useParams();
const [post, setPost] = useState(null);
const [liked, setLiked] = useState(false);
const [tocOpen, setTocOpen] = useState(false);
useEffect(() => {
fetch(`/api/posts/${slug}`).then(r => r.json()).then(setPost);
}, [slug]);
if (!post) return <div>Loading...</div>;
return (
<article>
<h1>{post.title}</h1>
<button onClick={() => setTocOpen(o => !o)}>Table of Contents</button>
{tocOpen && <TableOfContents headings={post.headings} />}
<div dangerouslySetInnerHTML={{ __html: post.content }} />
<button onClick={() => setLiked(o => !o)}>
{liked ? "❤️" : "🤍"} Like
</button>
</article>
);
}
Problemas: toda la página es client, el contenido del artículo se envía como JS, solicitud API desde el cliente, gestión compleja de estado de carga.
Después: Islas Mínimas
// components/TableOfContentsToggle.tsx
"use client";
import { useState, ReactNode } from "react";
export function TableOfContentsToggle({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
return (
<>
<button
className="text-sm text-gray-500 underline"
onClick={() => setOpen(o => !o)}
>
{open ? "Hide ToC" : "Show ToC"}
</button>
{open && <div className="my-4 p-4 bg-gray-50 rounded-lg">{children}</div>}
</>
);
}
// components/LikeButton.tsx
"use client";
import { useState } from "react";
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
const handleLike = async () => {
setLiked(o => !o);
await fetch(`/api/posts/${postId}/like`, { method: "POST" });
};
return (
<button onClick={handleLike}>
{liked ? "❤️" : "🤍"} Like
</button>
);
}
// app/blog/[slug]/page.tsx — Server Component
import { db } from "@/lib/db";
import { marked } from "marked"; // Librería pesada — cero costo en el bundle
import { TableOfContentsToggle } from "@/components/TableOfContentsToggle";
import { LikeButton } from "@/components/LikeButton";
import { notFound } from "next/navigation";
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await db.post.findUnique({ where: { slug } });
if (!post) notFound();
const html = marked(post.content); // Solo servidor — marked no se envía al cliente
return (
<article>
<h1>{post.title}</h1>
<TableOfContentsToggle>
{/* Los enlaces del ToC se generan en el servidor */}
<nav>
{post.headings.map(h => (
<a key={h.id} href={`#${h.id}`} className="block py-1">
{h.text}
</a>
))}
</nav>
</TableOfContentsToggle>
{/* HTML del artículo generado en el servidor — cero JS */}
<div dangerouslySetInnerHTML={{ __html: html }} />
{/* Solo el botón de like es una isla client */}
<LikeButton postId={post.id} />
</article>
);
}
Context Providers
Los context providers necesitan "use client", pero colocarlos directamente en layout.tsx convertiría todo el layout en un Client Component.
// ✅ Extraer providers en un wrapper delgado de Client Component
// components/Providers.tsx
"use client"; // ← solo este archivo
import { ThemeProvider } from "@/contexts/ThemeContext";
import { UserProvider } from "@/contexts/UserContext";
import { ReactNode } from "react";
export function Providers({ children }: { children: ReactNode }) {
return (
<ThemeProvider>
<UserProvider>
{children}
</UserProvider>
</ThemeProvider>
);
}
// app/layout.tsx — sigue siendo Server Component
import { Providers } from "@/components/Providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{/* Solo Providers es client. layout.tsx sigue siendo server. */}
<Providers>
{children}
</Providers>
</body>
</html>
);
}
Este patrón de wrapper delgado Providers es recomendado en la documentación oficial de Next.js.
Midiendo el Impacto
¿Cómo sabes si tu refactorización realmente ayudó? Compara estas métricas antes y después:
- Tamaño del bundle: Ejecuta
npx @next/bundle-analyzero revisa la salida del build. Los Client Components aparecen bajo "First Load JS" - Core Web Vitals: Menos JS del cliente significa mejores puntajes de LCP e INP
- React DevTools Profiler: La pestaña "Components" de React DevTools muestra qué componentes son Server vs Client — busca Client Components inesperados
Una buena regla general de los docs de Next.js: el valor predeterminado es Server Components, solo añade "use client" cuando necesites interactividad, hooks o APIs del navegador.
FAQ
¿"use client" significa que el componente solo se ejecuta en el cliente?
No. Los Client Components se pre-renderizan en el servidor como HTML (igual que SSR en Pages Router), luego se hidratan en el cliente. La diferencia es que su JavaScript se incluye en el bundle del cliente para la interactividad.
¿Un Client Component puede importar un Server Component?
No directamente. Un Client Component no puede hacer import de un Server Component porque el grafo de módulos lo forzaría al bundle del cliente. Sin embargo, puedes pasar Server Components como children u otras props — este es el Composition Pattern cubierto en este artículo.
¿Qué datos puedo pasar de Server a Client Components?
Solo datos serializables: strings, números, booleanos, arrays, objetos planos, Date, Map, Set, TypedArrays, FormData y elementos React (JSX). Las funciones, instancias de clases y referencias circulares no pueden cruzar la frontera.
¿Debo usar un wrapper grande de "use client" o muchos pequeños?
Muchos pequeños. Cada directiva "use client" crea una frontera — cuanto más pequeña la frontera, menos JavaScript se envía al cliente. Un wrapper grande anula el propósito de los Server Components.
¿Cómo funcionan los Context Providers con Server Components?
Context requiere "use client", así que no puedes usar useContext en Server Components. El patrón recomendado es crear un wrapper delgado <Providers> como Client Component y colocarlo en tu layout raíz, pasando children para que el layout siga siendo Server Component.
¿"use client" afecta el SEO?
No directamente, ya que los Client Components se renderizan como HTML en el servidor. Sin embargo, si "use client" fuerza la obtención de datos del lado del cliente (vía useEffect + fetch), los motores de búsqueda podrían no ver ese contenido. Mantener la obtención de datos en Server Components asegura que el contenido esté en el HTML inicial.
¿Cuál es la diferencia entre "use server" y "use client"?
"use server" marca funciones del servidor (Server Actions) que pueden ser llamadas desde Client Components — es para mutaciones y manejo de formularios, no para renderizado. "use client" marca componentes que necesitan interactividad del lado del cliente. Sirven para propósitos diferentes.
Conclusión
Los principios clave para minimizar los límites de "use client":
| Principio | Mal | Bien |
|---|---|---|
| Extraer partes interactivas | El padre completo es "use client" | Solo el componente mínimo necesario es "use client" |
| Obtención de datos en el servidor | useEffect + fetch en el cliente | Acceso directo a BD en Server Component |
| Usar slots children | Importar Server Component dentro de Client | Pasar como children mediante composición |
| Wrappers delgados de providers | Hacer layout "use client" | Solo un Client Component delgado <Providers> |
El modelo mental de Island Architecture facilita las decisiones: "¿Este componente necesita ser interactivo?" Si no — Server Component. Si sí — hazlo como una isla lo más pequeña posible.
Ten en mente la propagación de "use client", y mantendrás bundles más pequeños mientras sigues entregando interfaces interactivas.
Artículos relacionados: