wandres.dev
CUÁNDO REDUX · y las alternativas

Zustand: el estado global de cliente que basta

Para la inmensa mayoría de aplicaciones, el estado global de cliente no necesita la disciplina de Redux: necesita un store diminuto sin ceremonia. Esta lección construye ese store con Zustand —un hook creado con create, sin provider— y explica por qué el selector es la unidad de rendimiento y cuándo hace falta useShallow. Sitúa los middleware persist, immer, devtools y subscribeWithSelector como extras opcionales, y fija la regla de oro que evita repetir el pecado histórico de Redux: Zustand no es una cache, así que el estado del servidor jamás debe entrar en él. Simple, pequeño y suficiente resulta ser, en 2026, la respuesta correcta con más frecuencia de la que el instinto de sobreingeniería admite.

⏱ 17 min

Si el nivel anterior delimitó la franja estrecha donde Redux sigue ganando, esta lección ocupa el enorme territorio que queda fuera. Para la mayoría de las aplicaciones, el estado global de cliente —el tema, la sesión, si la barra lateral está abierta, qué elementos hay seleccionados— no pide flujo unidireccional, ni acciones serializables, ni viaje en el tiempo. Pide un lugar compartido donde guardar unos cuantos valores y leerlos desde cualquier componente sin pasar props por diez niveles. Zustand es exactamente eso y nada más: un store diminuto, sin provider, expuesto como un hook. Su tesis, incómoda para quien viene de arquitecturas pesadas, es que simple, pequeño y suficiente no es una concesión, sino la respuesta correcta la mayoría de las veces.

🎯 Al terminar esta lección sabrás
  • Crear un store con create y consumirlo con selectores, sin ningún provider.
  • Entender por qué el selector es la unidad de rendimiento y cuándo usar useShallow.
  • Situar los middleware persist, immer, devtools y subscribeWithSelector como extras opcionales.
  • Delimitar la regla de oro: qué no debe entrar jamás en Zustand, empezando por el estado del servidor.

Un store es un hook, y eso lo cambia todo

La idea central de Zustand cabe en una frase: un store es una función que devuelve un hook. No hay que envolver el árbol en un provider, no hay contexto que propagar, no hay reducer que registrar. El store vive en el módulo —en un closure— y cualquier componente que importe el hook queda suscrito. Las acciones no son objetos que se despachan a otro sitio: son funciones que viven dentro del propio store, junto a los datos que modifican.

import { create } from 'zustand'

type Sesion = {
  usuario: string | null
  tema: 'claro' | 'oscuro'
  entrar: (nombre: string) => void
  alternarTema: () => void
}

export const useSesion = create<Sesion>((set) => ({
  usuario: null,
  tema: 'oscuro',
  entrar: (nombre) => set({ usuario: nombre }),          // set fusiona superficialmente
  alternarTema: () => set((s) => ({ tema: s.tema === 'oscuro' ? 'claro' : 'oscuro' })),
}))

Fíjate en lo que no hay: ni <Provider> envolviendo la app, ni tipos de acción, ni reducer separado. set fusiona superficialmente el objeto que le pasas sobre el estado actual, y cuando necesitas el valor previo le pasas una función (s) => .... Los datos y las funciones que los cambian viven juntos, colocados, y eso hace que leer un store de Zustand sea entender el estado y sus transiciones de un solo vistazo, sin saltar entre archivos. Consumirlo desde un componente es igual de directo:

function BotonTema() {
  const alternar = useSesion((s) => s.alternarTema)  // selector: solo esta funcion
  return <button onClick={alternar}>cambiar tema</button>
}

El selector es la unidad de rendimiento

Ese argumento que pasas al hook —(s) => s.alternarTema— no es decorativo: es un selector, y es la pieza más importante de Zustand para el rendimiento. Cuando seleccionas una porción concreta del store, el componente solo se vuelve a renderizar si esa porción cambia, comparada con Object.is. Si en cambio suscribes el store entero, cualquier cambio en cualquier campo re-renderiza el componente, aunque no uses el campo que cambió. El selector es, por tanto, tu suscripción de grano fino: cuanto más preciso, menos renders.

Hay un matiz que causa bugs sutiles. Si un selector devuelve un objeto o array nuevo en cada llamada —por ejemplo, para leer dos campos a la vez— la comparación por identidad falla siempre y el componente re-renderiza en cada cambio del store, porque el objeto nuevo nunca es igual al anterior. La solución es useShallow, que compara superficialmente las propiedades en lugar de la referencia:

import { useShallow } from 'zustand/react/shallow'

// sin useShallow, este objeto nuevo en cada render dispararia renders de mas
const { usuario, tema } = useSesion(
  useShallow((s) => ({ usuario: s.usuario, tema: s.tema })),
)
💡
Selecciona funciones aparte de los datos

Un patrón que ahorra renders sin esfuerzo: selecciona las acciones por separado de los datos. Las funciones del store son estables —no cambian entre renders— así que un componente que solo despacha, como un botón, puede seleccionar únicamente su acción y no se re-renderizará nunca por cambios de datos. Mezclar datos y acciones en el mismo selector ata la estabilidad de la función a la volatilidad del dato. Separarlos es gratis y elimina toda una categoría de renders innecesarios.

Middleware: lo justo, cuando lo pides

Zustand es minimalista por defecto, pero no pobre: su potencia opcional vive en los middleware, que se componen envolviendo la función del store. No pagas por ninguno hasta que lo pides, y esa es justamente la filosofía.

💾

persist

Guarda el store en localStorage o donde indiques y lo rehidrata al arrancar. Ideal para el tema o la sesión, que deben sobrevivir a un recargado de página.

✏️

immer

Permite escribir mutaciones aparentes sobre estado anidado, igual que createSlice, produciendo por debajo copias inmutables. Útil cuando el estado tiene profundidad.

🔍

devtools

Conecta el store a las Redux DevTools para inspeccionar cambios. Se asoma al time-travel, aunque sin la garantía que da la acción serializable obligatoria de Redux.

📡

subscribeWithSelector

Permite suscribirse a cambios fuera de React —para efectos, sincronización o analítica— sin renderizar nada. El store como fuente de eventos, no solo como origen de renders.

Aplicarlos es envolver la definición del store. El más usado, persist, apenas añade ruido y resuelve un problema real —que el tema elegido no se pierda al recargar—:

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

export const useSesion = create<Sesion>()(
  persist(
    (set) => ({
      usuario: null,
      tema: 'oscuro',
      entrar: (nombre) => set({ usuario: nombre }),
      alternarTema: () => set((s) => ({ tema: s.tema === 'oscuro' ? 'claro' : 'oscuro' })),
    }),
    { name: 'sesion' },  // clave bajo la que se guarda en localStorage
  ),
)

La regla de oro: Zustand no es una cache

Aquí está el error que amenaza con repetir en Zustand el pecado que hundió a Redux. Es tentador, cuando ya tienes un store global cómodo, meter en él todo: el tema, la sesión y también la lista de productos que acabas de traer del servidor. No lo hagas. El estado del servidor es una cache de una verdad remota, con sus problemas de obsolescencia, revalidación y deduplicación, y Zustand no sabe nada de eso: te obligaría a reimplementar a mano toda la maquinaria que TanStack Query te da hecha. Zustand es para la verdad que posee el cliente —la que nadie puede cambiar a tus espaldas— no para el reflejo de una verdad ajena.

flowchart TD
Q[que clase de estado es?] -->|lo posee el cliente| Z[Zustand tema sesion UI global]
Q -->|copia de una verdad remota| T[TanStack Query no al store]
Q -->|solo un componente lo usa| L[useState local]
Q -->|derivado o interdependiente| J[Jotai]
style Z fill:#a6e3a1,color:#11111b
style T fill:#f38ba8,color:#11111b
style L fill:#89dceb,color:#11111b
style J fill:#89b4fa,color:#11111b

Interiorizar ese diagrama es la mitad del criterio de este nivel. Zustand brilla exactamente en la casilla verde: estado compartido, propiedad del cliente, plano —sin un grafo denso de derivaciones—. El tema, si el usuario está autenticado, qué pestaña está activa, si un modal global está abierto, qué filas seleccionó en una tabla. Todo eso es tuyo, cambia por acciones del usuario y necesita leerse desde muchos sitios. En cambio, en cuanto un dato viene de una petición HTTP, sale de Zustand y va a la cache; en cuanto solo lo usa un componente, se queda en useState; en cuanto forma un grafo de valores computados que dependen unos de otros, la lección siguiente te dará una herramienta mejor.

⚠️
No copies el servidor dentro del store

El anti-patrón concreto: llamas a la API, recibes una lista y la guardas con set. Ahora esa lista vive en dos sitios —la verdad remota y tu copia en el store— y tú eres responsable de mantenerlas sincronizadas: invalidar cuando muta, revalidar al reenfocar, deduplicar peticiones. Nada de eso es trabajo de Zustand. En el momento en que te descubras escribiendo un set con datos recién traídos de la red, detente: ese dato pertenece a TanStack Query, y forzarlo dentro del store de cliente es reconstruir, peor y a mano, la cache que ya existe.

Zustand no te da menos: te quita la ceremonia que nunca necesitabas

La forma equivocada de ver Zustand es como un Redux capado, una versión con menos features para quien no se toma en serio la arquitectura. Es al revés. Zustand no elimina capacidades que necesitabas; elimina ceremonia que casi nunca necesitaste, y al hacerlo revela una verdad incómoda sobre la década anterior: la inmensa mayoría de las aplicaciones que adoptaron Redux jamás usaron ninguno de sus cuatro beneficios reales. No tenían equipos de cuarenta personas, ni dominios auditables, ni bugs que exigieran viaje en el tiempo; tenían un tema, una sesión y media docena de banderas de UI, y pagaron por todo eso el precio completo de una disciplina pensada para problemas que no tenían. Zustand hace visible ese desajuste al ofrecer lo único que de verdad hacía falta —un store global con suscripción de grano fino vía selectores— y quitar lo demás. Su minimalismo transfiere una responsabilidad: la disciplina que Redux imponía por diseño, en Zustand la pones tú. Nada te impide mutar el store desde cualquier sitio, meter en él lo que no debe, o dejar que crezca sin orden. Para un equipo pequeño y competente ese es un intercambio excelente, porque la libertad cuesta poco cuando pocas manos tocan el código y todas se conocen. Para un equipo grande y disperso, esa misma libertad se convierte en el riesgo de divergencia que Redux existía para conjurar, y por eso la elección entre ambos no es de gusto sino de escala. Zustand es la prueba viviente de que la pregunta correcta nunca fue qué gestor de estado es mejor, sino cuánta disciplina impuesta compra tu contexto y cuánta se convierte en pura ceremonia; responderla con honestidad es, casi siempre, elegir la herramienta más pequeña que resuelve el problema real.

⚔️ Adelgaza tu estado global a lo que Zustand basta
  1. Lista todo lo que hoy vive en tu store global. Marca cada pieza como propiedad del cliente o copia de una verdad remota.
  2. Todo lo marcado como remoto es candidato a salir hacia TanStack Query. Mide cuánto queda: casi siempre menos de lo que esperabas.
  3. Reescribe lo que quede con un store de Zustand: datos y acciones colocados, sin provider. Comprueba cuántas líneas de reducers y acciones desaparecen.
  4. Convierte un selector que devuelve un objeto en uno con useShallow y observa con las devtools cómo caen los renders innecesarios.
  5. Separa la selección de acciones de la de datos en un componente que solo despacha, y verifica que deja de re-renderizar por cambios de datos.
  6. Añade persist al tema o la sesión y comprueba que sobreviven a un recargado. Resiste la tentación de persistir datos que en realidad son del servidor.