Saltar al contenido

Dónde te cuesta de verdad `use client`

El límite de cliente no es un ajuste de rendimiento, es un límite de bundle. Así encuentras hacia dónde ha derivado el tuyo, y qué vale devolverlo a su sitio.

4 min de lectura

use client no significa «este componente se ejecuta en el cliente». Todos los componentes de una aplicación Next.js se renderizan en el servidor al menos una vez.

Lo que use client declara en realidad es un límite: de este módulo hacia abajo, todo se compila dentro del bundle de JavaScript y se envía al navegador para que pueda hidratar. Ese es el coste. No el renderizado — el envío.

En cuanto asumes esa definición, los errores habituales se vuelven obvios.

La deriva hacia arriba

Los límites derivan hacia la raíz, una decisión razonable cada vez.

Diseño pide un desplegable en la cabecera. La cabecera necesita useState, así que use client se pone al principio de Header.tsx. La cabecera importa Nav, que importa NavItem, que importa una utilidad formatDate que importa una librería de fechas. Nada de eso necesitaba ser interactivo. Todo eso está ahora en el bundle, en cada página, para cada visitante.

Seis meses después el layout es un componente de cliente y nadie recuerda por qué.

Encontrar tu límite real

La salida del build te dice dónde se torció. Mira el First Load JS por ruta:

Route (app)                      Size  First Load JS
┌ ○ /                          1.2 kB         184 kB
├ ○ /about                     0.8 kB         184 kB
└ ○ /pricing                   2.1 kB         186 kB

Cuando todas las rutas cargan la misma base grande, el peso está en el layout compartido, no en las páginas. Esa es la firma de un límite de cliente que ha trepado hasta el árbol del layout.

Para el detalle, ejecuta el analizador de bundles:

npm install --save-dev @next/bundle-analyzer
ANALYZE=true npm run build

Buscas dos cosas: librerías que no esperabas, y librerías que aparecen en el chunk compartido cuando deberían estar en el chunk de una sola ruta.

Empujar el límite hacia abajo

La solución tiene casi siempre la misma forma — la hoja interactiva sigue siendo un componente de cliente, y todo lo que la rodea se queda en el servidor.

En lugar de esto:

'use client';
 
import { heavyMarkdownRenderer } from 'some-large-lib';
 
export function Article({ content, comments }) {
  const [showComments, setShowComments] = useState(false);
 
  return (
    <article>
      {heavyMarkdownRenderer(content)}
      <button onClick={() => setShowComments(true)}>Comments</button>
      {showComments ? <Comments data={comments} /> : null}
    </article>
  );
}

haz esto:

// Article.tsx — server component, no 'use client'
import { heavyMarkdownRenderer } from 'some-large-lib';
import { CommentsToggle } from './CommentsToggle';
 
export function Article({ content, comments }) {
  return (
    <article>
      {heavyMarkdownRenderer(content)}
      <CommentsToggle>
        <Comments data={comments} />
      </CommentsToggle>
    </article>
  );
}
// CommentsToggle.tsx — the only client component
'use client';
 
export function CommentsToggle({ children }: { children: ReactNode }) {
  const [open, setOpen] = useState(false);
 
  return (
    <>
      <button onClick={() => setOpen(true)}>Comments</button>
      {open ? children : null}
    </>
  );
}

La librería de markdown se queda en el servidor. Comments sigue siendo un componente de servidor aunque se renderice dentro de uno de cliente — porque se pasa como children, ya se renderizó en el servidor y llega como salida serializada, no como código.

Los children que se pasan a través de un componente de cliente no cruzan el límite. Esa única regla resuelve la mayoría de los casos en que un equipo cree estar obligado a irse al cliente.

Por qué esto aparece en el INP, no solo en el tiempo de carga

Interaction to Next Paint mide cuánto tarda el hilo principal en responder cuando una visitante toca algo. Cada kilobyte de JavaScript del bundle se parsea, compila y ejecuta antes de que la página pueda responder a nada, y la hidratación compite con ese primer toque.

En un Android de gama media — que es el dispositivo de la mayor parte del mundo — 300 KB de JavaScript son aproximadamente un segundo de trabajo en el hilo principal antes de que termine la hidratación. Un toque durante ese segundo se encola. Eso es un INP fallido, y ninguna cantidad de trabajo en CSS lo arregla.

Los Server Components no son principalmente una optimización de renderizado. Son una manera de no enviar el código en primer lugar.

Cosas que no necesitan ser componentes de cliente

De las auditorías que hacemos, los falsos positivos recurrentes:

  • Formatear fechas y números. Intl funciona en el servidor. Si lo que preocupa es la zona horaria de la visitante, pasa la cadena ya formateada, o renderiza un elemento <time> con atributo dateTime y deja la presentación a CSS o a un componente hoja diminuto.
  • Leer un tema. El atributo data-theme más CSS lo resuelve sin JavaScript.
  • Pedir datos al montar. Si no son específicos del usuario, pídelos en el servidor. Si lo son, pídelos en el servidor detrás de Suspense.
  • Analítica. Cárgala en un <Script> con strategy="afterInteractive" o "lazyOnload", no dentro de un componente.
  • Librerías de iconos. Los iconos son marcado. Importa el SVG concreto, no el fichero barril que arrastra dos mil.

La regla de revisión que usamos

En toda base de código en la que trabajamos, use client se trata como se trata un prefijo dangerously: está permitido, a veces es lo correcto, y nunca pasa una revisión sin una frase que explique por qué el límite corresponde a ese fichero exacto y no a uno más abajo.

Esa sola convención vale más que cualquier herramienta de tamaño de bundle, porque atrapa el problema en el momento en que se introduce y no dieciocho meses después, cuando alguien por fin abre el analizador.

Volver a todos los artículos