En la arquitectura frontend contemporánea de la era React 19, se ha consolidado una demarcación fundamental: el estado del servidor (Server Cache) se delega en herramientas especializadas (TanStack Query, Server Actions, React Server Components), mientras que el estado del cliente queda reservado con exclusividad al comportamiento interactivo de la UI, sesiones locales activas, borradores optimistas y la coordinación efímera entre componentes.
Los enfoques heredados (Redux con exceso de código repetitivo o React Context con re-renderizados descontrolados de suscriptores) han cedido su lugar a dos paradigmas dominantes:
useSyncExternalStore.Recurrir a patrones obsoletos de gestión de estado en el cliente acarrea deficiencias críticas de rendimiento y escalabilidad:
Provider fuerza el re-renderizado de todos los componentes que invoquen useContext, incluso si solo consumen una propiedad inmutable dentro de un objeto complejo.useSyncExternalStore provocan que distintas secciones de la interfaz muestren datos desfasados en un mismo fotograma.Tanto Zustand como Jotai pesan entre 1 KB y 3 KB, son totalmente compatibles con React 19 Concurrent Mode y garantizan un aislamiento preciso de los re-renderizados.
La diferencia cardinal entre Zustand y Jotai radica en la topología de memoria y en la mecánica de seguimiento de dependencias:
(state) => state.selectedField. Para neutralizar re-renderizados al retornar objetos derivados, se aplica comparación superficial (Shallow Equality mediante useShallow).// Topología Zustand: Store Centralizado + Selector de Proyección
const userName = useRootStore((state) => state.auth.user.name);
// Topología Jotai: Grafo Atómico de Dependencias
const [cartItems] = useAtom(cartItemsAtom); // Nodo raíz primitivo
const [totalPrice] = useAtom(totalPriceAtom); // Nodo computado derivado y memoizado automáticamenteRegistros de base de datos con invalidación de red se delegan a TanStack Query o Server Actions. El estado de cliente retiene exclusivamente parámetros de UI, sesiones y borradores efímeros.
Elija Zustand para paneles globales, autenticación y configuraciones transversales. Elija Jotai para lienzos de diseño, celdas interactivas y formularios con cientos de campos dependientes.
En Zustand, estructure slices de dominio mediante StateCreator. En Jotai, divida variables en átomos raíz y declare átomos puros derivados para proyecciones computadas.
Aplique useShallow en selectores de Zustand que retornan objetos. En flujos de 60+ FPS (scroll, canvas), suscríbase imperativamente con subscribeWithSelector.
Garantice inicializaciones deterministas en almacenes persistentes difiriendo la lectura en cliente para erradicar advertencias de Hydration Mismatch.
¿Cuál es la diferencia fundamental en el mecanismo de re-renderizado al mutar un campo de un formulario entre un almacén centralizado Zustand y un grafo de átomos Jotai?
localStorage mediante atomWithStorage sin contaminar esquemas globales.En aplicaciones de gran escala, aglutinar todo el estado y sus mutaciones en un único archivo produce un monolito inmanejable. El patrón Slices descompone el estado en fábricas de dominio independientes que se integran en un RootStore unificado.
Para asegurar la coherencia tipográfica de TypeScript entre slices, se emplea la utilidad StateCreator de Zustand:
import { StateCreator } from 'zustand';
import { RootStore } from '../rootStore';
export interface UserSession {
readonly id: string;
readonly email: string;
readonly role: 'admin' | 'member' | 'guest';
}
export interface AuthSlice {
session: UserSession | null;
isAuthenticated: boolean;
setSession: (session: UserSession | null) => void;
logout: () => void;
}
export const createAuthSlice: StateCreator<
RootStore,
[['zustand/devtools', never]],
[],
AuthSlice
> = (set) => ({
session: null,
isAuthenticated: false,
setSession: (session) =>
set(
{ session, isAuthenticated: Boolean(session) },
false,
'auth/setSession'
),
logout: () =>
set(
{ session: null, isAuthenticated: false },
false,
'auth/logout'
),
});import { StateCreator } from 'zustand';
import { RootStore } from '../rootStore';
export type ThemeMode = 'dark' | 'light' | 'system';
export interface UISlice {
theme: ThemeMode;
sidebarOpen: boolean;
toggleSidebar: () => void;
setTheme: (theme: ThemeMode) => void;
}
export const createUISlice: StateCreator<
RootStore,
[['zustand/devtools', never]],
[],
UISlice
> = (set) => ({
theme: 'dark',
sidebarOpen: true,
toggleSidebar: () =>
set(
(state) => ({ sidebarOpen: !state.sidebarOpen }),
false,
'ui/toggleSidebar'
),
setTheme: (theme) =>
set({ theme }, false, 'ui/setTheme'),
});import { create } from 'zustand';
import { devtools, subscribeWithSelector } from 'zustand/middleware';
import { AuthSlice, createAuthSlice } from './slices/authSlice';
import { UISlice, createUISlice } from './slices/uiSlice';
export type RootStore = AuthSlice & UISlice;
export const useRootStore = create<RootStore>()(
devtools(
subscribeWithSelector((...args) => ({
...createAuthSlice(...args),
...createUISlice(...args),
})),
{ name: 'AgenticLab_GlobalStore', enabled: process.env.NODE_ENV !== 'production' }
)
);Cuando un componente extrae múltiples campos mediante un objeto literal dentro de un selector, generar una referencia nueva en cada evaluación dispara re-renders continuos:
// ❌ ANTIPATRÓN: ¡Nueva referencia de objeto generada en cada tic del almacén!
const { session, theme } = useRootStore((state) => ({
session: state.session,
theme: state.theme,
}));
// ✅ PATRÓN DE PRODUCCIÓN: useShallow coteja las propiedades por valor superficial
import { useShallow } from 'zustand/react/shallow';
const { session, theme } = useRootStore(
useShallow((state) => ({
session: state.session,
theme: state.theme,
}))
);En flujos de datos de frecuencia ultra-alta (seguimiento de cursor, renderizado en canvas, desplazamiento continuo), el ciclo de reconciliación de React se evade íntegramente mediante Transient Updates:
import { useEffect, useRef } from 'react';
import { useRootStore } from '../store/rootStore';
export function CursorTracker() {
const dotRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Suscripción directa al store eludiendo el ciclo de React Reconciliation
const unsubscribe = useRootStore.subscribe(
(state) => state.sidebarOpen,
(isOpen) => {
if (dotRef.current) {
dotRef.current.style.transform = isOpen
? 'translateX(260px)'
: 'translateX(0px)';
}
},
{ fireImmediately: true }
);
return unsubscribe;
}, []);
return (
<div
ref={dotRef}
className="fixed bottom-4 left-4 w-4 h-4 rounded-full bg-accent-cyan transition-transform duration-200 pointer-events-none"
/>
);
}En el paradigma de Jotai, el átomo se define estáticamente en el ámbito del módulo y contiene únicamente su configuración. El valor en tiempo de ejecución reside aislado en el contenedor del almacén:
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
export interface CartItem {
readonly id: string;
readonly title: string;
readonly unitPrice: number;
readonly quantity: number;
}
// 1. Átomo primitivo persistente (LocalStorage con valor seguro para SSR)
export const cartItemsAtom = atomWithStorage<CartItem[]>(
'agenticlab_cart',
[],
undefined,
{ getOnInit: true }
);
// 2. Átomo primitivo para código promocional
export const promoCodeAtom = atom<string>('');
// 3. Átomo derivado de solo lectura para subtotal (Cálculo puro)
export const subtotalAtom = atom<number>((get) => {
const items = get(cartItemsAtom);
return items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
});
// 4. Átomo derivado asíncrono para descuento (Compatible con React 19 Suspense)
export const discountAtom = atom<Promise<number>>(async (get) => {
const promo = get(promoCodeAtom).trim().toUpperCase();
const subtotal = get(subtotalAtom);
if (!promo || subtotal === 0) return 0;
const response = await fetch(`/api/discount?code=${encodeURIComponent(promo)}`);
if (!response.ok) return 0;
const data = (await response.json()) as { percentage: number };
return (subtotal * data.percentage) / 100;
});
// 5. Átomo derivado de lectura/escritura para gestionar mutaciones del carrito
export const cartActionsAtom = atom(
null,
(get, set, action: { type: 'ADD'; item: CartItem } | { type: 'REMOVE'; id: string }) => {
const currentItems = get(cartItemsAtom);
if (action.type === 'ADD') {
const existingIndex = currentItems.findIndex((i) => i.id === action.item.id);
if (existingIndex > -1) {
const next = [...currentItems];
next[existingIndex] = {
...next[existingIndex],
quantity: next[existingIndex].quantity + action.item.quantity,
};
set(cartItemsAtom, next);
} else {
set(cartItemsAtom, [...currentItems, action.item]);
}
} else if (action.type === 'REMOVE') {
set(cartItemsAtom, currentItems.filter((i) => i.id !== action.id));
}
}
);La interacción del usuario dispara la actualización: llamada a set() en Zustand o set(atom, value) en Jotai.
Zustand fusiona las ramas del estado. Jotai recorre el grafo acíclico dirigido (DAG) y marca como sucias solo las ramas descendientes dependientes.
useShallow en Zustand frena la notificación al componente si las propiedades permanecen idénticas. Jotai alerta únicamente a los suscriptores del nodo modificado.
useSyncExternalStore entrega la instantánea actualizada a React Fiber, erradicando desincronizaciones visuales (Tearing) en Concurrent Mode.
¿Por qué el componente const { user, theme } = useRootStore(s => ({ user: s.user, theme: s.theme })) se re-renderizará ante cualquier cambio del campo no relacionado s.isSidebarOpen si no se envuelve en useShallow?
import { create, StateCreator } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
import { useShallow } from 'zustand/react/shallow';
// Slice de estado de sesión y seguridad
export interface SessionState {
token: string | null;
expiresAt: number | null;
activeTenantId: string | null;
setAuthToken: (token: string, expiresInSeconds: number) => void;
switchTenant: (tenantId: string) => void;
clearSession: () => void;
}
const createSessionSlice: StateCreator<
EnterpriseStore,
[['zustand/devtools', never], ['zustand/persist', unknown]],
[],
SessionState
> = (set) => ({
token: null,
expiresAt: null,
activeTenantId: null,
setAuthToken: (token, expiresInSeconds) =>
set(
{
token,
expiresAt: Date.now() + expiresInSeconds * 1000,
},
false,
'session/setAuthToken'
),
switchTenant: (activeTenantId) =>
set({ activeTenantId }, false, 'session/switchTenant'),
clearSession: () =>
set(
{ token: null, expiresAt: null, activeTenantId: null },
false,
'session/clearSession'
),
});
export type EnterpriseStore = SessionState;
export const useEnterpriseStoreBase = create<EnterpriseStore>()(
devtools(
persist(
(...args) => ({
...createSessionSlice(...args),
}),
{
name: 'agenticlab_enterprise_session',
storage: createJSONStorage(() => sessionStorage),
partialize: (state) => ({
token: state.token,
expiresAt: state.expiresAt,
activeTenantId: state.activeTenantId,
}),
}
),
{ name: 'EnterpriseStore' }
)
);
/**
* Envoltorio para consultas con tipado seguro y protección contra re-renderizados innecesarios.
*/
export function useEnterpriseStore<T>(
selector: (state: EnterpriseStore) => T,
equalityFn?: (a: T, b: T) => boolean
): T {
return useEnterpriseStoreBase(selector, equalityFn);
}
/**
* Hook preconfigurado para extraer el fragmento de sesión evitando re-renders redundantes
*/
export function useCurrentSession() {
return useEnterpriseStoreBase(
useShallow((state) => ({
isAuthorized: Boolean(state.token && state.expiresAt && state.expiresAt > Date.now()),
activeTenantId: state.activeTenantId,
setAuthToken: state.setAuthToken,
clearSession: state.clearSession,
}))
);
}Un selector que genera un nuevo objeto en cada invocación ((state) => ({ a: state.a, b: state.b })) rompe la igualdad estricta por referencia ===. En consecuencia, el componente consumidor se re-renderizará ante cualquier mutación del store, aun cuando los campos a y b permanezcan invariables.
// ❌ ANTIPATRÓN: Se re-renderiza con cualquier actualización global del store
const { user, role } = useRootStore((state) => ({
user: state.user,
role: state.role,
}));
// ✅ PATRÓN DE PRODUCCIÓN: Envoltorio en useShallow para evaluar claves de primer nivel
import { useShallow } from 'zustand/react/shallow';
const { user, role } = useRootStore(
useShallow((state) => ({
user: state.user,
role: state.role,
}))
);Invocar la función atom() en el cuerpo de render de un componente React crea un nuevo descriptor de átomo en cada ciclo. El estado anterior se pierde por completo, las referencias se reinician y se acumulan fugas de memoria por suscripciones huérfanas.
// ❌ ANTIPATRÓN: ¡Se crea un átomo nuevo en cada renderizado del componente!
function BadSearchInput() {
const localQueryAtom = atom(''); // FUGA DE MEMORIA: Descriptor nuevo en cada frame
const [query, setQuery] = useAtom(localQueryAtom);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
// ✅ PATRÓN DE PRODUCCIÓN: El átomo se declara estáticamente en el ámbito del módulo
const searchQueryAtom = atom('');
function GoodSearchInput() {
const [query, setQuery] = useAtom(searchQueryAtom);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}Durante el renderizado en servidor (SSR), sessionStorage o localStorage no existen en el entorno. Si un Client Component lee de inmediato el almacén persistido, el marcado del servidor y del cliente divergen, desencadenando un fallo crítico de hidratación en React.
// ❌ ANTIPATRÓN: Lectura de estado persistente antes de completar el montaje
export function UserProfileHeader() {
const token = useEnterpriseStore((s) => s.token);
// En servidor: token === null; en cliente: token === 'xyz' -> ¡Error de hidratación!
return <div>{token ? 'Panel de Usuario' : 'Invitado'}</div>;
}
// ✅ PATRÓN DE PRODUCCIÓN: Diferir la presentación hasta que el montaje sea efectivo
export function UserProfileHeaderSafe() {
const [isMounted, setIsMounted] = useState(false);
const token = useEnterpriseStore((s) => s.token);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return <div>Cargando perfil...</div>;
}
return <div>{token ? 'Panel de Usuario' : 'Invitado'}</div>;
}Modificar directamente arrays u objetos anidados mantiene intacta la referencia en memoria. Como consecuencia, los selectores no detectan alteraciones y la interfaz de usuario no se actualiza.
// ❌ ANTIPATRÓN: Mutación directa del array en memoria
const useStoreBad = create((set) => ({
todos: [{ id: '1', done: false }],
toggleTodo: (id: string) => set((state: any) => {
state.todos[0].done = true; // ¡La referencia del array no varió! ¡No hay re-render!
return { todos: state.todos };
}),
}));
// ✅ PATRÓN DE PRODUCCIÓN: Transformación inmutable del array
const useStoreGood = create((set) => ({
todos: [{ id: '1', done: false }],
toggleTodo: (id: string) =>
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done } : todo
),
})),
}));