React Server Components: qué se rompe al mezclar lógica de cliente sin darse cuenta
Cuando adoptamos React Server Components en un proyecto que ya tenía código en producción, el error más frecuente no aparece en la primera pantalla que migramos. Aparece dos o tres componentes más abajo en el árbol, cuando algo que funcionaba en el modelo anterior de React deja de comportarse como se espera y el mensaje de error no siempre señala la causa real. RSC no es una optimización que se agrega por encima del código existente: cambia qué significa que un componente se ejecute, y esa distinción hay que sostenerla en cada archivo del árbol, no solo en el que se está tocando.
El límite "use client" es un contrato, no una anotación
La directiva "use client" se suele explicar como una forma de decirle a React "este componente necesita el navegador". Esa descripción es correcta pero incompleta, y la parte que falta es la que produce la mayoría de los errores: la directiva no marca un componente, marca un límite en el árbol de imports. Todo lo que ese archivo importe, directa o indirectamente, cruza al lado del cliente con él.
El error típico en una migración es asumir que basta con poner "use client" en el componente que usa un hook, sin revisar qué arrastra ese componente hacia arriba:
// components/FiltroProductos.tsx
'use client'
import { useState } from 'react'
import { AnalyticsProvider } from '../lib/analytics-context'
export function FiltroProductos() {
const [categoria, setCategoria] = useState('todas')
return (
<AnalyticsProvider>
<select value={categoria} onChange={(e) => setCategoria(e.target.value)}>
<option value="todas">Todas</option>
<option value="ofertas">Ofertas</option>
</select>
</AnalyticsProvider>
)
}
El componente compila y funciona, pero AnalyticsProvider termina empaquetado en el bundle del cliente aunque su lógica interna solo dependa de datos que ya estaban disponibles en el servidor. La pregunta que aprendimos a hacer antes de cada "use client" no es "¿este componente necesita un hook?" sino "¿qué es lo mínimo que necesita ejecutarse en el navegador, y puedo aislar solo eso?". La respuesta casi siempre es un componente más pequeño de lo que parecía al principio: el <select> con su onChange, no el proveedor de contexto que lo rodea.
Cómo se propaga el límite hacia arriba sin que nadie lo decida
El problema más difícil de diagnosticar no es un componente mal marcado, es la propagación silenciosa. Si un componente de servidor importa un componente de cliente y lo renderiza directamente como hijo, ese componente de cliente sigue siendo cliente, lo cual es correcto. El problema aparece cuando, para pasarle una función o un valor calculado que necesita lógica de servidor, alguien mueve esa lógica dentro del propio componente de cliente porque "ya está ahí y es más rápido". Cada vez que eso pasa, una porción de trabajo que podía resolverse en el servidor —una consulta, un cálculo, una transformación de datos— se ejecuta ahora en el navegador de cada usuario, sin que ningún error de compilación lo señale, porque desde el punto de vista de React no hay ningún límite mal puesto: solo hay lógica de servidor viviendo en el lugar equivocado.
En un proyecto migramos una lista de productos con filtros a Server Components. La lista se renderizaba en el servidor, correcto. El filtro necesitaba interactividad, así que lo marcamos como cliente, también correcto. El problema fue que la función que decidía qué productos mostrar según el filtro —una función que solo necesitaba los datos ya cargados y ninguna API del navegador— se escribió dentro del componente cliente en vez de pasarse como resultado ya calculado desde el servidor cuando el filtro cambiaba. El resultado funcionaba, pero cada cambio de filtro repetía en el navegador un trabajo que el servidor ya había hecho una vez para la carga inicial, y la única forma de notar el problema fue leyendo el código con la pregunta correcta, porque no había ningún síntoma visible que apuntara directamente a la causa.
Estado duplicado: cuando el servidor y el cliente cuentan versiones distintas de la misma verdad
El segundo error recurrente es de estado, no de límites. Cuando parte del árbol es servidor y parte es cliente, es tentador mantener una copia del dato del servidor en estado local del cliente para que la interfaz se sienta reactiva sin esperar un nuevo render del servidor:
'use client'
import { useState } from 'react'
export function TarjetaPedido({ pedido }: { pedido: Pedido }) {
const [estadoLocal, setEstadoLocal] = useState(pedido.estado)
async function marcarEntregado() {
setEstadoLocal('entregado')
await actualizarPedido(pedido.id, 'entregado')
}
return <button onClick={marcarEntregado}>{estadoLocal}</button>
}
Este patrón parece razonable y en muchos casos funciona, pero introduce una fuente de verdad paralela: si la actualización en el servidor falla, o si otro usuario cambia el mismo pedido, estadoLocal queda desincronizado del valor real, y como React no vuelve a leer pedido.estado una vez que estadoLocal existe, la interfaz sigue mostrando un dato que el servidor ya no respalda. La corrección no es evitar el estado local —la interactividad lo necesita— sino tratarlo como un valor optimista explícito, revertible si la actualización falla, y no como una copia permanente del dato del servidor. Cuando la operación falla, volvemos a leer el valor real en vez de dejar el optimista como definitivo.
Qué se queda como componente de servidor y qué no
La pregunta que usamos para decidir, componente por componente, es si necesita algo que solo existe en el navegador: estado que cambia por interacción del usuario, efectos, contexto de React, o APIs del DOM. Si la respuesta es no, el componente se queda en el servidor, incluso si hoy vive mezclado con otros que sí son interactivos. Data fetching, formateo de datos, cálculos que no dependen de la sesión del usuario en el navegador: todo eso es candidato a servidor por defecto, y solo baja al cliente si algo específico lo obliga.
Lo que no funcionó: poner "use client" arriba de todo para avanzar rápido
Durante la migración, la primera respuesta a cada error de hidratación o de hook fuera de contexto fue subir la directiva "use client" un nivel más en el árbol, hasta el layout de la sección completa. Los errores desaparecían, la migración avanzaba rápido, y esa fue exactamente la señal de que estábamos haciendo lo contrario de lo que buscábamos: con la directiva en el layout, toda la subárbol volvía a ser cliente, incluida la carga de datos que ya vivía cómodamente en el servidor antes de tocar nada. Revertimos ese cambio component por componente, empujando "use client" hacia las hojas del árbol en vez de hacia la raíz, que es más lento pero es el único camino que conserva el motivo real para adoptar Server Components en primer lugar.
Antes de dar por cerrada una migración
Revisamos tres cosas en cada sección migrada: que cada "use client" esté en el componente más pequeño posible y no en un contenedor que arrastra dependencias innecesarias, que ningún estado de cliente sea una copia silenciosa de un dato de servidor sin manejo explícito de la reversión ante fallos, y que la lógica sin necesidad real de navegador —cálculos, transformaciones, acceso a datos— siga viviendo del lado del servidor. Cuando las tres se cumplen, el árbol mixto deja de ser una fuente de errores intermitentes y empieza a comportarse como lo que se buscaba desde el principio: servidor por defecto, cliente solo donde la interacción lo exige.