En el ecosistema de TypeScript, la gestión del estado en cliente mediante Zustand ha alcanzado una hegemonía notable gracias a su arquitectura ligera basada en clausuras y sincronización directa mediante useSyncExternalStore. Sin embargo, al evolucionar hacia arquitecturas de nivel empresarial, los equipos se enfrentan a un desafío técnico relevante: el tipado preciso de cadenas de middleware y la composición modular de fragmentos de estado independientes (Slice Pattern).
Históricamente, la función create en Zustand ofrecía la sobrecarga genérica directa create<T>(...). Al suministrar explícitamente el parámetro T, el compilador de TypeScript fijaba el tipo raíz del estado, pero perdía la capacidad de inferir automáticamente las mutaciones de tipo aportadas por el middleware (como los borradores mutables de Immer o los métodos de persistencia añadidos por persist).
Para resolver esta limitación de inferencia, las versiones modernas de Zustand (v4 y v5) introdujeron el patrón de declaración con curry: create<T>()(...). La primera llamada fija la interfaz del almacén raíz, devolviendo una fábrica de orden superior que propaga los mutadores en el sistema de tipos (Type-Level Mutators):
+-------------------------------------------------------------------------+
| MECANISMO DE CURRYING Y MUTADORES DE TIPO EN ZUSTAND |
+-------------------------------------------------------------------------+
| [create<RootStore>()] |
| |--> Fija el contrato base del almacén: T = RootStore |
| |--> Devuelve la fábrica con soporte para Mutator Tuple |
+-------------------------------------------------------------------------+
|
v
| [(...args) => devtools(persist(immer((set, get) => ({ ... }))))] |
| | |
| |-- Mutador 1: 'zustand/devtools' -> Añade nombre de acción a set() |
| |-- Mutador 2: 'zustand/persist' -> Expone APIs de api.persist |
| `-- Mutador 3: 'zustand/immer' -> Convierte draft en set() |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| Tipo de hook resultante: UseBoundStore<Write<StoreApi<T>, Mutators>> |
| (Autocompletado integral de acciones, propiedades y APIs de middleware) |
+-------------------------------------------------------------------------+La arquitectura interna de tipado en Zustand se fundamenta en el genérico StateCreator, el cual gestiona tuplas de mutadores:
Coordinación entre slices mediante StateCreator:
+-------------------------------------------------------------+
| RootStore |
| (AuthSlice & CartSlice & NotificationSlice) |
+-------------------------------------------------------------+
^ ^
| |
StateCreator<...> StateCreator<...>
| |
+--------------------+ +--------------------+
| AuthSlice | | CartSlice |
| - user: User | | - items: Item[] |
| - login(): void | | - checkout(): void|
| | | (lee auth mediante|
| | | get()) |
+--------------------+ +--------------------+localStorage mediante persist, con serialización selectiva y auditoría en Redux DevTools.immer para mutar directamente estructuras de capas y nodos vectoriales anidados con control de historial (Undo/Redo).useShallow para impedir re-renderizados en cascada en el árbol de componentes.Cada slice se declara como una función aislada tipada mediante StateCreator. Cuando un slice requiere características de middleware (como nombrar acciones para devtools o borradores de immer), estas se declaran en el segundo parámetro de tipo de StateCreator:
import { StateCreator } from 'zustand';
import { RootStore } from '../rootStore';
export interface UserProfile {
readonly id: string;
readonly email: string;
readonly role: 'admin' | 'operator' | 'viewer';
}
export interface AuthSlice {
readonly user: UserProfile | null;
readonly token: string | null;
readonly isAuthenticated: boolean;
readonly login: (token: string, profile: UserProfile) => void;
readonly logout: () => void;
}
/**
* Slice de autenticación.
* Toma RootStore como tipo base para permitir interoperabilidad sin tipos cíclicos.
*/
export const createAuthSlice: StateCreator<
RootStore,
[['zustand/devtools', never], ['zustand/immer', never]],
[],
AuthSlice
> = (set) => ({
user: null,
token: null,
isAuthenticated: false,
login: (token, profile) =>
set(
(state) => {
state.user = profile;
state.token = token;
state.isAuthenticated = true;
},
false,
'auth/login'
),
logout: () =>
set(
(state) => {
state.user = null;
state.token = null;
state.isAuthenticated = false;
},
false,
'auth/logout'
),
});get()#El slice de carrito ilustra cómo acceder de forma segura al estado de autenticación desde un slice adyacente utilizando get():
import { StateCreator } from 'zustand';
import { RootStore } from '../rootStore';
export interface CartItem {
readonly id: string;
readonly priceCents: number;
readonly quantity: number;
}
export interface CartSlice {
readonly items: ReadonlyArray<CartItem>;
readonly addItem: (item: CartItem) => void;
readonly clearCart: () => void;
readonly checkout: () => Promise<boolean>;
}
export const createCartSlice: StateCreator<
RootStore,
[['zustand/devtools', never], ['zustand/immer', never]],
[],
CartSlice
> = (set, get) => ({
items: [],
addItem: (newItem) =>
set(
(state) => {
const existing = state.items.find((i) => i.id === newItem.id);
if (existing) {
existing.quantity += newItem.quantity;
} else {
state.items.push(newItem);
}
},
false,
'cart/addItem'
),
clearCart: () =>
set(
(state) => {
state.items = [];
},
false,
'cart/clearCart'
),
checkout: async () => {
// Lectura segura del estado del slice de autenticación mediante get()
const { isAuthenticated, token } = get();
if (!isAuthenticated || !token) {
throw new Error('[CheckoutError] Operación no permitida sin autenticación previa');
}
const { items } = get();
if (items.length === 0) {
return false;
}
// Procesamiento de la petición de compra
return true;
},
});create en formato curry#La integración de múltiples slices aplicando devtools, persist e immer exige respetar estrictamente el orden de anidamiento de los envoltorios:
import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { AuthSlice, createAuthSlice } from './slices/authSlice';
import { CartSlice, createCartSlice } from './slices/cartSlice';
export type RootStore = AuthSlice & CartSlice;
/**
* La llamada en formato curry create<RootStore>()(...) asegura:
* 1. La preservación de tipos de todas las uniones de slices.
* 2. La inyección de tipos draft de Immer en set().
* 3. Nombres de acción tipados en Redux DevTools.
*/
export const useRootStore = create<RootStore>()(
devtools(
persist(
immer((...a) => ({
...createAuthSlice(...a),
...createCartSlice(...a),
})),
{
name: 'agenticlab_enterprise_store',
storage: createJSONStorage(() => localStorage),
// Persistir únicamente el token y los elementos del carrito
partialize: (state) => ({
token: state.token,
items: state.items,
}),
}
),
{ name: 'RootStore', enabled: process.env.NODE_ENV !== 'production' }
)
);useShallow#'use client';
import React from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useRootStore } from '../store/rootStore';
export function HeaderUserBadge() {
// Extracción de múltiples propiedades optimizada contra re-renderizados redundantes
const { user, isAuthenticated, logout } = useRootStore(
useShallow((state) => ({
user: state.user,
isAuthenticated: state.isAuthenticated,
logout: state.logout,
}))
);
if (!isAuthenticated || !user) {
return <span className="text-xs font-mono text-text-muted">Invitado</span>;
}
return (
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-accent-cyan">{user.email}</span>
<span className="text-xs px-2 py-0.5 rounded bg-bg-elevated border border-border text-text-muted">
{user.role}
</span>
<button
onClick={logout}
className="text-xs text-danger hover:underline font-mono"
>
Cerrar sesión
</button>
</div>
);
}La primera invocación asegura el contrato raíz T = RootStore, aislando el contexto de inferencia.
Las funciones devtools -> persist -> immer agregan descriptores de mutación a la tupla de tipos del compilador.
Gracias a los mutadores acumulados, el argumento set se tipa como Immer Draft<RootStore>, permitiendo mutaciones directas.
El compilador genera el hook useRootStore con autocompletado total de acciones y el método useRootStore.getState().
import { create, StateCreator, StoreMutatorIdentifier } from 'zustand';
// Descriptor de mutador personalizado para el motor de tipos de Zustand
type Logger = <
T,
Mps extends [StoreMutatorIdentifier, unknown][] = [],
Mcs extends [StoreMutatorIdentifier, unknown][] = []
>(
f: StateCreator<T, Mps, Mcs>,
name?: string
) => StateCreator<T, Mps, Mcs>;
type LoggerImpl = <T>(
f: StateCreator<T, [], []>,
name?: string
) => StateCreator<T, [], []>;
const loggerImpl: LoggerImpl = (f, name) => (set, get, store) => {
const loggedSet: typeof set = (...args) => {
if (process.env.NODE_ENV !== 'production') {
console.group(`[Zustand Action: ${name ?? 'Anonymous'}]`);
console.log('Estado anterior:', get());
}
set(...args);
if (process.env.NODE_ENV !== 'production') {
console.log('Estado posterior:', get());
console.groupEnd();
}
};
store.setState = loggedSet;
return f(loggedSet, get, store);
};
export const logger = loggerImpl as unknown as Logger;
// Ejemplo de creación de almacén
export interface SettingsState {
readonly theme: 'dark' | 'light' | 'system';
readonly language: string;
readonly setTheme: (theme: 'dark' | 'light' | 'system') => void;
}
export const useSettingsStore = create<SettingsState>()(
logger(
(set) => ({
theme: 'system',
language: 'es',
setTheme: (theme) => set({ theme }),
}),
'SettingsStore'
)
);Al ejecutar create<MyState>((set) => immer(...)), el compilador asume que set acepta exclusivamente reemplazos inmutables estándar, rechazando mutaciones directas como state.property = value debido a que los tipos draft no llegaron a inferirse.
// ❌ INCORRECTO: Invocación directa sin curry anula el tipado de Immer
const useBadStore = create<MyState>(
immer((set) => ({
count: 0,
inc: () => set((state) => { state.count += 1; }), // ¡ERROR DE COMPILACIÓN!
}))
);
// ✅ CORRECTO: Invocación en formato curry con paréntesis vacíos
const useGoodStore = create<MyState>()(
immer((set) => ({
count: 0,
inc: () => set((state) => { state.count += 1; }), // Compila limpiamente
}))
);Si el orden de los mutadores en la tupla del slice no coincide con el orden real de aplicación del middleware en rootStore.ts, el compilador emitirá un error de incompatibilidad de tipos en los parámetros de set.
// ❌ INCORRECTO: Tupla de mutadores con orden invertido
export const createBadSlice: StateCreator<
RootStore,
[['zustand/immer', never], ['zustand/devtools', never]], // ¡Orden incorrecto!
[],
MySlice
> = ...Un selector que devuelve un nuevo objeto literal { a: state.a, b: state.b } en cada llamada provocará que Object.is falle en cada actualización del almacén, disparando re-renderizados perpetuos del componente.
// ❌ NO OPTIMIZADO: Re-renderizado ante CUALQUIER cambio en el almacén
const { a, b } = useRootStore((s) => ({ a: s.a, b: s.b }));
// ✅ OPTIMIZADO: Protegido con useShallow
const { a, b } = useRootStore(useShallow((s) => ({ a: s.a, b: s.b })));Si authSlice.ts importa tipos de cartSlice.ts mientras cartSlice.ts importa directamente authSlice.ts, TypeScript puede alcanzar límites de recursión al generar archivos .d.ts. Los slices deben depender exclusivamente de la interfaz compartida RootStore.
// ✅ CORRECTO: El slice se tipa mediante RootStore definido en el módulo raíz
export const createAuthSlice: StateCreator<RootStore, ...> = ...Declarar interfaces para AuthSlice y CartSlice, combinándolas en RootStore = AuthSlice & CartSlice.
Implementar cada slice en un archivo aislado empleando StateCreator<RootStore, Mutators, [], Slice>.
Integrar los slices en rootStore.ts encadenando devtools(persist(immer(...))).
Definir la función partialize para registrar en almacenamiento únicamente los campos indispensables.
Suscribirse a selectores compuestos empleando useShallow para descartar re-renderizados innecesarios.