wandres.dev
EL POST-REDUX · Zustand, Jotai, Valtio

Zustand: el store que es un hook

Zustand comprime todo lo que Redux repartía en cinco piezas y tres archivos en una sola idea: un store que es, literalmente, un hook. Sin Provider, sin acciones nombradas, sin reductores, sin inmutabilidad a mano y en unos 3KB. El más adoptado de la ola ligera en 2026, no por moda sino porque su modelo mental cabe en una frase y su coste cabe en el presupuesto de cualquier bundle.

⏱ 17 min

Si el nivel anterior diagnosticó el desajuste, Zustand es la respuesta que más limpio lo resuelve, y por eso es la más adoptada de la ola ligera en 2026. Su tesis se enuncia en una frase: un store es un hook. No hay Provider que envuelva la app, ni acciones que nombrar, ni reductores que escribir, ni copias inmutables que teclear a mano. Del colectivo Poimandres —los mismos de React Three Fiber—, Zustand toma el modelo de cuatro conceptos de Redux y lo colapsa en uno solo, todo por unos 3KB comprimidos. Este capítulo desmonta cómo lo consigue y dónde están sus filos.

🎯 Al terminar esta lección sabrás
  • Crear un store con create y entender por qué el resultado es un hook usable directamente.
  • Dominar los selectores como mecanismo de suscripción fina y control del re-render.
  • Colocar las acciones dentro del store con set y get, sin capa de reductores.
  • Componer middleware —persist, devtools, immer— y organizar el store a escala con slices.

Un store es un hook

La llamada create recibe una función que describe el estado inicial y las acciones, y devuelve un hook. Ese hook es a la vez la puerta de lectura y de escritura, y vive en el ámbito del módulo, no en un árbol de contexto:

import { create } from 'zustand'

type OsoState = {
  osos: number
  aumentar: () => void
  reiniciar: () => void
}

export const useOsoStore = create<OsoState>((set) => ({
  osos: 0,
  aumentar: () => set((state) => ({ osos: state.osos + 1 })),
  reiniciar: () => set({ osos: 0 }),
}))

No hay acciones nombradas ni tipos: las acciones son funciones normales que llaman a set, y set fusiona superficialmente el objeto que devuelves con el estado actual, así que no reescribes las claves que no tocas. La ausencia de Provider no es un truco de comodidad: como el store vive en el módulo, cualquier componente que importe el hook accede al mismo estado sin necesidad de que un ancestro lo provea. La contrapartida es que ese store es un singleton de módulo, lo cual está perfecto en el cliente pero exige cuidado en renderizado de servidor, donde varias peticiones comparten proceso; para ese caso Zustand ofrece crear el store por petición y proveerlo con contexto, pero es la excepción, no la norma.

El selector: suscripción quirúrgica

Usar el hook sin argumentos devolvería el estado entero y re-renderizaría el componente ante cualquier cambio. La clave del rendimiento en Zustand es el selector: una función que extrae solo la porción que te importa, de modo que el componente se re-renderiza únicamente cuando esa porción cambia.

function ContadorOsos() {
  const osos = useOsoStore((state) => state.osos)   // solo re-render si osos cambia
  return <h1>{osos} osos</h1>
}

function Boton() {
  const aumentar = useOsoStore((state) => state.aumentar) // la accion es estable
  return <button onClick={aumentar}>uno mas</button>
}

El detalle fino: el componente que solo selecciona la acción aumentar no se re-renderiza jamás cuando cambia osos, porque la referencia de la función es estable. Esta granularidad —suscribirse a exactamente lo que consumes— es lo que Redux lograba con useSelector pero sin el andamiaje que lo rodeaba. Y a diferencia de la Context API de React, donde cualquier cambio del valor del contexto re-renderiza a todos sus consumidores, aquí la suscripción es por selector, así que un store grande no penaliza a los componentes que solo miran una esquina de él.

💡
get para leer dentro de una accion

set recibe opcionalmente el estado previo como argumento, pero cuando una acción necesita leer varias porciones del estado para decidir, el segundo parámetro de create es get. Con create((set, get) => ...) puedes escribir acciones que consultan el estado completo actual —por ejemplo, validar antes de mutar, o calcular a partir de otros campos— sin suscribir ningún componente. Es la vía idiomática para lógica que lee y escribe a la vez, y el equivalente natural de los thunks de Redux sin su ceremonia.

Middleware: persistencia, devtools e Immer

Zustand mantiene el núcleo minúsculo y deja las capacidades avanzadas como middleware opcional que envuelve la función del store. El más usado es persist, que sincroniza el estado con localStorage de forma transparente:

import { persist } from 'zustand/middleware'

export const useAjustes = create(
  persist(
    (set) => ({
      tema: 'oscuro',
      alternar: () => set((s) => ({ tema: s.tema === 'oscuro' ? 'claro' : 'oscuro' })),
    }),
    { name: 'ajustes' },   // clave en localStorage
  ),
)

Se componen anidándolos, y esa composición es la razón de que el núcleo quepa en 3KB: pagas peso solo por lo que activas.

💾

persist

Serializa el estado a localStorage u otro almacén y lo rehidrata al arrancar. Cuida la hidratación en SSR para evitar desajustes de render.

🔭

devtools

Engancha las devtools de Redux y recupera el time-travel para quien lo eche de menos, sin adoptar el modelo de Redux.

✍️

immer

Reintroduce la mutación aparente al estilo RTK para estados profundamente anidados, evitando la propagación a mano.

🎯

subscribeWithSelector

Permite suscribirse fuera de React a una porción concreta del estado, base de las actualizaciones transitorias.

Organizar a escala: slices y estado transitorio

El miedo razonable ante tanta libertad es que un store crezca hasta volverse un cajón desastre global. El patrón idiomático para evitarlo son los slices: fragmentos de estado y acciones definidos en funciones separadas que reciben set y get, y que se combinan en un único create. Modularizas sin fragmentar el store ni perder la suscripción por selector.

const crearPeces = (set) => ({
  peces: 0,
  pescar: () => set((s) => ({ peces: s.peces + 1 })),
})
const crearOsos = (set) => ({
  osos: 0,
  cazar: () => set((s) => ({ osos: s.osos + 1 })),
})

export const useStore = create((...a) => ({
  ...crearPeces(...a),
  ...crearOsos(...a),
}))

Para el otro extremo del espectro —valores que cambian muchas veces por segundo, como la posición del ratón o un frame de animación— re-renderizar React en cada cambio sería suicida. Ahí entran las actualizaciones transitorias: te suscribes al store fuera del ciclo de render y aplicas el cambio a mano, sin provocar re-render.

// Reaccionar sin re-render: ideal para canvas, animaciones o valores de alta frecuencia.
const unsub = useStore.subscribe((state) => {
  pintarEnCanvas(state.osos)   // corre en cada cambio, sin re-renderizar React
})
⚠️
El filo del selector que devuelve un objeto

El error más común en Zustand v5: un selector que construye un objeto nuevo en cada llamada, como (s) => ({ a: s.a, b: s.b }). Como la referencia del objeto cambia siempre, la comparación por identidad falla y el componente se re-renderiza en cada actualización del store, aunque a y b no cambien. La solución idiomática es envolver el selector en useShallow, que compara superficialmente clave a clave. Selecciona valores primitivos por separado, o usa useShallow cuando de verdad necesites varios a la vez.

import { useShallow } from 'zustand/shallow'

// Correcto: comparacion superficial, re-render solo si a o b cambian.
const { a, b } = useAjustes(useShallow((s) => ({ a: s.a, b: s.b })))
Zustand no eliminó a Redux: eliminó la distancia entre pensar y escribir

La grandeza de Zustand no es que sea pequeño, aunque lo sea, ni que no tenga Provider, aunque no lo tenga. Es que colapsa la distancia entre el modelo mental y el código. En Redux pensabas quiero sumar uno y luego traducías ese pensamiento a un tipo de acción, un creador, un caso en un reducer y un selector, cuatro artefactos que existían para servir a garantías —serializabilidad, pureza, time-travel— que la app mediana no usaba. En Zustand piensas quiero sumar uno y escribes una función que llama a set; no hay traducción, no hay artefactos intermedios que existan solo para satisfacer al framework. Esto tiene una consecuencia que va más allá de la comodidad: cuando la herramienta no cobra impuesto conceptual, el estado se modela por lo que el dominio necesita y no por lo que la librería exige, y el código resultante se lee como el problema en vez de como la solución. El coste, y hay que decirlo, es que Zustand te devuelve la responsabilidad que Redux imponía: nadie te obliga a estructurar, así que un store mal pensado se convierte en un cajón desastre global tan rápido como uno bien pensado se mantiene limpio. La disciplina que Redux te imponía por diseño, Zustand te la deja como decisión, y por eso los slices no son un adorno sino la forma de reponer voluntariamente la estructura que ganaste el derecho a omitir. Es el más adoptado de 2026 no porque piense por ti, sino porque se aparta de tu camino, y para la mayoría de las apps eso es exactamente lo que el estado global simple pedía. La libertad, aquí como en todo, es también una carga: te deja hacer lo correcto sin estorbar y lo incorrecto sin avisar.

⚔️ Migra un slice de RTK a Zustand
  1. Toma el slice de estado de cliente más simple de una app con RTK —tema, sesión, un modal— y reescríbelo como un store de Zustand con create. Cuenta las líneas y los archivos de antes y de después.
  2. Sustituye cada useSelector por una llamada al hook con un selector, y cada useDispatch más acción por una llamada directa a la acción del store. Elimina el Provider si ese slice era el único que lo justificaba.
  3. Provoca el filo a propósito: crea un selector que devuelva un objeto literal y observa en las devtools de React que el componente se re-renderiza de más. Arréglalo con useShallow y confirma que los re-render desaparecen.
  4. Divide un store que haya crecido en dos slices con sus funciones creadoras y combínalos en un solo create. Verifica que la suscripción por selector sigue siendo quirúrgica pese a la modularización.
  5. Añade persist para que el estado sobreviva a recargar, luego devtools para inspeccionarlo, y mide el peso con y sin cada middleware para comprobar que solo pagas por lo que activas.