32blogby StudioMitsu

Minimiza 'use client' en React Server Components

Patrones de diseño para mantener pequeños los límites de 'use client' en Next.js App Router. Island Architecture, Composition Pattern y ejemplos prácticos de refactorización.

9 min read
Contenido

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

tsx
// ❌ Entire layout becomes a Client Component
"use client"; // ← Added because MobileMenu needs it

import { useState } from "react";
import { Header } from "./Header"; // Intended to be a Server Component...
import { Footer } from "./Footer"; // Now fully client too

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

tsx
// ❌ Before: entire Layout is a 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>
  );
}
tsx
// ✅ After: only MobileMenu is "use client"

// components/MobileMenu.tsx
"use client"; // ← only here
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>
      )}
    </>
  );
}
tsx
// app/layout.tsx — stays a 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, but Layout itself is Server */}
      </nav>
      <main>{children}</main>
    </div>
  );
}

Patrón 2: Mantener los Padres como Servers con children

tsx
// ❌ Before: data-fetching component becomes a 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>
  );
}
tsx
// ✅ After: data fetching on server, UI control as a small island

// 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 />}
    </>
  );
}
tsx
// 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(); // Direct DB access

  return (
    <section>
      <div className="grid">
        {products.map(p => <ProductCard key={p.id} product={p} />)}
      </div>
      <CartButton /> {/* Client island, but the page itself stays 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:

tsx
// 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>
  );
}
tsx
// 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}>
          {/* This content lives in Server Component land */}
          <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

tsx
"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

tsx
// 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>}
    </>
  );
}
tsx
// 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>
  );
}
tsx
// app/blog/[slug]/page.tsx — Server Component
import { db } from "@/lib/db";
import { marked } from "marked"; // Heavy library — zero bundle cost
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); // Server-only — marked doesn't ship to client

  return (
    <article>
      <h1>{post.title}</h1>

      <TableOfContentsToggle>
        {/* ToC links generated on the server */}
        <nav>
          {post.headings.map(h => (
            <a key={h.id} href={`#${h.id}`} className="block py-1">
              {h.text}
            </a>
          ))}
        </nav>
      </TableOfContentsToggle>

      {/* Article HTML generated server-side — zero JS */}
      <div dangerouslySetInnerHTML={{ __html: html }} />

      {/* Only the like button is a client island */}
      <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.

tsx
// ✅ Extract providers into a thin Client Component wrapper

// components/Providers.tsx
"use client"; // ← only this file
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>
  );
}
tsx
// app/layout.tsx — stays a Server Component
import { Providers } from "@/components/Providers";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {/* Only Providers is client. layout.tsx stays server. */}
        <Providers>
          {children}
        </Providers>
      </body>
    </html>
  );
}

Este patrón de wrapper delgado Providers es recomendado en la documentación oficial de Next.js.

Conclusión

Los principios clave para minimizar los límites de "use client":

PrincipioMalBien
Extraer partes interactivasEl padre completo es "use client"Solo el componente mínimo necesario es "use client"
Obtención de datos en el servidoruseEffect + fetch en el clienteAcceso directo a BD en Server Component
Usar slots childrenImportar Server Component dentro de ClientPasar como children mediante composición
Wrappers delgados de providersHacer 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.