Antes de la llegada de los React Server Components (RSC) y de la arquitectura de Next.js App Router, la gestión de estado en el cliente (Redux, MobX, Zustand) constituía el pilar dominante del desarrollo frontend. Los equipos replicaban indiscriminadamente en stores globales del navegador toda la información del backend: registros de bases de datos, DTOs de APIs externas, estados efímeros de formularios y conmutadores visuales. Este enfoque provocaba un sobrepeso notable en el tamaño del bundle de JavaScript, redundancias de almacenamiento y severos desajustes de sincronización de caché.
En la arquitectura actual de Next.js, el principio rector es tajante: los datos del servidor deben permanecer en el servidor. La obtención de datos dentro de Server Components elimina la necesidad de stores globales intermedios para propagar información por el árbol de componentes; los componentes reciben los datos mediante props estrictamente tipadas o aprovechan la memoización nativa de peticiones.
Sin embargo, la gestión de estado no ha desaparecido, sino que se ha estructurado en capas de responsabilidad bien delimitadas:
En una aplicación con Next.js, el estado se distribuye en cuatro categorías aisladas:
┌─────────────────────────────────────────────────────────┐
│ FUENTES DE DATOS │
└──────────────┬───────────────────────────┬──────────────┘
│ │
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ SERVER STATE │ │ URL STATE │
│ PostgreSQL / Redis / ORM│ │ ?category=tech&page=2 │
└──────────────┬───────────┘ └─────────────┬────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ NEXT.JS APP ROUTER (Server Component) │
│ const { category } = await searchParams; │
│ const data = await db.query(category); │
└────────────────────────────┬────────────────────────────┘
│ props / initialData
▼
┌─────────────────────────────────────────────────────────┐
│ CLIENT HYDRATION & INTERACTION LAYER │
│ │
│ ┌────────────────────────┐ ┌───────────────────────┐ │
│ │ Zustand Store (UI) │ │ React 19 useOptimistic│ │
│ │ (Carrito, modales, DnD)│ │ (Likes y chat en vivo)│ │
│ └────────────────────────┘ └───────────────────────┘ │
└─────────────────────────────────────────────────────────┘Clasificar la naturaleza del dato: entidad persistente de base de datos o estado efímero de UI.
Si el estado afecta a enlaces compartidos o indexación SEO (filtros, páginas), ubicarlo en searchParams / nuqs.
Para acciones inmediatas (dar like, añadir a lista rápida), integrar React 19 useOptimistic.
Para estados ricos en el cliente, implementar una factoría Zustand Per-Request Store.
Conectar los proveedores cliente mediante AppProviders sin convertir el RootLayout a 'use client'.
Un equipo implementa un carrito de compras con Zustand declarando export const useCartStore = create(...) en el ámbito de módulo de cart-store.ts. En local funciona perfectamente, pero tras desplegar a producción con tráfico real, los usuarios empiezan a ver esporádicamente productos de carritos ajenos al recargar la página. ¿Cuál es el origen de la vulnerabilidad?
searchParams asíncrono y aislamiento con Suspense#En Server Components, la lectura de los parámetros de búsqueda es estrictamente asíncrona:
import { Suspense } from 'react';
import { SearchFilterInput } from './_components/SearchFilterInput';
import { ProductList } from './_components/ProductList';
interface CatalogPageProps {
searchParams: Promise<{ q?: string; page?: string }>;
}
export default async function CatalogPage({ searchParams }: CatalogPageProps) {
const { q = '', page = '1' } = await searchParams;
return (
<main className="max-w-6xl mx-auto p-6 flex flex-col gap-6">
<h1 className="text-2xl font-bold">Catálogo de productos</h1>
{/* Aislar el componente cliente en Suspense evita desoptimizar toda la página */}
<Suspense fallback={<div className="h-10 bg-bg-surface animate-pulse rounded-lg" />}>
<SearchFilterInput initialQuery={q} />
</Suspense>
<ProductList query={q} page={parseInt(page, 10)} />
</main>
);
}En el cliente, sincronizar useSearchParams() con useTransition previene el bloqueo de la entrada del usuario:
'use client';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';
import { useTransition } from 'react';
export function SearchFilterInput({ initialQuery }: { initialQuery: string }) {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
const [isPending, startTransition] = useTransition();
const handleSearch = (term: string) => {
const params = new URLSearchParams(searchParams);
if (term) {
params.set('q', term);
params.set('page', '1');
} else {
params.delete('q');
}
startTransition(() => {
replace(`${pathname}?${params.toString()}`);
});
};
return (
<div className="relative">
<input
defaultValue={initialQuery}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Buscar en el catálogo..."
className="w-full px-4 py-2 rounded-lg bg-bg-surface border border-border focus:border-accent-violet outline-none"
/>
{isPending && (
<span className="absolute right-3 top-2.5 text-xs text-accent-violet font-medium animate-pulse">
Buscando...
</span>
)}
</div>
);
}useOptimistic#El hook useOptimistic suprime la percepción de latencia de red: la interfaz se actualiza al instante con la interacción y se revierte automáticamente si la Server Action arroja un error.
'use client';
import { useOptimistic, useTransition } from 'react';
import { toggleLikeAction } from '../server/actions';
interface LikeButtonProps {
postId: string;
initialLikes: number;
initialIsLiked: boolean;
}
export function LikeButton({ postId, initialLikes, initialIsLiked }: LikeButtonProps) {
const [isPending, startTransition] = useTransition();
const [optimisticState, setOptimisticState] = useOptimistic(
{ likes: initialLikes, isLiked: initialIsLiked },
(state, nextLiked: boolean) => ({
likes: nextLiked ? state.likes + 1 : state.likes - 1,
isLiked: nextLiked,
})
);
const handleClick = () => {
const nextValue = !optimisticState.isLiked;
startTransition(async () => {
// 1. Actualización visual optimista inmediata
setOptimisticState(nextValue);
// 2. Ejecución de la Server Action
await toggleLikeAction(postId, nextValue);
});
};
return (
<button
onClick={handleClick}
disabled={isPending}
className={`px-4 py-2 rounded-lg border font-medium transition-colors ${
optimisticState.isLiked
? 'bg-accent-violet/20 border-accent-violet text-accent-violet'
: 'bg-bg-surface border-border text-text-secondary'
}`}
>
❤️ {optimisticState.likes}
</button>
);
}layout.tsx (Patrón Donut)#Los proveedores de contexto en el cliente no deben convertir el layout raíz en un Client Component. Se encapsulan en un componente delimitador:
'use client';
import { ReactNode } from 'react';
import { CartStoreProvider } from '@/entities/cart/ui/CartStoreProvider';
export function AppProviders({ children }: { children: ReactNode }) {
return (
<CartStoreProvider>
{children}
</CartStoreProvider>
);
}En src/app/layout.tsx, el JSX del servidor fluye sin problemas mediante la prop children:
import { AppProviders } from '@/shared/providers/AppProviders';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es">
<body className="bg-bg-primary text-text-primary">
<AppProviders>
{children}
</AppProviders>
</body>
</html>
);
}nuqs#Para flujos complejos de filtrado con múltiples facetas, la librería nuqs garantiza seguridad de tipos y validación de esquemas:
'use client';
import { useQueryState, parseAsStringLiteral } from 'nuqs';
const categories = ['all', 'electronics', 'clothing', 'books'] as const;
export function CategorySelect() {
const [category, setCategory] = useQueryState(
'category',
parseAsStringLiteral(categories).withDefault('all')
);
return (
<select
value={category}
onChange={(e) => setCategory(e.target.value as typeof category)}
className="px-3 py-2 rounded-lg bg-bg-surface border border-border"
>
<option value="all">Todas las categorías</option>
<option value="electronics">Electrónica</option>
<option value="clothing">Ropa</option>
<option value="books">Libros</option>
</select>
);
}En la página de un catálogo, un componente cliente SearchFilter ejecuta useSearchParams() para filtrar elementos. Al compilar con pnpm run build, Next.js emite una advertencia de Client-side Deopt y deshabilita la generación estática (SSG) de la página. ¿Cómo se recupera la generación estática para la ruta?
Declarar un store con export const useStore = create(...) en el ámbito de módulo funciona en local, pero en servidores Node.js los módulos se cachean y comparten entre cientos de peticiones concurrentes. Si durante el SSR se guardan datos de sesión o carrito en ese singleton, otros usuarios recibirán información confidencial ajena en sus respuestas.
Solución: Instancie siempre el store mediante una función factoría createStore() contenida en un React Context y respaldada por useRef.
Al invocar useSearchParams() en un Client Component sin un contenedor <Suspense>, Next.js no puede resolver los valores en tiempo de compilación (next build). La ruta completa se degrada a renderizado dinámico en el cliente (Client-side Deopt), perjudicando el SEO.
Solución: Envuelva siempre los componentes cliente que lean la URL dentro de una frontera <Suspense fallback={<Skeleton />}>.
El middleware persist de Zustand lee de localStorage. Durante el SSR el servidor renderiza el estado por defecto (ej. theme: 'dark'), pero el cliente hidrata con el valor guardado en el navegador ('light'). React detecta la discrepancia en el árbol DOM y arroja un error Hydration Mismatch.
Solución: Emplee useSyncExternalStore con un snapshot de servidor por defecto o posponga el renderizado de la UI dependiente del cliente hasta después del montaje.
Obtener entidades en el servidor y sincronizarlas de inmediato en Zustand mediante useEffect(() => store.setProducts(products), []) es un antipatrón. Duplica el consumo de memoria en el navegador, retrasa la hidratación y produce datos desfasados. Los datos deben residir en los Server Components.