Configuración y uso básico de Zustand
Receta
Instala Zustand, crea un store con create y consúmelo en cualquier componente sin providers ni envoltorios de context.
npm install zustand// stores/counter-store.ts
import { create } from "zustand";
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));"use client";
import { useCounterStore } from "@/stores/counter-store";
function Counter() {
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
return (
<div>
<p>Recuento: {count}</p>
<button onClick={increment}>+1</button>
</div>
);
}Ejemplo funcional
// stores/todo-store.ts
import { create } from "zustand";
interface Todo {
id: string;
text: string;
done: boolean;
}
interface TodoState {
todos: Todo[];
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
removeTodo: (id: string) => void;
clearCompleted: () => void;
}
export const useTodoStore = create<TodoState>((set) => ({
todos: [],
addTodo: (text) =>
set((state) => ({
todos: [...state.todos, { id: crypto.randomUUID(), text, done: false }],
})),
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
})),
removeTodo: (id) =>
set((state) => ({
todos: state.todos.filter((t) => t.id !== id),
})),
clearCompleted: () =>
set((state) => ({
todos: state.todos.filter((t) => !t.done),
})),
}));// components/todo-app.tsx
"use client";
import { useState } from "react";
import { useTodoStore } from "@/stores/todo-store";
export function TodoApp() {
const [input, setInput] = useState("");
const todos = useTodoStore((s) => s.todos);
const addTodo = useTodoStore((s) => s.addTodo);
const toggleTodo = useTodoStore((s) => s.toggleTodo);
const removeTodo = useTodoStore((s) => s.removeTodo);
const clearCompleted = useTodoStore((s) => s.clearCompleted);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (input.trim()) {
addTodo(input.trim());
setInput("");
}
};
return (
<div>
<form onSubmit={handleSubmit}>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button type="submit">Añadir</button>
</form>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleTodo(todo.id)}
/>
<span style={{ textDecoration: todo.done ? "line-through" : "none" }}>
{todo.text}
</span>
</label>
<button onClick={() => removeTodo(todo.id)}>Eliminar</button>
</li>
))}
</ul>
<button onClick={clearCompleted}>Limpiar completados</button>
<p>{todos.filter((t) => !t.done).length} elementos restantes</p>
</div>
);
}Análisis profundo
Cómo funciona
createdevuelve un hook de React que se suscribe al store. El store en sí es un objeto JavaScript vanilla gestionado fuera de React.- La función
setfusiona el state parcial en el state actual (fusión superficial por defecto). - Los componentes solo se re-renderizan cuando cambia el slice de state que seleccionan. Esta es la principal ventaja de rendimiento de Zustand frente a Context.
- No se necesita Provider. El store es un singleton a nivel de módulo que cualquier componente puede importar y usar directamente.
setpuede aceptar un objeto (fusionado) o una función(state) => partialState(para actualizaciones basadas en el state actual).
Variaciones
Obtener el state completo (no recomendado por rendimiento):
const { count, increment } = useCounterStore();
// Re-renderiza ante CUALQUIER cambio de stateUsar el store fuera de React:
// Accede al state directamente (sin hooks)
const count = useCounterStore.getState().count;
// Suscríbete a los cambios
const unsub = useCounterStore.subscribe((state) => {
console.log("El recuento cambió:", state.count);
});Reemplazar el state en lugar de fusionarlo:
set({ count: 0 }, true); // El segundo argumento `true` reemplaza todo el stateNotas de TypeScript
- Pasa siempre la interfaz del state como genérico a
create<State>(). - Las acciones (funciones) conviven con el state en la misma interfaz.
setestá tipado para aceptarPartial<State>o(state: State) => Partial<State>.
import { create, StoreApi } from "zustand";
type Store = StoreApi<CounterState>;Errores comunes
- Seleccionar el store completo (
useStore()sin selector) hace que el componente se re-renderice ante cada cambio de state. Usa siempre selectores. setrealiza una fusión superficial. Los objetos anidados deben propagarse manualmente:set({ user: { ...state.user, name: "new" } }).- El store es un singleton. En entornos SSR (Next.js), un único store se comparte entre todas las solicitudes a menos que crees instancias por solicitud.
- No desestructures el hook del store a nivel de módulo. Llámalo solo dentro de los componentes.
setes sincrónico. La actualización del state y el re-renderizado ocurren en el mismo tick (agrupados por React 18+).
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| Zustand | Sin providers, API mínima, selectores rápidos | Singleton en SSR, curva de aprendizaje para middleware |
| React Context | Integrado, sin dependencias | Re-renderiza a todos los consumidores, sin selectores |
| Redux Toolkit | Ecosistema maduro, DevTools | Boilerplate, configuración compleja |
| Jotai | Modelo atómico, bottom-up | Modelo mental distinto, muchos átomos que gestionar |
Ejemplo del mundo real
De una aplicación SaaS en producción con Next.js 15 / React 19 (SystemsArchitect.io).
// Ejemplo en producción: store de auth
// File: src/stores/auth.ts
import { create } from 'zustand'
import { User } from '@supabase/supabase-js'
interface AuthState {
user: User | null
loading: boolean
setUser: (user: User | null) => void
setLoading: (loading: boolean) => void
}
export const useAuthStore = create<AuthState>()((set) => ({
user: null,
loading: true,
setUser: (user) => set({ user }),
setLoading: (loading) => set({ loading }),
}))
// Uso con selector (evita re-renderizados por cambios de state no relacionados):
// const user = useAuthStore((state) => state.user)
// NO: const { user } = useAuthStore() // se suscribe a TODOS los cambiosLo que esto demuestra en producción:
- Los paréntesis dobles
create<AuthState>()((set) => ...)no son un error tipográfico. El primer()es obligatorio al usar genéricos de TypeScript con Zustand, y también habilita el encadenamiento de middleware (p. ej.,create<AuthState>()(persist(devtools((set) => ...)))). set({ user })hace una fusión superficial, no un reemplazo. Solo se actualiza el campousermientrasloadingpermanece intacto. Este es el comportamiento predeterminado de Zustand.- Usa siempre selectores:
useAuthStore((s) => s.user)se suscribe solo al campouser. Usarconst { user } = useAuthStore()sin selector se suscribe a todo el store, provocando re-renderizados cada vez que cambia cualquier campo (incluidoloading). - El store existe fuera de React como un singleton a nivel de módulo. Se puede acceder desde cualquier lugar mediante
useAuthStore.getState(), incluso en código no React como utilidades de API o middleware. - En SSR con Next.js, este singleton se comparte entre todas las solicitudes en el servidor. Para state específico del usuario, inicializa el store solo en el lado del cliente o usa un patrón de store por solicitud.
FAQs
¿Qué devuelve la función create y cómo se usa?
createdevuelve un hook de React (p. ej.,useCounterStore) que se suscribe al store.- Llámalo con un selector dentro de un componente:
useCounterStore((state) => state.count). - El store en sí es un objeto JavaScript vanilla gestionado fuera de React.
¿Por qué pasas una función selector al hook del store en lugar de desestructurar?
- Los selectores como
(state) => state.countse suscriben solo a ese slice de state. - El componente solo se re-renderiza cuando cambia el valor seleccionado.
- Desestructurar sin selector (
const { count } = useStore()) se suscribe a todo el store, provocando re-renderizados ante cada cambio.
¿Cómo funciona set: reemplaza o fusiona el state?
- Por defecto,
setrealiza una fusión superficial del state parcial en el state actual. - Para reemplazar todo el state, pasa
truecomo segundo argumento:set({ count: 0 }, true). - Los objetos anidados deben propagarse manualmente, ya que la fusión solo es de un nivel de profundidad.
¿Necesitas un Provider o un envoltorio de Context para usar Zustand?
- No. Los stores de Zustand son singletons a nivel de módulo.
- Cualquier componente puede importar y usar el hook del store directamente.
- Los providers solo son necesarios en SSR/Next.js para evitar state compartido entre solicitudes.
¿Cómo accedes al state de Zustand fuera de un componente de React?
// Lee el state directamente
const count = useCounterStore.getState().count;
// Suscríbete a los cambios
const unsub = useCounterStore.subscribe((state) => {
console.log("Recuento:", state.count);
});¿Cuál es la diferencia entre set({ count: 0 }) y set((state) => ({ count: state.count + 1 }))?
set({ count: 0 })establece un valor estático independientemente del state actual.set((state) => ...)calcula el siguiente state basándose en el state actual, lo cual es necesario para incrementos, toggles y actualizaciones derivadas.
Trampa: ¿Qué ocurre si seleccionas el store completo sin selector?
- Llamar a
useStore()sin selector se suscribe a cada propiedad del store. - El componente se re-renderiza ante cualquier cambio de state, incluso campos no relacionados.
- Usa siempre un selector para evitar re-renderizados innecesarios.
Trampa: ¿Por qué set({ user: { name: "new" } }) no conserva otros campos de user?
sethace una fusión superficial solo en el nivel superior.- Los objetos anidados se reemplazan por completo, no se fusionan.
- Debes propagar manualmente:
set((s) => ({ user: { ...s.user, name: "new" } })).
¿Cómo tipas un store de Zustand en TypeScript?
interface CounterState {
count: number;
increment: () => void;
}
const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));- Pasa la interfaz del state como genérico a
create<State>(). - Las acciones y el state conviven en la misma interfaz.
¿Qué tipo acepta set en TypeScript?
setaceptaPartial<State>o(state: State) => Partial<State>.- TypeScript exige que solo establezcas propiedades que existan en la interfaz del state.
¿Por qué el ejemplo en producción usa paréntesis dobles create<AuthState>()((...) => ...)?
- El primer
()es obligatorio al usar genéricos de TypeScript con middleware. - También habilita el encadenamiento de middleware:
create<State>()(persist(devtools((set) => ...))). - Sin middleware,
create<State>((set) => ...)(una sola invocación) es suficiente.
¿set es sincrónico o asincrónico?
setes sincrónico. La actualización del state ocurre de inmediato.- React 18+ agrupa los re-renderizados, por lo que llamadas secuenciales rápidas a
setpueden agruparse en un solo renderizado.