Saltar al contenido principal

Capacidades de Zustand

Con create, set y selectores ya puedes resolver la mayoría de los casos del curso. Esta lección reúne lo que Zustand agrega cuando el store crece: controlar con precisión qué se vuelve a renderizar, hacer peticiones desde las acciones, guardar el estado en localStorage, depurarlo con las herramientas de Redux y repartirlo en varios archivos.


1. Selectores y re-renders​

La promesa de Zustand es que un componente solo se renderiza cuando cambia lo que seleccionó. Observa en el árbol de la barra de navegación qué componentes se vuelven a pintar cuando llega una notificación, con y sin selectores:

Qué componentes se re-renderizan según el selector

La regla con la que Zustand decide: después de cada set, vuelve a ejecutar el selector de cada componente suscrito y compara el resultado nuevo con el anterior usando Object.is (igualdad por referencia). Si son iguales, no hay render.

SelectorQué devuelve¿Cuándo re-renderiza?
useStore()El store completoCon cualquier cambio
useStore((s) => s.notifications)El arregloCuando se reemplaza el arreglo por otro
useStore((s) => s.addNotification)Una funciónNunca (la referencia no cambia)
useStore((s) => s.notifications.length)Un númeroSolo si cambia la cantidad

useShallow: seleccionar varios valores​

Es tentador pedir varias cosas a la vez con un objeto:

Esto provoca un bucle en Zustand 5
// Cada render crea un objeto NUEVO, Object.is lo ve distinto y vuelve a renderizar
const { user, token } = useAuthStore((s) => ({ user: s.user, token: s.token }));

En Zustand 5 esto termina en un error de "maximum update depth exceeded". La solución es envolver el selector con useShallow, que compara el contenido del objeto propiedad por propiedad en lugar de por referencia:

Varios valores con useShallow
import { useShallow } from 'zustand/react/shallow';

const { user, token } = useAuthStore(
useShallow((s) => ({ user: s.user, token: s.token })),
);
Otra opción, sin useShallow

Usa un selector por valor: const user = useAuthStore((s) => s.user); y const token = useAuthStore((s) => s.token);. Es igual de eficiente y no necesita importar nada. Reserva useShallow para cuando quieras agrupar muchos valores.

Estado derivado​

Cuando un valor se calcula a partir del estado (cuántas notificaciones sin leer, total del carrito), no lo guardes: derívalo.

Derivar en el selector
// Bien: devuelve un número (primitivo), solo cambia si cambia la cuenta
const unread = useNotificationsStore((s) => s.notifications.filter((n) => !n.read).length);

// Cuidado: filter devuelve un arreglo NUEVO en cada ejecución del selector.
// Si necesitas la lista filtrada, selecciona el arreglo original y filtra en el componente.
const all = useNotificationsStore((s) => s.notifications);
const unreadItems = all.filter((n) => !n.read);

2. Acciones asíncronas​

En Redux, las peticiones requieren un thunk. En Zustand una acción puede ser, simplemente, una función async. Guarda en el store el estado de la petición (isLoading, error) para que la interfaz lo muestre:

src/features/auth/store/auth.store.ts (acción asíncrona)
import { create } from 'zustand';
import { authService } from '../services/auth.service';
import type { LoginCredentials } from '../types';

type AuthState = {
token: string | null;
isLoading: boolean;
error: string | null;
login: (credentials: LoginCredentials) => Promise<boolean>;
logout: () => void;
};

export const useAuthStore = create<AuthState>()((set) => ({
token: null,
isLoading: false,
error: null,

login: async (credentials) => {
set({ isLoading: true, error: null });
try {
const token = await authService.login(credentials);
set({ token, isLoading: false });
return true;
} catch (err) {
set({
isLoading: false,
error: err instanceof Error ? err.message : 'No se pudo iniciar sesión',
});
return false;
}
},

logout: () => set({ token: null }),
}));

El componente solo llama la acción y reacciona al resultado:

Uso en LoginForm
const login = useAuthStore((s) => s.login);
const isLoading = useAuthStore((s) => s.isLoading);
const error = useAuthStore((s) => s.error);

const onSubmit = async (data: LoginCredentials) => {
const ok = await login(data);
if (ok) router.replace('/dashboard');
};
Mismo ciclo que un thunk

isLoading = true antes, token al terminar bien, error al fallar: es el ciclo pending, fulfilled y rejected de createAsyncThunk, sin la maquinaria extra. A cambio, tú eres responsable de escribirlo completo y de manera consistente en todos tus stores.


3. Middleware: capas alrededor del store​

Un middleware es una función que envuelve la creación del store y le agrega una capacidad, sin cambiar cómo escribes tus acciones. Se importan de zustand/middleware y se anidan alrededor de la función del store. Recorre qué hace cada capa y qué pasa en una llamada a set y al recargar la página:

Middleware de Zustand como capas alrededor del store
MiddlewarePara qué sirveImportación
devtoolsConecta el store con la extensión Redux DevTools: historial de cambios y viaje en el tiempozustand/middleware
persistGuarda el estado en localStorage (u otro almacenamiento) y lo restaura al recargarzustand/middleware
immerPermite escribir las acciones "mutando" el estado, mientras produce copias inmutableszustand/middleware/immer
subscribeWithSelectorPermite suscribirse fuera de React a un valor concreto, no a todo el storezustand/middleware

persist: que el estado sobreviva a recargar​

El caso de uso más común: no perder la sesión al refrescar el navegador.

persist con partialize
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
isLoading: false,
error: null,
login: async (credentials) => { /* ... */ return true; },
logout: () => set({ token: null }),
}),
{
name: 'auth-storage', // clave en localStorage
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ token: state.token }), // solo guarda lo necesario
version: 1, // para migrar si cambia la forma
},
),
);

Tres detalles que evitan errores comunes:

  • partialize: decide qué se guarda. No persistas isLoading ni error: al recargar, una pantalla con "cargando" eterno es un bug.
  • version: si cambias la forma del estado guardado, sube la versión y define migrate; así los usuarios con datos viejos no rompen la app.
  • Las funciones no se guardan: JSON.stringify las descarta, y al restaurar el estado las acciones vuelven a tomarse del código.
Persistencia y renderizado en el servidor

localStorage no existe en el servidor. Si el store restaura datos al crearse, el navegador dibuja algo distinto al HTML que envió el servidor y React avisa con un error de hidratación (hydration mismatch). La solución es retrasar la restauración hasta que el componente se monte:

persist sin desfase de hidratación
persist(creator, {
name: 'auth-storage',
skipHydration: true, // no restaurar al crear el store
partialize: (s) => ({ token: s.token }),
});

// En un Client Component alto del árbol (por ejemplo, el layout):
useEffect(() => {
useAuthStore.persist.rehydrate();
}, []);

Lo usaremos en el caso práctico para que el AuthGuard espere a conocer la sesión antes de decidir si expulsa al usuario.

devtools: ver el historial​

devtools con nombre de acción
import { devtools } from 'zustand/middleware';

export const useNotificationsStore = create<NotificationsState>()(
devtools(
(set) => ({
notifications: [],
addNotification: (title) =>
set(
(s) => ({ notifications: [{ id: Date.now(), title, read: false }, ...s.notifications] }),
undefined,
'notifications/add', // aparece como nombre de la acción en Redux DevTools
),
}),
{ name: 'NotificationsStore', enabled: process.env.NODE_ENV !== 'production' },
),
);

Instala la extensión Redux DevTools en tu navegador: verás cada set con su nombre, el estado antes y después, y podrás moverte por el historial igual que con Redux.

immer: escribir como si mutaras​

Primero se instala (npm install immer). Después, en lugar de armar copias a mano, escribes el cambio directamente y el middleware produce el estado inmutable:

immer en una acción
import { immer } from 'zustand/middleware/immer';

export const useNotificationsStore = create<NotificationsState>()(
immer((set) => ({
notifications: [],
addNotification: (title) =>
set((state) => {
state.notifications.unshift({ id: Date.now(), title, read: false });
}),
markRead: (id) =>
set((state) => {
const n = state.notifications.find((x) => x.id === id);
if (n) n.read = true;
}),
})),
);

Es el mismo Immer que usa Redux Toolkit por dentro. Vale la pena cuando el estado tiene objetos anidados; para estados planos, el set con objeto es más simple.

Combinar middleware​

Se anidan unos dentro de otros. El orden recomendado: devtools por fuera (para ver todo), luego persist, y immer lo más cerca de tu código.

Los tres juntos
export const useNotificationsStore = create<NotificationsState>()(
devtools(
persist(
immer((set) => ({ /* estado y acciones */ })),
{ name: 'notifications-storage' },
),
{ name: 'NotificationsStore' },
),
);

4. Dividir un store grande en slices​

Cuando un store acumula demasiadas responsabilidades, puedes partirlo en slices (porciones) y combinarlos. Cada slice es una función que describe una parte del estado:

Patrón de slices
import { create, type StateCreator } from 'zustand';

type SessionSlice = { token: string | null; logout: () => void };
type UiSlice = { sidebarOpen: boolean; toggleSidebar: () => void };
type AppState = SessionSlice & UiSlice;

const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> = (set) => ({
token: null,
logout: () => set({ token: null }),
});

const createUiSlice: StateCreator<AppState, [], [], UiSlice> = (set) => ({
sidebarOpen: true,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
});

export const useAppStore = create<AppState>()((...args) => ({
...createSessionSlice(...args),
...createUiSlice(...args),
}));
¿Slices o varios stores?

En el curso preferimos varios stores pequeños por feature (lección 3). Los slices son útiles cuando varias partes deben vivir en el mismo store (por ejemplo, para que una acción modifique dos porciones a la vez), o cuando entras a un proyecto que ya los usa.


5. Fuera de React: suscripciones y pruebas​

subscribeWithSelector permite reaccionar a un valor concreto desde cualquier archivo. Un caso típico: limpiar datos cuando se cierra la sesión.

Reaccionar al cierre de sesión
import { subscribeWithSelector } from 'zustand/middleware';

// auth.store.ts: envolver el creador con subscribeWithSelector
// create<AuthState>()(subscribeWithSelector((set) => ({ ... })))

useAuthStore.subscribe(
(state) => state.token,
(token) => {
if (token === null) {
useNotificationsStore.setState({ notifications: [] });
}
},
);

Para las pruebas, el estado de un store persiste entre una prueba y la siguiente porque vive a nivel de módulo. Resetéalo con el estado inicial que guarda el propio hook:

Reiniciar un store en una prueba
beforeEach(() => {
useNotificationsStore.setState(useNotificationsStore.getInitialState(), true);
});

El segundo argumento true indica reemplazar el estado completo en vez de fusionarlo.


6. Resumen de capacidades​

Necesito...Uso
Que un componente solo se renderice cuando cambia un datoSelector por valor
Leer varios valores con un solo hookuseShallow
Hacer una petición y mostrar cargando o errorAcción async con isLoading y error
Que la sesión sobreviva a recargarpersist con partialize (y skipHydration en Next.js)
Ver y rebobinar los cambiosdevtools + Redux DevTools
Escribir acciones sobre estado anidado sin copias manualesimmer
Repartir un store grandeSlices o varios stores
Usar el store en un cliente HTTP o utilidadgetState() / setState() / subscribe()
Reiniciar el store en una pruebasetState(getInitialState(), true)

Cuestionario de Autoevaluación​

Cargando cuestionario...