Zustand se ha consolidado en 2026 como la solución de gestión de estado más popular en el ecosistema React. Ligero, intuitivo, sin boilerplate y con TypeScript de primera clase: Zustand es lo que Redux debería haber sido desde el principio. En esta guía completa aprenderás a usar Zustand desde cero junto con los patrones de estado más avanzados para construir aplicaciones React profesionales.
¿Por qué Zustand? El problema del estado global en React
Gestionar el estado global en React siempre ha sido un desafío. El Context API de React es suficiente para casos simples, pero tiene un problema de rendimiento: cuando el contexto cambia, todos los componentes que consumen ese contexto se re-renderizan, aunque no usen el dato que cambió. Redux resuelve esto pero a costa de una cantidad de boilerplate que puede resultar abrumadora.
Zustand aparece con una propuesta diferente: un store global basado en hooks, con selectores atómicos, sin providers y con una API tan sencilla que puedes aprender lo esencial en menos de 10 minutos. Y encima, el rendimiento es excelente porque los componentes solo se re-renderizan cuando cambia exactamente el dato que están consumiendo.
| Característica | Zustand | Redux Toolkit | Context API | Jotai |
|---|---|---|---|---|
| Tamaño del bundle | ⚡ ~1KB | 🔴 ~15KB | ✅ 0KB (nativo) | ⚡ ~3KB |
| Boilerplate | ✅ Mínimo | ⚠️ Medio | ✅ Mínimo | ✅ Mínimo |
| TypeScript | ✅ Excelente | ✅ Excelente | ⚠️ Manual | ✅ Excelente |
| DevTools | ✅ Redux DevTools | ✅ Redux DevTools | ❌ No | ✅ Jotai DevTools |
| Rendimiento | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Curva de aprendizaje | ✅ Muy baja | ⚠️ Media | ✅ Baja | ✅ Baja |
Instalación y tu primer store
npm install zustand
El store más sencillo posible, un contador:
// src/stores/contadorStore.ts
import { create } from 'zustand';
interface ContadorState {
cuenta: number;
incrementar: () => void;
decrementar: () => void;
resetear: () => void;
establecer: (valor: number) => void;
}
export const useContadorStore = create((set) => ({
// Estado inicial
cuenta: 0,
// Acciones
incrementar: () => set((state) => ({ cuenta: state.cuenta + 1 })),
decrementar: () => set((state) => ({ cuenta: state.cuenta - 1 })),
resetear: () => set({ cuenta: 0 }),
establecer: (valor) => set({ cuenta: valor }),
}));
// Uso en cualquier componente (sin Provider, sin contexto)
import { useContadorStore } from './stores/contadorStore';
function Contador() {
const cuenta = useContadorStore((state) => state.cuenta);
const incrementar = useContadorStore((state) => state.incrementar);
const resetear = useContadorStore((state) => state.resetear);
return (
Cuenta: {cuenta}
);
}
Fíjate en algo crucial: no hay ningún Provider que envuelva la aplicación. El store de Zustand es un módulo JavaScript normal que cualquier componente puede importar y usar directamente. Y gracias al selector (state) => state.cuenta, el componente solo se re-renderiza cuando cuenta cambia, no cuando cambia cualquier otra parte del store.
Store completo: gestión del carrito de compra
Veamos un ejemplo más realista: un store para gestionar el carrito de una tienda online.
// src/stores/carritoStore.ts
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
interface Producto {
id: number;
nombre: string;
precio: number;
imagen?: string;
}
interface ItemCarrito extends Producto {
cantidad: number;
}
interface CarritoState {
items: ItemCarrito[];
// Getters computados
totalItems: () => number;
totalPrecio: () => number;
estaEnCarrito: (id: number) => boolean;
// Acciones
agregar: (producto: Producto) => void;
eliminar: (id: number) => void;
cambiarCantidad: (id: number, cantidad: number) => void;
vaciar: () => void;
}
export const useCarritoStore = create()(
// devtools: integración con Redux DevTools
devtools(
// persist: guarda el estado en localStorage automáticamente
persist(
(set, get) => ({
items: [],
// Getters: funciones que calculan valores derivados
totalItems: () => get().items.reduce((acc, item) => acc + item.cantidad, 0),
totalPrecio: () => get().items.reduce((acc, item) => acc + item.precio * item.cantidad, 0),
estaEnCarrito: (id) => get().items.some((item) => item.id === id),
// Acciones
agregar: (producto) => {
set((state) => {
const itemExistente = state.items.find((i) => i.id === producto.id);
if (itemExistente) {
// Si ya existe, incrementa la cantidad
return {
items: state.items.map((i) =>
i.id === producto.id ? { ...i, cantidad: i.cantidad + 1 } : i
),
};
}
// Si no existe, añade con cantidad 1
return { items: [...state.items, { ...producto, cantidad: 1 }] };
});
},
eliminar: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
cambiarCantidad: (id, cantidad) => {
if (cantidad <= 0) {
get().eliminar(id);
return;
}
set((state) => ({
items: state.items.map((i) => (i.id === id ? { ...i, cantidad } : i)),
}));
},
vaciar: () => set({ items: [] }),
}),
{
name: 'carrito-storage', // Clave en localStorage
// Solo persistir los items, no las funciones
partialize: (state) => ({ items: state.items }),
}
),
{ name: 'CarritoStore' } // Nombre en Redux DevTools
)
);
// Hook con selector optimizado para el badge del carrito
export const useTotalItems = () => useCarritoStore((state) => state.totalItems());
Middlewares de Zustand: persist, devtools e immer
Zustand tiene un sistema de middlewares muy potente. Los más útiles son:
- persist: Persiste el estado en localStorage, sessionStorage o cualquier storage personalizado. Ideal para carritos, preferencias de usuario, tokens, etc.
- devtools: Integra el store con las Redux DevTools del navegador. Puedes ver cada acción y el estado en tiempo real, hacer time-travel debugging y mucho más.
- immer: Permite mutar el estado directamente (con sintaxis mutable) usando Immer por debajo, que genera las actualizaciones inmutables automáticamente.
// Usando immer para mutaciones directas (más legible para estados anidados)
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface State {
usuario: { nombre: string; preferencias: { tema: 'claro' | 'oscuro'; idioma: string } };
actualizarTema: (tema: 'claro' | 'oscuro') => void;
}
const useStore = create()(
immer((set) => ({
usuario: {
nombre: 'Adam',
preferencias: { tema: 'oscuro', idioma: 'es' },
},
// Con immer puedes mutar directamente sin spread
actualizarTema: (tema) =>
set((state) => {
state.usuario.preferencias.tema = tema; // ¡Mutación directa!
}),
}))
);
Patrones avanzados: stores asíncronos y fetching de datos
// src/stores/productosStore.ts
import { create } from 'zustand';
type EstadoCarga = 'idle' | 'cargando' | 'exito' | 'error';
interface Producto { id: number; nombre: string; precio: number; }
interface ProductosState {
productos: Producto[];
estado: EstadoCarga;
error: string | null;
paginaActual: number;
totalPaginas: number;
// Acciones
cargarProductos: (pagina?: number) => Promise;
buscarProductos: (query: string) => Promise;
}
export const useProductosStore = create((set, get) => ({
productos: [],
estado: 'idle',
error: null,
paginaActual: 1,
totalPaginas: 1,
cargarProductos: async (pagina = 1) => {
set({ estado: 'cargando', error: null });
try {
const res = await fetch('/api/productos?pagina=' + pagina);
if (!res.ok) throw new Error('Error al cargar productos');
const { datos, totalPaginas } = await res.json();
set({ productos: datos, estado: 'exito', paginaActual: pagina, totalPaginas });
} catch (err) {
set({ estado: 'error', error: err instanceof Error ? err.message : 'Error desconocido' });
}
},
buscarProductos: async (query) => {
if (!query.trim()) {
get().cargarProductos();
return;
}
set({ estado: 'cargando' });
try {
const res = await fetch('/api/productos?busqueda=' + encodeURIComponent(query));
const { datos } = await res.json();
set({ productos: datos, estado: 'exito' });
} catch {
set({ estado: 'error', error: 'Error en la búsqueda' });
}
},
}));
// Componente que usa el store asíncrono
function ListaProductos() {
const { productos, estado, error, cargarProductos } = useProductosStore();
useEffect(() => { cargarProductos(); }, []);
if (estado === 'cargando') return Cargando...
;
if (estado === 'error') return Error: {error}
;
return (
{productos.map((p) => - {p.nombre} - {p.precio}€
)}
);
}
Separar el store en slices para proyectos grandes
Para proyectos grandes, se recomienda dividir el store en «slices» (segmentos) y combinarlos. Esto mejora la organización y permite reutilizar la lógica:
// src/stores/slices/authSlice.ts
import { StateCreator } from 'zustand';
export interface AuthSlice {
usuario: { id: number; nombre: string } | null;
estaAutenticado: boolean;
login: (email: string, pass: string) => Promise;
logout: () => void;
}
export const createAuthSlice: StateCreator = (set) => ({
usuario: null,
estaAutenticado: false,
login: async (email, pass) => {
const res = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password: pass }),
headers: { 'Content-Type': 'application/json' },
});
const { user } = await res.json();
set({ usuario: user, estaAutenticado: true });
},
logout: () => set({ usuario: null, estaAutenticado: false }),
});
// src/stores/slices/uiSlice.ts
export interface UISlice {
sidebarAbierto: boolean;
tema: 'claro' | 'oscuro';
toggleSidebar: () => void;
cambiarTema: () => void;
}
export const createUISlice: StateCreator = (set) => ({
sidebarAbierto: false,
tema: 'oscuro',
toggleSidebar: () => set((s) => ({ sidebarAbierto: !s.sidebarAbierto })),
cambiarTema: () => set((s) => ({ tema: s.tema === 'claro' ? 'oscuro' : 'claro' })),
});
// src/stores/appStore.ts - Store combinado
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { AuthSlice, createAuthSlice } from './slices/authSlice';
import { UISlice, createUISlice } from './slices/uiSlice';
type AppStore = AuthSlice & UISlice;
export const useAppStore = create()(
devtools(
persist(
(...args) => ({
...createAuthSlice(...args),
...createUISlice(...args),
}),
{ name: 'app-store', partialize: (s) => ({ tema: s.tema }) }
)
)
);
Testing de stores con Zustand
// src/stores/__tests__/carritoStore.test.ts
import { act, renderHook } from '@testing-library/react';
import { useCarritoStore } from '../carritoStore';
// Resetear el store entre tests
beforeEach(() => {
useCarritoStore.setState({ items: [] });
});
describe('CarritoStore', () => {
const producto = { id: 1, nombre: 'Teclado', precio: 89.99 };
test('debe agregar un producto al carrito', () => {
const { result } = renderHook(() => useCarritoStore());
act(() => { result.current.agregar(producto); });
expect(result.current.items).toHaveLength(1);
expect(result.current.items[0].cantidad).toBe(1);
});
test('debe incrementar la cantidad si el producto ya existe', () => {
const { result } = renderHook(() => useCarritoStore());
act(() => {
result.current.agregar(producto);
result.current.agregar(producto);
});
expect(result.current.items).toHaveLength(1);
expect(result.current.items[0].cantidad).toBe(2);
});
test('debe calcular el total correctamente', () => {
const { result } = renderHook(() => useCarritoStore());
act(() => { result.current.agregar(producto); });
expect(result.current.totalPrecio()).toBeCloseTo(89.99);
});
});
Conclusión
Zustand es la prueba de que la gestión de estado global en React no tiene por qué ser complicada. Con una API mínima, rendimiento excelente, TypeScript de primera clase y middlewares potentes como persist y devtools, cubre el 95% de los casos de uso sin el overhead de Redux.
Para la mayoría de proyectos React en 2026, la combinación de estado local con useState/useReducer para UI simple y Zustand para estado global compartido es la arquitectura más pragmática y mantenible. Si todavía no lo has probado, hazlo en tu próximo proyecto. Creo que no querrás volver atrás. ¿Qué solución de estado usas actualmente en tus proyectos? Déjame tu respuesta en los comentarios.