Módulo 20: Estado global: Zustand y Redux Toolkit

Objetivos del módulo

  • Clasificar el estado de una aplicación y decidir dónde debe vivir cada dato
  • Entender por qué Context no sirve como almacén de estado que cambia a menudo, y medirlo
  • Crear stores con Zustand, con acciones síncronas y asíncronas
  • Usar selectores bien: useShallow, valores derivados y el error del bucle infinito
  • Guardar estado en el navegador con persist: partialize, versiones y migraciones
  • Leer y cambiar un store desde fuera de React con getState, setState y subscribe
  • Componer los middlewares immer, devtools, persist y subscribeWithSelector, y dividir un store en trozos
  • Crear un store por rama del árbol con createStore y Context
  • Comprender el flujo de Redux: acción, middleware, reductor, store y selector
  • Escribir Redux moderno con configureStore, createSlice y hooks tipados
  • Memorizar selectores con createSelector y normalizar datos con createEntityAdapter
  • Hacer peticiones con createAsyncThunk y con RTK Query: caché, etiquetas y actualización optimista
  • Comparar Zustand, Redux Toolkit, Context, TanStack Query y RTK Query, y elegir con criterio

💡 Consejo: Zustand 5 y Redux Toolkit 2 no dependen de ninguna novedad de React 19. Sus peerDependencies piden react >= 18 en Zustand y ^18 || ^19 en react-redux 9, así que este módulo vale también para React 18.3. La única diferencia está en los dos apartados marcados, que usan <Contexto value>.


1. Cuándo hace falta estado global

Muchos alumnos llegan a este módulo con una idea: “una aplicación seria necesita Redux”. No es verdad. La mayor parte de lo que antes se metía en un store global hoy tiene un sitio mejor, y lo que queda suele ser poco. Por eso este módulo empieza por la pregunta que más dinero ahorra: ¿de verdad lo necesito?

1.1. Cinco tipos de estado

No todo el estado es igual. Cada tipo tiene un dueño natural y una herramienta adecuada:

Tipo Ejemplos en EMPRESA Dónde vive Módulo
Local Pestaña activa, modal abierto useState, useReducer 04, 11
De formulario Valores y errores mientras se edita useState, React Hook Form 08, 21
De URL Página, filtro, empleado seleccionado Ruta y useSearchParams 18
De servidor Empleados, pedidos, departamentos TanStack Query, RTK Query 19
Global de cliente Carrito, sesión, tema, preferencias, avisos Context o un store Este

La confusión más cara es tratar el estado de servidor como si fuera de cliente. Los empleados no son tuyos: son una copia de lo que hay en el servidor, y esa copia envejece. Si los guardas en un store a mano, tendrás que escribir tú la carga, los errores, la caché, la invalidación y los reintentos, justo lo que TanStack Query ya hace. En aplicaciones antiguas de Redux, la mayor parte del store eran copias de datos de servidor. Al sacarlas, muchas veces no queda casi nada.

Secuencia de cinco preguntas que llevan a estado de servidor, URL, formulario, estado local o Context; lo que no encaja va a un store global

Figura 1. Las preguntas que hay que hacerse antes de meter un dato en un store.

Una señal clara de que sí necesitas un store: varios componentes alejados en el árbol leen y escriben el mismo dato, ese dato no viene del servidor, y cambia a menudo. El carrito es el ejemplo clásico: la cabecera muestra las unidades, el catálogo añade, el panel lateral edita.

⚠️ Importante: Un store es una variable global con buenos modales. Cualquier componente puede cambiarla, sobrevive al desmontaje de quien la usa y hay que limpiarla entre pruebas. No es gratis: úsalo cuando el dato sea global de verdad.

1.2. Prepara el proyecto

Crea un proyecto como en el módulo 01, donde también se explica qué cambia en PowerShell, e instala las librerías de este módulo:

npm create vite@latest modulo-20 -- --template react-ts
cd modulo-20
npm install
npm install zustand immer @reduxjs/toolkit react-redux
npm run dev

Descarga la API de ejemplo y arráncala en otra terminal. Queda en http://localhost:3000/api:

node server.mjs

Cada ejemplo sustituye a src/App.tsx. Si tiene varios ficheros, ponlos todos en src/, junto a App.tsx. Borra src/App.css y sustituye src/index.css por estas reglas, que usan todos los ejemplos:

:root {
  font-family: 'Segoe UI', system-ui, sans-serif;
  color: #263238;
  background: #f5f6fa;
}

body {
  margin: 0;
  padding: 1.25rem;
}

.tarjeta {
  background: #fff;
  border: 1px solid #c5cae9;
  border-radius: 10px;
  padding: 1rem 1.25rem;
  max-width: 680px;
  margin: 0 0 1rem;
}

.tarjeta h2 {
  margin: 0 0 0.5rem;
  font-size: 1.15rem;
  color: #1a237e;
}

.columnas {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: 1rem;
  align-items: start;
}

.columnas .tarjeta {
  margin: 0;
}

.fila {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  flex-wrap: wrap;
  margin-bottom: 0.75rem;
}

.separada {
  justify-content: space-between;
}

button,
input,
select {
  font: inherit;
  padding: 0.35rem 0.6rem;
  border: 1px solid #9fa8da;
  border-radius: 6px;
}

button {
  background: #fff;
  color: #283593;
  border-color: #3f51b5;
  cursor: pointer;
}

button:disabled {
  opacity: 0.5;
  cursor: default;
}

button[aria-pressed='true'] {
  background: #3f51b5;
  color: #fff;
}

table {
  width: 100%;
  border-collapse: collapse;
}

th,
td {
  text-align: left;
  padding: 0.3rem 0.4rem;
  border-bottom: 1px solid #eceff1;
}

.num {
  text-align: right;
}

.apagado {
  color: #607d8b;
  font-size: 0.9rem;
}

.error {
  color: #c62828;
}

.renders {
  font-size: 0.85rem;
  color: #bf360c;
  font-weight: 600;
}

1.3. Por qué Context no basta 🆕 19.0

🆕 Desde React 19.0. <Contexto value> no existe en React 18.3. Si usas una versión anterior: escribe <Contexto.Provider value>. Todo lo que se explica aquí sobre los renders funciona igual.

En el módulo 11 viste que Context evita pasar props por muchos niveles. Pero Context transporta valores, no gestiona suscripciones: cuando cambia el valor del proveedor, se renderizan todos sus consumidores, lean o no el campo que ha cambiado. No hay forma de decirle “avísame solo si cambia unidades”.

Para verlo con números, este ejemplo monta los mismos tres componentes dos veces, con Context y con Zustand. El hook useRenders cuenta las veces que se confirma cada componente:

import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
import { create } from 'zustand'

// Cuenta los commits de un componente y escribe el número sin provocar otro render
function useRenders() {
  const veces = useRef(0)
  const nodo = useRef<HTMLElement>(null)
  useEffect(() => {
    veces.current += 1
    if (nodo.current) nodo.current.textContent = `${veces.current} renders`
  })
  return nodo
}

// ---------- Versión con Context ----------
interface Sesion {
  usuario: string
  unidades: number
  sumar: () => void
}

const ContextoSesion = createContext<Sesion | null>(null)

function useContextoSesion() {
  const valor = useContext(ContextoSesion)
  if (!valor) throw new Error('Falta ProveedorSesion')
  return valor
}

function ProveedorSesion({ children }: { children: ReactNode }) {
  const [unidades, setUnidades] = useState(0)
  const valor = { usuario: 'Ana García', unidades, sumar: () => setUnidades((n) => n + 1) }
  return <ContextoSesion value={valor}>{children}</ContextoSesion>
}

function UsuarioContexto() {
  const { usuario } = useContextoSesion()
  const nodo = useRenders()
  return (
    <p className="fila separada">
      <span>Usuario: {usuario}</span> <small ref={nodo} className="renders" />
    </p>
  )
}

function CarritoContexto() {
  const { unidades } = useContextoSesion()
  const nodo = useRenders()
  return (
    <p className="fila separada">
      <span>Unidades: {unidades}</span> <small ref={nodo} className="renders" />
    </p>
  )
}

function BotonContexto() {
  const { sumar } = useContextoSesion()
  const nodo = useRenders()
  return (
    <p className="fila separada">
      <button onClick={sumar}>Añadir (Context)</button> <small ref={nodo} className="renders" />
    </p>
  )
}

// ---------- Versión con Zustand ----------
const useSesion = create<Sesion>()((set) => ({
  usuario: 'Ana García',
  unidades: 0,
  sumar: () => set((estado) => ({ unidades: estado.unidades + 1 })),
}))

function UsuarioZustand() {
  const usuario = useSesion((estado) => estado.usuario)
  const nodo = useRenders()
  return (
    <p className="fila separada">
      <span>Usuario: {usuario}</span> <small ref={nodo} className="renders" />
    </p>
  )
}

function CarritoZustand() {
  const unidades = useSesion((estado) => estado.unidades)
  const nodo = useRenders()
  return (
    <p className="fila separada">
      <span>Unidades: {unidades}</span> <small ref={nodo} className="renders" />
    </p>
  )
}

function BotonZustand() {
  const sumar = useSesion((estado) => estado.sumar)
  const nodo = useRenders()
  return (
    <p className="fila separada">
      <button onClick={sumar}>Añadir (Zustand)</button> <small ref={nodo} className="renders" />
    </p>
  )
}

export default function App() {
  return (
    <div className="columnas">
      <section className="tarjeta">
        <h2>Context</h2>
        <ProveedorSesion>
          <UsuarioContexto />
          <CarritoContexto />
          <BotonContexto />
        </ProveedorSesion>
      </section>
      <section className="tarjeta">
        <h2>Zustand con selectores</h2>
        <UsuarioZustand />
        <CarritoZustand />
        <BotonZustand />
      </section>
    </div>
  )
}

Tras pulsar cinco veces cada botón, este es el resultado real:

Dos tarjetas: con Context los tres componentes marcan 7 renders; con Zustand, solo Unidades marca 7 y los otros dos marcan 2

Figura 2. Mismos componentes y mismas cinco pulsaciones. Los contadores empiezan en 2 porque Strict Mode ejecuta dos veces los efectos al montar.

Con Context, Usuario y el botón se renderizan cinco veces sin necesidad. Con Zustand, solo Unidades. La diferencia está en quién decide:

Con Context, el proveedor provoca el render de los tres consumidores. Con un store, cada componente compara su selector y solo repite el que ha cambiado

Figura 3. Con Context decide el proveedor; con un store decide cada suscriptor.

Partir el contexto en varios, memorizar el valor con useMemo y envolver en memo reduce el problema, como viste en el módulo 11, pero el coste crece con cada dato nuevo. Por eso la regla práctica es esta: Context para valores que cambian poco (tema, idioma, usuario conectado) y un store para los que cambian a menudo.


2. Zustand: el store más pequeño posible

Zustand es una librería mínima: una función, create, que recibe el estado inicial y las acciones, y devuelve un hook. No hay proveedor, ni acciones como objetos, ni reductores.

import { create } from 'zustand'

interface Contador {
  valor: number
  sumar: () => void
  reiniciar: () => void
}

const useContador = create<Contador>()((set, get) => ({
  valor: 0,
  sumar: () => set((estado) => ({ valor: estado.valor + 1 })),
  reiniciar: () => {
    console.log(`Se pierde el valor ${get().valor}`)
    set({ valor: 0 })
  },
}))

Y se usa así, en cualquier componente, sin envolver nada:

function Marcador() {
  const valor = useContador((estado) => estado.valor)
  const sumar = useContador((estado) => estado.sumar)
  return <button onClick={sumar}>{valor}</button>
}

Lo importante de ese código:

  • create<Contador>()(...) lleva dos paréntesis. No es una errata. TypeScript no permite indicar un genérico y deducir otro a la vez. El primer paréntesis fija tu tipo y el segundo deja que se deduzca lo que añaden los middlewares. Sin middlewares funciona con uno, pero acostúmbrate a los dos.
  • set fusiona el objeto que le pasas con el estado actual, igual que un { ...estado, ...cambios }. Por eso set({ valor: 0 }) no borra sumar. La fusión es de un solo nivel: los objetos anidados los tienes que copiar tú.
  • set admite una función que recibe el estado actual. Úsala cuando el valor nuevo dependa del anterior, como la función actualizadora del módulo 04.
  • get() lee el estado actual dentro de una acción, sin suscribirse a nada.
  • Las acciones viven en el store junto a los datos. Su referencia no cambia nunca, así que seleccionarlas no provoca renders.

📘 Concepto: Por dentro, un store de Zustand es un objeto JavaScript normal, fuera de React, con tres cosas: el estado, setState y una lista de oyentes. El hook usa useSyncExternalStore, que ya viste en el módulo 12, para suscribir el componente. En cada cambio, React vuelve a ejecutar tu selector y compara el resultado con Object.is. Si es igual, no renderiza.


3. Selectores: la clave del rendimiento

El selector es la función que le pasas al hook. Define a qué te suscribes. Este ejemplo prueba las cuatro formas habituales. Es el mismo contador de renders, y cada pulsación sube el salario:

import { useEffect, useRef } from 'react'
import { create } from 'zustand'
import { useShallow } from 'zustand/react/shallow'

interface Ficha {
  nombre: string
  oficio: string
  salario: number
  subirSalario: () => void
}

const useFicha = create<Ficha>()((set) => ({
  nombre: 'Ana',
  oficio: 'DIRECTOR GENERAL',
  salario: 6500,
  subirSalario: () => set((f) => ({ salario: f.salario + 100 })),
}))

function useRenders() {
  const veces = useRef(0)
  const nodo = useRef<HTMLElement>(null)
  useEffect(() => {
    veces.current += 1
    if (nodo.current) nodo.current.textContent = `${veces.current} renders`
  })
  return nodo
}

// 1. Sin selector: se suscribe a TODO el estado
function SinSelector() {
  const { nombre } = useFicha()
  const nodo = useRenders()
  return <p>Sin selector: {nombre} <small ref={nodo} className="renders" /></p>
}

// 2. Un selector por dato: la opción más simple y la recomendada
function UnSelector() {
  const nombre = useFicha((f) => f.nombre)
  const oficio = useFicha((f) => f.oficio)
  const nodo = useRenders()
  return <p>Un selector por dato: {nombre}, {oficio} <small ref={nodo} className="renders" /></p>
}

// 3. Varios datos a la vez: useShallow compara el objeto campo a campo
function ConUseShallow() {
  const { nombre, oficio } = useFicha(
    useShallow((f) => ({ nombre: f.nombre, oficio: f.oficio })),
  )
  const nodo = useRenders()
  return <p>useShallow: {nombre}, {oficio} <small ref={nodo} className="renders" /></p>
}

// 4. Un valor calculado primitivo: solo cambia si cambia el resultado
function ValorDerivado() {
  const tramoAlto = useFicha((f) => f.salario >= 7000)
  const nodo = useRenders()
  return <p>Tramo alto: {tramoAlto ? 'sí' : 'no'} <small ref={nodo} className="renders" /></p>
}

// El salario se lee aquí abajo para que App no se suscriba y no arrastre a sus hijos
function Cabecera() {
  const salario = useFicha((f) => f.salario)
  const subirSalario = useFicha((f) => f.subirSalario)
  return (
    <>
      <h2>Salario: {salario} €</h2>
      <button onClick={subirSalario}>Subir 100 €</button>
    </>
  )
}

export default function App() {
  return (
    <section className="tarjeta">
      <Cabecera />
      <SinSelector />
      <UnSelector />
      <ConUseShallow />
      <ValorDerivado />
    </section>
  )
}

Tras seis pulsaciones:

Sin selector marca 8 renders, un selector por dato 2, useShallow 2 y Tramo alto 3

Figura 4. Seis subidas de salario. Sin selector se repite siempre; con selectores, solo cuando cambia lo que se lee.

Forma Renders tras 6 cambios Explicación
useFicha() 6 de más Sin selector devuelve todo el estado, que es un objeto nuevo en cada set
Un selector por dato 0 de más Compara dos cadenas que no cambian
useShallow 0 de más Compara el objeto campo a campo
Valor derivado 1 de más f.salario >= 7000 solo pasa de false a true una vez

El último caso merece atención: un selector puede calcular. Si devuelve un primitivo, el componente solo se renderiza cuando cambia el resultado, no cuando cambian los datos de entrada. Es la forma más barata de leer “¿hay algo en el carrito?”: useCarrito((e) => e.lineas.length > 0).

⚠️ Importante: Un selector no protege de un padre que se renderiza. En la primera versión de este ejemplo, App leía el salario, y al renderizarse arrastraba a todos sus hijos: las cuatro filas marcaban 8 y parecía que los selectores no servían. Por eso existe el componente Cabecera. Las reglas del módulo 14 siguen vigentes: baja la suscripción al componente que usa el dato.

El error del bucle infinito

Este es el error más típico de Zustand 5. Un selector que crea un objeto nuevo:

import { create } from 'zustand'

const useFicha = create(() => ({ nombre: 'Ana', oficio: 'DIRECTOR GENERAL' }))

export default function App() {
  // MAL: este selector devuelve un objeto nuevo cada vez que se ejecuta
  const { nombre, oficio } = useFicha((f) => ({ nombre: f.nombre, oficio: f.oficio }))
  return <p>{nombre}, {oficio}</p>
}

La consola muestra esto, y la aplicación se cae:

The result of getSnapshot should be cached to avoid an infinite loop
Maximum update depth exceeded. This can happen when a component repeatedly
calls setState inside componentWillUpdate or componentDidUpdate. React limits
the number of nested updates to prevent infinite loops.

La causa está en useSyncExternalStore: exige que dos lecturas seguidas sin cambios den el mismo valor. Aquí {...} crea un objeto distinto en cada lectura, Object.is dice que han cambiado, React renderiza otra vez, y vuelta a empezar. Lo mismo pasa con e.lineas.filter(...) o e.lineas.map(...), que crean un array nuevo, y con ?? [], que crea uno cada vez.

Tres soluciones, de mejor a peor según el caso:

// 1. Un selector por dato: lo más simple
const nombre = useFicha((f) => f.nombre)
const oficio = useFicha((f) => f.oficio)

// 2. useShallow, si necesitas varios a la vez o un array derivado
const { nombre, oficio } = useFicha(useShallow((f) => ({ nombre: f.nombre, oficio: f.oficio })))

// 3. Calcular fuera del selector, con useMemo si es caro
const lineas = useCarrito((e) => e.lineas)
const caras = useMemo(() => lineas.filter((l) => l.precio > 100), [lineas])

useShallow envuelve tu selector y recuerda el último resultado. Si el nuevo tiene los mismos campos con los mismos valores, devuelve el anterior, y la comparación con Object.is pasa.

💡 Consejo: En Zustand 5, el hook que devuelve create solo recibe el selector: no admite una función de igualdad como segundo argumento. Si encuentras código con useStore(selector, shallow), no te servirá. Quien necesite comparar a su manera tiene createWithEqualityFn en zustand/traditional, pero useShallow cubre casi todo.


4. Un carrito completo

Con lo anterior ya se puede escribir algo real. El store guarda las líneas, y los selectores calculan unidades y total. Fíjate en que unidades y total no se guardan: son estado derivado, como en el módulo 04. Guardarlos obligaría a mantenerlos sincronizados en cada acción.

src/store.ts:

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

export interface Linea {
  productoId: number
  nombre: string
  precio: number
  cantidad: number
}

interface EstadoCarrito {
  lineas: Linea[]
  abierto: boolean // estado de interfaz: no se guarda
  agregar: (producto: Omit<Linea, 'cantidad'>) => void
  cambiarCantidad: (productoId: number, cantidad: number) => void
  vaciar: () => void
  alternar: () => void
}

export const useCarrito = create<EstadoCarrito>()(
  persist(
    (set) => ({
      lineas: [],
      abierto: true,

      agregar: (producto) =>
        set((estado) => {
          const existe = estado.lineas.some((l) => l.productoId === producto.productoId)
          return {
            lineas: existe
              ? estado.lineas.map((l) =>
                  l.productoId === producto.productoId ? { ...l, cantidad: l.cantidad + 1 } : l,
                )
              : [...estado.lineas, { ...producto, cantidad: 1 }],
          }
        }),

      cambiarCantidad: (productoId, cantidad) =>
        set((estado) => ({
          lineas:
            cantidad <= 0
              ? estado.lineas.filter((l) => l.productoId !== productoId)
              : estado.lineas.map((l) => (l.productoId === productoId ? { ...l, cantidad } : l)),
        })),

      vaciar: () => set({ lineas: [] }),
      alternar: () => set((estado) => ({ abierto: !estado.abierto })),
    }),
    {
      name: 'empresa-carrito', // clave en localStorage
      storage: createJSONStorage(() => localStorage), // es el valor por defecto
      partialize: (estado) => ({ lineas: estado.lineas }), // solo guarda las líneas
    },
  ),
)

// Selectores: fuera del componente, reutilizables y fáciles de probar
export const selectUnidades = (e: EstadoCarrito) =>
  e.lineas.reduce((suma, l) => suma + l.cantidad, 0)

export const selectTotal = (e: EstadoCarrito) =>
  e.lineas.reduce((suma, l) => suma + l.cantidad * l.precio, 0)

src/App.tsx. Observa que hay tres componentes separados que no se pasan ni una prop:

import { useEffect, useState } from 'react'
import { selectTotal, selectUnidades, useCarrito } from './store'

const API = 'http://localhost:3000/api'
const euros = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' })

interface Producto {
  productoId: number
  nombre: string
  precio: number
}

function Catalogo() {
  const [productos, setProductos] = useState<Producto[]>([])
  const agregar = useCarrito((e) => e.agregar)

  useEffect(() => {
    const control = new AbortController()
    fetch(`${API}/productos?tamano=5`, { signal: control.signal })
      .then((r) => r.json())
      .then((pagina) => setProductos(pagina.contenido))
      .catch(() => {})
    return () => control.abort()
  }, [])

  return (
    <section className="tarjeta">
      <h2>Catálogo</h2>
      <table>
        <tbody>
          {productos.map((p) => (
            <tr key={p.productoId}>
              <td>{p.nombre}</td>
              <td className="num">{euros.format(p.precio)}</td>
              <td className="num">
                <button
                  onClick={() =>
                    agregar({ productoId: p.productoId, nombre: p.nombre, precio: p.precio })
                  }
                >
                  Añadir
                </button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </section>
  )
}

function Cabecera() {
  const unidades = useCarrito(selectUnidades)
  const alternar = useCarrito((e) => e.alternar)
  return (
    <div className="fila separada">
      <strong>EMPRESA</strong>
      <button onClick={alternar}>Carrito ({unidades})</button>
    </div>
  )
}

function Carrito() {
  const abierto = useCarrito((e) => e.abierto)
  const lineas = useCarrito((e) => e.lineas)
  const total = useCarrito(selectTotal)
  const cambiarCantidad = useCarrito((e) => e.cambiarCantidad)
  const vaciar = useCarrito((e) => e.vaciar)

  if (!abierto) return null
  return (
    <section className="tarjeta">
      <h2>Tu carrito</h2>
      {lineas.length === 0 && <p className="apagado">Todavía no has añadido nada.</p>}
      {lineas.map((l) => (
        <div key={l.productoId} className="fila separada">
          <span>{l.nombre}</span>
          <span>
            <button onClick={() => cambiarCantidad(l.productoId, l.cantidad - 1)}>-</button>{' '}
            {l.cantidad}{' '}
            <button onClick={() => cambiarCantidad(l.productoId, l.cantidad + 1)}>+</button>
          </span>
        </div>
      ))}
      <div className="fila separada">
        <strong>Total: {euros.format(total)}</strong>
        <button onClick={vaciar} disabled={lineas.length === 0}>Vaciar</button>
      </div>
    </section>
  )
}

export default function App() {
  return (
    <>
      <Cabecera />
      <div className="columnas">
        <Catalogo />
        <Carrito />
      </div>
    </>
  )
}

Catálogo de productos a la izquierda y carrito con dos líneas y total de 1897 euros a la derecha, con el botón Carrito (3) en la cabecera

Figura 5. El carrito tras añadir un portátil UltraBook y dos Portátil Básico. La cabecera, el catálogo y el panel comparten el store sin props.

Observa cómo se han escrito las actualizaciones: agregar copia el array y la línea que cambia. Zustand no impone la inmutabilidad, pero la necesita. Si haces estado.lineas.push(...), la referencia de lineas no cambia, los selectores dan el mismo valor y la pantalla no se actualiza. El apartado 7 muestra cómo escribir esto más corto con Immer.

El listado de productos sigue con useEffect para no añadir otra librería, pero en una aplicación real sería un useQuery, como en el módulo 19: el catálogo es estado de servidor, y el store guarda solo lo que el usuario ha elegido.


5. Guardar el estado con persist

El middleware persist envuelve el creador del store: guarda el estado en cada cambio y lo recupera al arrancar. Ya lo has visto en el carrito. Si recargas la página, las líneas siguen ahí. Esto es lo que queda realmente en localStorage, bajo la clave empresa-carrito:

{"state":{"lineas":[{"productoId":1,"nombre":"Portátil UltraBook 14","precio":899,"cantidad":1},{"productoId":3,"nombre":"Portátil Básico 15","precio":499,"cantidad":2}]},"version":0}

El estado va dentro de state, junto al número de versión. Las opciones importantes son estas:

Opción Para qué sirve
name Clave de almacenamiento. Es la única obligatoria y debe ser única en el dominio
storage Dónde guardar. Por defecto createJSONStorage(() => localStorage). Con sessionStorage, se borra al cerrar la pestaña
partialize Elige qué campos se guardan
version Número de versión del formato guardado. Por defecto, 0
migrate Convierte datos de una versión antigua a la actual
merge Cómo se combina lo guardado con el estado inicial
onRehydrateStorage Función que se ejecuta antes y después de recuperar
skipHydration No recupera al crear el store; lo harás tú con rehydrate()

Guarda solo lo necesario con partialize. En el carrito, abierto queda fuera: es estado de interfaz, y al volver no tiene por qué seguir igual. Las funciones nunca se guardan, porque JSON no las admite.

Versiones y migraciones. Imagina que publicas la aplicación, miles de usuarios tienen el carrito guardado y luego cambias el formato. Al arrancar, leerías datos con la forma antigua. Si subes version sin dar migrate, Zustand descarta lo guardado y escribe este error en la consola:

State loaded from storage couldn't be migrated since no migrate function was provided

Lo he comprobado: el store arranca con los valores iniciales, así que el usuario pierde el carrito. El ejercicio 4 resuelve una migración completa.

Campos nuevos. Si añades un campo y no cambias la versión, no hay problema. La fusión por defecto es superficial: lo guardado se combina con el estado inicial, y el campo nuevo conserva su valor inicial. Ojo con los objetos anidados, porque se sustituyen enteros. Para fusionar en profundidad, usa merge.

Hidratación. Con localStorage, la lectura es síncrona, y el store ya nace con los datos recuperados. Con un almacenamiento asíncrono, como IndexedDB o AsyncStorage, hay un momento en que el store tiene los valores iniciales. Para esos casos están useCarrito.persist.hasHydrated() y useCarrito.persist.onFinishHydration(fn). También tienes useCarrito.persist.clearStorage() para borrar lo guardado.

⚠️ Importante: Con Server-Side Rendering no existe localStorage en el servidor, y el HTML inicial no coincidirá con lo que hay en el navegador, lo que produce un error de hidratación. La solución es skipHydration: true y llamar a rehydrate() en un efecto. Lo verás en contexto en el módulo 23. Con Vite, que solo renderiza en el cliente, no ocurre.


6. Acciones asíncronas y uso fuera de React

Zustand no necesita nada especial para lo asíncrono: una acción es una función, y puede ser async. Llama a set cuando quiera. La sesión es un buen ejemplo, porque sí es estado global de cliente: el token y el usuario se usan en toda la aplicación.

src/sesion.ts:

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

const API = 'http://localhost:3000/api'

interface Usuario {
  email: string
  nombre: string
  rol: 'ADMIN' | 'USER'
}

interface EstadoSesion {
  token: string | null
  usuario: Usuario | null
  estado: 'inactivo' | 'cargando' | 'error'
  error: string | null
  entrar: (email: string, password: string) => Promise<void>
  salir: () => void
}

export const useSesion = create<EstadoSesion>()(
  persist(
    (set) => ({
      token: null,
      usuario: null,
      estado: 'inactivo',
      error: null,

      entrar: async (email, password) => {
        set({ estado: 'cargando', error: null })
        try {
          const respuesta = await fetch(`${API}/auth/login`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ email, password }),
          })
          const datos = await respuesta.json()
          if (!respuesta.ok) throw new Error(datos.mensaje)
          set({ token: datos.token, usuario: datos.usuario, estado: 'inactivo' })
        } catch (error) {
          set({ estado: 'error', error: error instanceof Error ? error.message : 'Error' })
        }
      },

      salir: () => set({ token: null, usuario: null }),
    }),
    {
      name: 'empresa-sesion',
      storage: createJSONStorage(() => sessionStorage), // se borra al cerrar la pestaña
      partialize: ({ token, usuario }) => ({ token, usuario }),
    },
  ),
)

Esta es la gran ventaja de un store externo: no pertenece a React. Una función corriente que hace peticiones no puede usar hooks, pero sí puede leer el store. El objeto que devuelve create es un hook y además tiene estos métodos:

Método Uso
useSesion.getState() Lee el estado actual una vez, sin suscribirse
useSesion.setState(cambios) Cambia el estado desde cualquier sitio
useSesion.subscribe(fn) Avisa de cada cambio con el estado nuevo y el anterior. Devuelve la función para cancelar
useSesion.getInitialState() Devuelve el estado inicial, útil para reiniciar en pruebas

src/api.ts:

import { useSesion } from './sesion'

const API = 'http://localhost:3000/api'

// Una función normal, fuera de React: por eso usa getState() y no el hook
export async function pedirApi<T>(ruta: string, opciones: RequestInit = {}): Promise<T> {
  const { token, salir } = useSesion.getState()
  const respuesta = await fetch(`${API}${ruta}`, {
    ...opciones,
    headers: {
      'Content-Type': 'application/json',
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
      ...opciones.headers,
    },
  })
  if (respuesta.status === 401) salir() // token caducado: cierra la sesión en toda la aplicación
  const datos = await respuesta.json()
  if (!respuesta.ok) throw new Error(datos.mensaje)
  return datos
}

// subscribe avisa de cada cambio, también fuera de React
useSesion.subscribe((actual, anterior) => {
  if (anterior.token && !actual.token) console.log('Sesión cerrada')
  if (!anterior.token && actual.token) console.log(`Sesión abierta: ${actual.usuario?.nombre}`)
})

src/App.tsx:

import { useState } from 'react'
import { pedirApi } from './api'
import { useSesion } from './sesion'

function FormularioEntrada() {
  const [password, setPassword] = useState('secreto')
  const entrar = useSesion((s) => s.entrar)
  const estado = useSesion((s) => s.estado)
  const error = useSesion((s) => s.error)

  return (
    <form
      className="tarjeta"
      onSubmit={(evento) => {
        evento.preventDefault()
        void entrar('ana.garcia@empresa.es', password)
      }}
    >
      <h2>Iniciar sesión</h2>
      <div className="fila">
        <input
          aria-label="Contraseña"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
        <button disabled={estado === 'cargando'}>Entrar</button>
      </div>
      {error && <p className="error" role="alert">{error}</p>}
    </form>
  )
}

function Panel() {
  const usuario = useSesion((s) => s.usuario)
  const salir = useSesion((s) => s.salir)
  const [respuesta, setRespuesta] = useState('')

  async function comprobar() {
    const yo = await pedirApi<{ email: string; rol: string }>('/auth/yo')
    setRespuesta(`La API te reconoce como ${yo.email} (${yo.rol})`)
  }

  return (
    <section className="tarjeta">
      <h2>Hola, {usuario?.nombre}</h2>
      <div className="fila">
        <button onClick={comprobar}>Comprobar sesión</button>
        <button onClick={salir}>Salir</button>
      </div>
      <p className="apagado">{respuesta}</p>
    </section>
  )
}

export default function App() {
  const conectado = useSesion((s) => s.token !== null)
  return conectado ? <Panel /> : <FormularioEntrada />
}

Con una contraseña incorrecta aparece Email o contraseña incorrectos, que es el mensaje de la API. Con la correcta, al entrar y salir la consola muestra lo siguiente, y Comprobar sesión responde La API te reconoce como ana.garcia@empresa.es (ADMIN):

Sesión abierta: Ana García
Sesión cerrada

⚠️ Importante: Guardar un token en localStorage o sessionStorage lo deja al alcance de cualquier script de la página: si tienes una vulnerabilidad XSS, como las del módulo 02, te lo pueden robar. En producción es más seguro que el servidor lo envíe en una cookie HttpOnly. Para esta API de ejemplo, que usa Authorization: Bearer, sessionStorage es un mal menor, y al menos se borra con la pestaña.

⚠️ Importante: No uses getState() dentro del render para leer datos que deben actualizar la pantalla: no se suscribe a nada y el componente se queda con un valor viejo. Dentro de un componente, usa siempre el hook. getState() es para manejadores de eventos, funciones externas y pruebas.


7. Middlewares y organización

Un middleware de Zustand es una función que envuelve al creador del store y le añade algo. Se anidan como capas. Este store de preferencias de tabla usa cuatro:

import { create } from 'zustand'
import { devtools, persist, subscribeWithSelector } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'

interface Columna {
  id: string
  visible: boolean
}

interface EstadoTabla {
  columnas: Columna[]
  tamanoPagina: number
  alternarColumna: (id: string) => void
  setTamanoPagina: (tamano: number) => void
}

export const useTabla = create<EstadoTabla>()(
  devtools(
    subscribeWithSelector(
      persist(
        immer((set) => ({
          columnas: [
            { id: 'nombre', visible: true },
            { id: 'oficio', visible: true },
            { id: 'salario', visible: false },
          ],
          tamanoPagina: 10,

          alternarColumna: (id) =>
            set((borrador) => {
              const columna = borrador.columnas.find((c) => c.id === id)
              if (columna) columna.visible = !columna.visible // Immer permite "mutar" el borrador
            }),

          setTamanoPagina: (tamano) => set({ tamanoPagina: tamano }),
        })),
        { name: 'empresa-tabla' },
      ),
    ),
    { name: 'Tabla de empleados', enabled: import.meta.env.DEV },
  ),
)

// subscribeWithSelector permite escuchar solo una parte del estado
useTabla.subscribe(
  (estado) => estado.tamanoPagina,
  (tamano, anterior) => console.log(`Tamaño de página: ${anterior} -> ${tamano}`),
)

src/App.tsx:

import { useTabla } from './store'

export default function App() {
  const columnas = useTabla((e) => e.columnas)
  const tamano = useTabla((e) => e.tamanoPagina)
  const alternar = useTabla((e) => e.alternarColumna)
  const setTamano = useTabla((e) => e.setTamanoPagina)
  return (
    <section className="tarjeta">
      <h2>Tabla: {tamano} filas</h2>
      <div className="fila">
        {columnas.map((c) => (
          <button key={c.id} aria-pressed={c.visible} onClick={() => alternar(c.id)}>
            {c.id}
          </button>
        ))}
        <button onClick={() => setTamano(tamano + 10)}>+10 filas</button>
      </div>
    </section>
  )
}

Tras pulsar salario y +10 filas, la consola muestra Tamaño de página: 10 -> 20 y localStorage contiene:

{"state":{"columnas":[{"id":"nombre","visible":true},{"id":"oficio","visible":true},{"id":"salario","visible":true}],"tamanoPagina":20},"version":0}
Middleware Importación Qué aporta
persist zustand/middleware Guarda y recupera el estado
devtools zustand/middleware Conecta con la extensión Redux DevTools: historial de cambios y viaje en el tiempo
subscribeWithSelector zustand/middleware subscribe(selector, oyente): escucha solo una parte del estado
immer zustand/middleware/immer Permite escribir actualizaciones que parecen mutaciones. Necesita el paquete immer
combine zustand/middleware Deduce el tipo a partir del estado inicial, sin escribir la interfaz

El orden importa. La norma es que devtools vaya por fuera de todo, para que vea los cambios de los demás, e immer por dentro, junto a tus acciones. Lo que hay por dentro de immer recibe el set modificado.

Con immer, compara lo que costaba actualizar la línea en el carrito del apartado 4 con esto:

agregar: (producto) =>
  set((borrador) => {
    const linea = borrador.lineas.find((l) => l.productoId === producto.productoId)
    if (linea) linea.cantidad += 1
    else borrador.lineas.push({ ...producto, cantidad: 1 })
  }),

Immer te da un borrador que registra tus cambios y construye un objeto nuevo sin tocar el original. No es una mutación real. Lo explicamos con más detalle en el apartado 10, porque Redux Toolkit lo usa siempre.

Dividir un store en trozos

Si el store crece, se puede partir en varias funciones. Cada trozo es un StateCreator, cuyo primer genérico es el estado completo, para que pueda leer y escribir en los demás:

import { create, type StateCreator } from 'zustand'

interface TrozoCarrito {
  unidades: number
  sumar: () => void
}

interface TrozoUi {
  menuAbierto: boolean
  alternarMenu: () => void
  reiniciar: () => void // usa estado de los dos trozos
}

type Tienda = TrozoCarrito & TrozoUi

// El primer genérico es el estado COMPLETO: cada trozo puede leer y escribir en todo
const crearTrozoCarrito: StateCreator<Tienda, [], [], TrozoCarrito> = (set) => ({
  unidades: 0,
  sumar: () => set((e) => ({ unidades: e.unidades + 1 })),
})

const crearTrozoUi: StateCreator<Tienda, [], [], TrozoUi> = (set) => ({
  menuAbierto: false,
  alternarMenu: () => set((e) => ({ menuAbierto: !e.menuAbierto })),
  reiniciar: () => set({ unidades: 0, menuAbierto: false }),
})

export const useTienda = create<Tienda>()((...args) => ({
  ...crearTrozoCarrito(...args),
  ...crearTrozoUi(...args),
}))

src/App.tsx:

import { useTienda } from './store'

export default function App() {
  const unidades = useTienda((e) => e.unidades)
  const menuAbierto = useTienda((e) => e.menuAbierto)
  const sumar = useTienda((e) => e.sumar)
  const alternarMenu = useTienda((e) => e.alternarMenu)
  const reiniciar = useTienda((e) => e.reiniciar)
  return (
    <section className="tarjeta">
      <h2>Unidades: {unidades} / menú {menuAbierto ? 'abierto' : 'cerrado'}</h2>
      <div className="fila">
        <button onClick={sumar}>Sumar</button>
        <button onClick={alternarMenu}>Menú</button>
        <button onClick={reiniciar}>Reiniciar</button>
      </div>
    </section>
  )
}

Antes de hacer esto, plantéate si no te basta con varios stores pequeños, que es lo más natural en Zustand: useCarrito, useSesion, usePreferencias. Un componente puede usar varios, y cada uno se persiste por separado. Los trozos solo compensan si los datos están muy relacionados y una acción debe tocar varios a la vez, como reiniciar.


8. Un store por rama del árbol 🆕 19.0

🆕 Desde React 19.0. El ejemplo usa <ContextoFiltro value>, que no existe en React 18.3. Si usas una versión anterior: escribe <ContextoFiltro.Provider value>. El resto funciona igual, porque createStore y useStore no dependen de la versión de React.

Hasta ahora los stores eran singletons: hay uno por página. A veces eso es justo lo que no quieres:

  • Dos instancias independientes: dos tablas en la misma pantalla, cada una con su filtro.
  • Valores iniciales desde props: un store global se crea antes de que exista ningún componente.
  • Pruebas: cada prueba quiere un store limpio.
  • SSR: un store de módulo se comparte entre todas las peticiones del servidor. Los datos de un usuario podrían llegar a otro. Con Next.js (módulo 23) hay que crear el store por petición.

La solución combina lo mejor de los dos mundos: createStore crea el store sin hook, Context reparte su referencia, que no cambia nunca, y useStore se suscribe con selectores. Context ya no provoca renders, porque solo transporta un objeto estable.

src/filtro.tsx:

import { createContext, useContext, useState, type ReactNode } from 'react'
import { createStore, useStore, type StoreApi } from 'zustand'

interface EstadoFiltro {
  texto: string
  setTexto: (texto: string) => void
}

// createStore crea el store sin hook: es una "fábrica", no un singleton
const crearStoreFiltro = (textoInicial: string) =>
  createStore<EstadoFiltro>()((set) => ({
    texto: textoInicial,
    setTexto: (texto) => set({ texto }),
  }))

const ContextoFiltro = createContext<StoreApi<EstadoFiltro> | null>(null)

export function ProveedorFiltro(props: { textoInicial?: string; children: ReactNode }) {
  // Inicializador perezoso: el store se crea una vez por instancia del componente
  const [store] = useState(() => crearStoreFiltro(props.textoInicial ?? ''))
  return <ContextoFiltro value={store}>{props.children}</ContextoFiltro>
}

export function useFiltro<T>(selector: (estado: EstadoFiltro) => T): T {
  const store = useContext(ContextoFiltro)
  if (!store) throw new Error('useFiltro debe usarse dentro de ProveedorFiltro')
  return useStore(store, selector)
}

src/App.tsx:

import { ProveedorFiltro, useFiltro } from './filtro'

function Buscador({ titulo }: { titulo: string }) {
  const texto = useFiltro((e) => e.texto)
  const setTexto = useFiltro((e) => e.setTexto)
  return (
    <section className="tarjeta">
      <h2>{titulo}</h2>
      <input aria-label={titulo} value={texto} onChange={(e) => setTexto(e.target.value)} />
      <p className="apagado">Filtro: {texto || '(vacío)'}</p>
    </section>
  )
}

export default function App() {
  return (
    <div className="columnas">
      <ProveedorFiltro textoInicial="garcia">
        <Buscador titulo="Empleados" />
      </ProveedorFiltro>
      <ProveedorFiltro>
        <Buscador titulo="Productos" />
      </ProveedorFiltro>
    </div>
  )
}

Al escribir monitor en la segunda tarjeta, la primera conserva garcia: cada ProveedorFiltro tiene su propio store.

El useState con función es importante. Crear el store directamente en el cuerpo del componente crearía uno nuevo en cada render y perderías el estado.


9. Redux: el modelo

Redux aparece en casi cualquier oferta de trabajo con React y en muchas aplicaciones grandes que tendrás que mantener. Aunque en un proyecto nuevo elijas otra cosa, debes entenderlo. Además, sus ideas han influido en todo lo demás: useReducer, que ya conoces del módulo 11, es la misma idea a pequeña escala.

Redux se basa en tres reglas:

  1. Una única fuente de verdad. Todo el estado está en un solo árbol, dentro de un único store.
  2. El estado es de solo lectura. La única forma de cambiarlo es despachar una acción, un objeto que describe qué ha pasado.
  3. Los cambios los hacen funciones puras. Un reductor recibe el estado y la acción, y devuelve el estado siguiente.

Ciclo: el componente despacha una acción, que pasa por el middleware y llega al reductor; el reductor produce el estado nuevo en el store, que avisa a los selectores y estos actualizan el componente

Figura 6. El flujo de Redux. Los datos circulan siempre en el mismo sentido.

Pieza Qué es Ejemplo
Acción Objeto con type y, normalmente, payload { type: 'carrito/quitar', payload: 6 }
Creador de acciones Función que devuelve una acción quitar(6)
Reductor Función pura (estado, accion) => estado carritoSlice.reducer
Store Guarda el estado y ofrece dispatch, getState, subscribe configureStore(...)
Middleware Intercepta acciones antes del reductor Thunks, registro
Selector Función que extrae datos del estado (e) => e.carrito.lineas

Todo esto parece mucha ceremonia para cambiar un dato, y lo es. A cambio, cada cambio de estado queda registrado como un objeto que se puede leer, serializar, repetir y deshacer. Las Redux DevTools muestran la lista de acciones y el estado tras cada una, y puedes moverte por esa lista. En una aplicación grande con muchos desarrolladores, poder responder “¿quién cambió esto y por qué?” vale mucho.

📘 Concepto: Redux distingue entre qué ha pasado (la acción) y cómo cambia el estado (el reductor). Una misma acción puede interesar a varios reductores: sesion/cerrada puede vaciar el carrito y borrar los datos del usuario sin que quien la despacha conozca a ninguno. En Zustand es al revés: llamas a una función que dice directamente qué hacer.


10. Redux Toolkit

Redux Toolkit (RTK) es la forma oficial de escribir Redux. Si ves código con createStore de redux, constantes ADD_TODO y switch gigantes, es Redux antiguo. RTK elimina casi todo ese código repetitivo. Vamos a escribir el mismo carrito.

createSlice

Un slice, o porción, reúne el estado de una función de la aplicación, sus reductores y sus acciones. src/carritoSlice.ts:

import { createSlice, type PayloadAction } from '@reduxjs/toolkit'

export interface Linea {
  productoId: number
  nombre: string
  precio: number
  cantidad: number
}

interface EstadoCarrito {
  lineas: Linea[]
}

const estadoInicial: EstadoCarrito = { lineas: [] }

const carritoSlice = createSlice({
  name: 'carrito',
  initialState: estadoInicial,
  reducers: {
    agregar(estado, accion: PayloadAction<Omit<Linea, 'cantidad'>>) {
      const linea = estado.lineas.find((l) => l.productoId === accion.payload.productoId)
      if (linea) linea.cantidad += 1 // parece una mutación, pero es un borrador de Immer
      else estado.lineas.push({ ...accion.payload, cantidad: 1 })
    },
    quitar(estado, accion: PayloadAction<number>) {
      estado.lineas = estado.lineas.filter((l) => l.productoId !== accion.payload)
    },
    vaciar() {
      return estadoInicial // devolver un valor sustituye el estado
    },
  },
  selectors: {
    selectLineas: (estado) => estado.lineas,
    selectUnidades: (estado) => estado.lineas.reduce((suma, l) => suma + l.cantidad, 0),
    selectTotal: (estado) => estado.lineas.reduce((suma, l) => suma + l.cantidad * l.precio, 0),
  },
})

export const { agregar, quitar, vaciar } = carritoSlice.actions
export const { selectLineas, selectUnidades, selectTotal } = carritoSlice.selectors
export default carritoSlice.reducer

createSlice genera un creador de acciones por cada reductor. El tipo de la acción se forma con el nombre y la clave: agregar(p) devuelve { type: 'carrito/agregar', payload: p }. Ya no hay que escribir constantes. PayloadAction<T> tipa el payload.

Immer: las reglas

linea.cantidad += 1 parece prohibido, porque un reductor debe ser puro. Funciona porque createSlice usa Immer por dentro: estado es un borrador, un Proxy que anota lo que haces, y al terminar Immer crea un objeto nuevo con esos cambios. Lo que no se ha tocado conserva su referencia.

Hay dos errores que Immer no perdona:

// MAL: asignar al parámetro no cambia nada. Solo cambia la variable local
vaciar(estado) {
  estado = { lineas: [] }
}

// BIEN: devuelve el estado nuevo
vaciar() {
  return { lineas: [] }
}

// MAL: o mutas el borrador o devuelves un valor, pero no las dos cosas
quitar(estado, accion: PayloadAction<number>) {
  estado.lineas.pop()
  return estado.lineas.filter((l) => l.productoId !== accion.payload)
}

Fuera de un reductor, las reglas de siempre: el estado está congelado. Immer lo congela al terminar, y si intentas escribir en él, lanza un error, como verás en el apartado 11.

💡 Consejo: Para depurar dentro de un reductor, console.log(estado) enseña un Proxy poco útil. Usa current(estado), que se importa de @reduxjs/toolkit y devuelve una copia normal.

configureStore y los hooks tipados

src/store.ts:

import { configureStore } from '@reduxjs/toolkit'
import carrito from './carritoSlice'

export const store = configureStore({
  reducer: { carrito },
})

// Los tipos salen del propio store: no hay que escribirlos a mano
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

configureStore combina los reductores, activa el soporte de Redux DevTools y añade los middlewares por defecto: el de thunks y, en desarrollo, las comprobaciones de los apartados siguientes. La clave del objeto reducer da nombre a la porción: aquí el estado queda en estado.carrito.

src/hooks.ts. Se hace una sola vez, y evita repetir los tipos en cada componente:

import { useDispatch, useSelector } from 'react-redux'
import type { AppDispatch, RootState } from './store'

export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()

src/App.tsx:

import { Provider } from 'react-redux'
import { agregar, quitar, selectLineas, selectTotal, selectUnidades, vaciar } from './carritoSlice'
import { useAppDispatch, useAppSelector } from './hooks'
import { store } from './store'

const euros = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' })

const CATALOGO = [
  { productoId: 1, nombre: 'Portátil UltraBook 14', precio: 899 },
  { productoId: 6, nombre: 'Ratón inalámbrico', precio: 19.9 },
]

function Carrito() {
  const lineas = useAppSelector(selectLineas)
  const unidades = useAppSelector(selectUnidades)
  const total = useAppSelector(selectTotal)
  const dispatch = useAppDispatch()

  return (
    <section className="tarjeta">
      <h2>Carrito ({unidades})</h2>
      <div className="fila">
        {CATALOGO.map((p) => (
          <button key={p.productoId} onClick={() => dispatch(agregar(p))}>
            {p.nombre}
          </button>
        ))}
      </div>
      {lineas.map((l) => (
        <div key={l.productoId} className="fila separada">
          <span>{l.cantidad} x {l.nombre}</span>
          <button onClick={() => dispatch(quitar(l.productoId))}>Quitar</button>
        </div>
      ))}
      <div className="fila separada">
        <strong>Total: {euros.format(total)}</strong>
        <button onClick={() => dispatch(vaciar())}>Vaciar</button>
      </div>
    </section>
  )
}

// En una aplicación real, <Provider> va en main.tsx
export default function App() {
  return (
    <Provider store={store}>
      <Carrito />
    </Provider>
  )
}

Las diferencias con Zustand se ven de un vistazo. Hace falta <Provider>, un componente no llama a una acción, sino que la despacha, y hay un fichero más de configuración. Los selectores de createSlice reciben el estado de la porción, pero se exportan para usarlos con el estado raíz: useAppSelector(selectLineas) funciona sin decir dónde está el carrito.

useSelector funciona igual que un selector de Zustand. Por dentro usa useSyncExternalStoreWithSelector y compara con ===, así que solo se renderiza el componente si cambia lo que has seleccionado.


11. Comprobaciones y selectores memorizados

Redux Toolkit vigila en desarrollo que no te saltes las reglas. Este ejemplo provoca los tres avisos más frecuentes:

import { configureStore, createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit'
import { useState } from 'react'
import { Provider, useSelector } from 'react-redux'

interface Empleado {
  id: number
  nombre: string
  salario: number
}

const plantilla = createSlice({
  name: 'plantilla',
  initialState: {
    empleados: [
      { id: 100, nombre: 'Ana', salario: 6500 },
      { id: 101, nombre: 'Luis', salario: 2100 },
    ] as Empleado[],
    alta: null as Date | string | null,
  },
  reducers: {
    registrarAlta(estado, accion: PayloadAction<Date | string>) {
      estado.alta = accion.payload
    },
  },
})

const store = configureStore({ reducer: { plantilla: plantilla.reducer } })
type Estado = ReturnType<typeof store.getState>

const selectEmpleados = (e: Estado) => e.plantilla.empleados

// MAL: filter devuelve un array nuevo en cada llamada
const selectAltosMal = (e: Estado) => selectEmpleados(e).filter((x) => x.salario > 3000)

// BIEN: createSelector recuerda el último resultado
const selectAltos = createSelector([selectEmpleados], (empleados) =>
  empleados.filter((x) => x.salario > 3000),
)

function Pruebas() {
  const [montar, setMontar] = useState(false)
  return (
    <section className="tarjeta">
      <h2>Errores típicos (mira la consola)</h2>
      <div className="fila">
        <button onClick={() => store.dispatch(plantilla.actions.registrarAlta(new Date()))}>
          Date en una acción
        </button>
        <button
          onClick={() => {
            try {
              selectEmpleados(store.getState())[0].salario = 1
            } catch (error) {
              console.log(String(error))
            }
          }}
        >
          Mutar el estado
        </button>
        <button onClick={() => setMontar(true)}>Selector inestable</button>
      </div>
      {montar && <Inestable />}
      <Estable />
    </section>
  )
}

function Inestable() {
  const altos = useSelector(selectAltosMal)
  return <p>Inestable: {altos.length}</p>
}

function Estable() {
  const altos = useSelector(selectAltos)
  return <p>Estable: {altos.length}</p>
}

export default function App() {
  return (
    <Provider store={store}>
      <Pruebas />
    </Provider>
  )
}

Datos no serializables. Al pulsar Date en una acción, la consola muestra dos errores, uno por la acción y otro por el estado:

A non-serializable value was detected in an action, in the path: `payload`.
Value: Mon Nov 02 2026 06:31:17 GMT+0100 (hora estándar de Europa central)
Take a look at the logic that dispatched this action: ...

A non-serializable value was detected in the state, in the path: `plantilla.alta`.
Take a look at the reducer(s) handling this action type: plantilla/registrarAlta.

Redux quiere solo datos que sobrevivan a JSON.stringify: las DevTools, la persistencia y el viaje en el tiempo dependen de ello. Un Date, un Map, un Set, una clase o una función rompen esa garantía. Guarda fechas como texto ISO: new Date().toISOString(). Fíjate en que la aplicación sigue funcionando: es un aviso, y no aparece en producción.

Mutar el estado fuera de un reductor. El botón Mutar el estado escribe en un objeto del store:

TypeError: Cannot assign to read only property 'salario' of object '#<Object>'

Selector inestable. El botón Selector inestable monta un componente con filter dentro del selector:

Selector selectAltosMal returned a different result when called with the same parameters.
This can lead to unnecessary rerenders.
Selectors that return a new reference (such as an object or an array) should be memoized

Es el mismo problema del apartado 3, con una diferencia: react-redux no entra en un bucle infinito, sino que renderiza de más en cada acción, porque cada vez recibe un array nuevo. Aquí el error es silencioso, salvo por este aviso, que además solo aparece la primera vez.

createSelector

La solución es memorizar. createSelector recibe unos selectores de entrada y una función que combina sus resultados. Solo la vuelve a ejecutar si cambia alguna entrada:

const selectAltos = createSelector([selectEmpleados], (empleados) =>
  empleados.filter((x) => x.salario > 3000),
)

Mientras selectEmpleados devuelva la misma referencia, selectAltos devuelve el mismo array sin recalcular nada. Además de evitar renders, ahorra trabajo si el cálculo es caro.

Un error frecuente es escribir un selector de entrada que transforma: (e) => e.lista.filter(...) da siempre un resultado nuevo, y la memorización no sirve de nada. Los selectores de entrada solo deben extraer. Lo que transforma va en la función final.


12. Peticiones con createAsyncThunk

Un reductor es síncrono y puro, así que no puede hacer peticiones. Esa lógica va en un thunk: una función que se despacha igual que una acción y que recibe dispatch y getState. createAsyncThunk crea uno y despacha por ti tres acciones durante la petición:

Acción Cuándo se despacha
empleados/cargar/pending Al empezar
empleados/cargar/fulfilled Si la función termina bien. Lo que devuelve es el payload
empleados/cargar/rejected Si lanza un error, si se usa rejectWithValue o si se cancela

Los reductores escuchan esas acciones en extraReducers, porque se han creado fuera del slice. Este ejemplo combina el thunk con createEntityAdapter, que guarda los elementos normalizados: un array de identificadores y un objeto indexado por identificador. Así, buscar por id no recorre nada, y actualizar un empleado no toca a los demás.

src/empleadosSlice.ts:

import { createAsyncThunk, createEntityAdapter, createSlice } from '@reduxjs/toolkit'
import type { AppDispatch, RootState } from './store'

const API = 'http://localhost:3000/api'

export interface Empleado {
  empId: number
  nombre: string
  apellidos: string
  oficio: string
  salario: number
  deptId: number
}

interface Pagina<T> {
  contenido: T[]
}

// Versión de createAsyncThunk con los tipos de ESTE store ya puestos
const crearThunk = createAsyncThunk.withTypes<{
  state: RootState
  dispatch: AppDispatch
  rejectValue: string
}>()

export const cargarEmpleados = crearThunk(
  'empleados/cargar',
  async (deptId: number, { signal, rejectWithValue }) => {
    const respuesta = await fetch(`${API}/empleados?deptId=${deptId}&tamano=100`, { signal })
    if (!respuesta.ok) return rejectWithValue(`Error ${respuesta.status} al cargar`)
    const pagina: Pagina<Empleado> = await respuesta.json()
    return pagina.contenido
  },
)

// Normaliza: guarda { ids: [...], entities: { 100: {...} } } y da reductores ya hechos
const adaptador = createEntityAdapter({
  selectId: (e: Empleado) => e.empId,
  sortComparer: (a, b) => a.apellidos.localeCompare(b.apellidos, 'es'),
})

const empleadosSlice = createSlice({
  name: 'empleados',
  initialState: adaptador.getInitialState({
    estado: 'inactivo' as 'inactivo' | 'cargando' | 'listo' | 'error',
    error: null as string | null,
  }),
  reducers: {
    subirSalario(estado, accion: { payload: { empId: number; importe: number } }) {
      const { empId, importe } = accion.payload
      const empleado = estado.entities[empId]
      if (empleado) empleado.salario += importe // acceso directo por id
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(cargarEmpleados.pending, (estado) => {
        estado.estado = 'cargando'
        estado.error = null
      })
      .addCase(cargarEmpleados.fulfilled, (estado, accion) => {
        estado.estado = 'listo'
        adaptador.setAll(estado, accion.payload)
      })
      .addCase(cargarEmpleados.rejected, (estado, accion) => {
        if (accion.meta.aborted) return // lo hemos cancelado nosotros: no es un error
        estado.estado = 'error'
        estado.error = accion.payload ?? accion.error.message ?? 'Error desconocido'
      })
  },
})

export const { subirSalario } = empleadosSlice.actions
export default empleadosSlice.reducer

export const { selectAll: selectEmpleados, selectTotal: selectNumEmpleados } =
  adaptador.getSelectors((estado: RootState) => estado.empleados)

src/store.ts. Añade un middleware de registro que escribe cada acción en la consola. En cuatro líneas ves cómo funciona un middleware: recibe la acción, hace algo y la pasa con next:

import { configureStore, isAction, type Middleware } from '@reduxjs/toolkit'
import empleados from './empleadosSlice'

// Un middleware ve pasar todas las acciones antes de que lleguen al reductor
const registro: Middleware = () => (next) => (accion) => {
  if (isAction(accion)) console.log(accion.type)
  return next(accion)
}

export const store = configureStore({
  reducer: { empleados },
  middleware: (getDefault) => getDefault().concat(registro),
})

export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

src/App.tsx:

import { useEffect, useState } from 'react'
import { Provider, useDispatch, useSelector } from 'react-redux'
import {
  cargarEmpleados,
  selectEmpleados,
  selectNumEmpleados,
  subirSalario,
} from './empleadosSlice'
import { store, type AppDispatch, type RootState } from './store'

const useAppDispatch = useDispatch.withTypes<AppDispatch>()
const useAppSelector = useSelector.withTypes<RootState>()

function Empleados() {
  const [deptId, setDeptId] = useState(20)
  const empleados = useAppSelector(selectEmpleados)
  const total = useAppSelector(selectNumEmpleados)
  const estado = useAppSelector((e) => e.empleados.estado)
  const error = useAppSelector((e) => e.empleados.error)
  const dispatch = useAppDispatch()

  useEffect(() => {
    const peticion = dispatch(cargarEmpleados(deptId))
    return () => peticion.abort() // cancela la petición si cambias de departamento
  }, [deptId, dispatch])

  return (
    <section className="tarjeta">
      <h2>Departamento {deptId}: {total} empleados</h2>
      <div className="fila">
        {[20, 30, 40].map((id) => (
          <button key={id} aria-pressed={id === deptId} onClick={() => setDeptId(id)}>
            {id}
          </button>
        ))}
        {estado === 'cargando' && <span className="apagado">Cargando...</span>}
      </div>
      {error && <p className="error" role="alert">{error}</p>}
      <table>
        <tbody>
          {empleados.map((e) => (
            <tr key={e.empId}>
              <td>{e.apellidos}, {e.nombre}</td>
              <td className="num">{e.salario} €</td>
              <td className="num">
                <button onClick={() => dispatch(subirSalario({ empId: e.empId, importe: 100 }))}>
                  +100
                </button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </section>
  )
}

export default function App() {
  return (
    <Provider store={store}>
      <Empleados />
    </Provider>
  )
}

Tabla de seis empleados del departamento 20 con botones de cambio de departamento y de subida de salario

Figura 7. Empleados de un departamento, en un store normalizado y ordenado por apellidos.

Este es el registro real al abrir la página, pulsar +100 y cambiar a los departamentos 30 y 40:

empleados/cargar/pending
empleados/cargar/pending
empleados/cargar/rejected
empleados/cargar/fulfilled
empleados/subirSalario
empleados/cargar/pending
empleados/cargar/fulfilled
empleados/cargar/pending
empleados/cargar/fulfilled

Las cuatro primeras líneas merecen una explicación. Strict Mode monta el efecto, lo desmonta y lo vuelve a montar (módulo 09). La limpieza llama a peticion.abort(), que dispara signal y despacha rejected con meta.aborted a true. Por eso el reductor de rejected lo ignora: si no, mostraría un error por una cancelación que has pedido tú, y además justo cuando la segunda petición está en marcha.

Lo que te da createAsyncThunk:

  • rejectWithValue: rechaza con un valor tuyo, que llega en accion.payload, en lugar del error serializado de accion.error.
  • signal: un AbortSignal para pasarlo a fetch, como en el módulo 09.
  • condition: función que se evalúa antes de empezar. Si devuelve false, el thunk no se ejecuta ni despacha nada. Lo usas en el ejercicio 7.
  • dispatch(thunk(...)).unwrap(): devuelve una promesa que da el payload o lanza el error. Sirve para reaccionar en el componente: await dispatch(guardar(datos)).unwrap().
  • createAsyncThunk.withTypes: fija los tipos de estado y dispatch una vez.

⚠️ Importante: Mira de nuevo este ejemplo. Son más de 60 líneas para cargar una lista, y no tiene caché: al volver al departamento 20, se hace la petición otra vez, y si hubieras subido un salario se perdería. createAsyncThunk sigue siendo útil para procesos con varios pasos, pero para leer datos del servidor existe algo mejor.

Lógica que reacciona a las acciones

RTK trae también createListenerMiddleware, que ejecuta código cuando se despacha una acción, y sirve para efectos como “cuando se añada algo al carrito, guárdalo en el servidor”. Redux no trae persistencia propia como persist: se hace con un middleware de este tipo o con un paquete externo.


13. RTK Query

RTK Query viene incluido en Redux Toolkit y resuelve lo mismo que TanStack Query: caché de datos de servidor, sin escribir ni un reductor. Si tu aplicación ya usa Redux, es la opción natural. Conoces los conceptos del módulo 19. Aquí cambia la forma: defines toda la API en un solo sitio, y RTK Query genera los hooks.

src/api.ts:

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export interface Departamento {
  deptId: number
  nombre: string
  localidad: string | null
  presupuesto: number | null
}

interface Pagina<T> {
  contenido: T[]
  total: number
}

interface RespuestaLogin {
  token: string
  usuario: { email: string; nombre: string; rol: string }
}

export const empresaApi = createApi({
  reducerPath: 'empresaApi', // dónde vive la caché dentro del store
  baseQuery: fetchBaseQuery({
    baseUrl: 'http://localhost:3000/api',
    prepareHeaders: (cabeceras, { getState }) => {
      const { sesion } = getState() as { sesion: { token: string | null } }
      if (sesion.token) cabeceras.set('Authorization', `Bearer ${sesion.token}`)
      return cabeceras
    },
  }),
  tagTypes: ['Departamento'],
  endpoints: (build) => ({
    entrar: build.mutation<RespuestaLogin, { email: string; password: string }>({
      query: (credenciales) => ({ url: '/auth/login', method: 'POST', body: credenciales }),
    }),

    departamentos: build.query<Departamento[], void>({
      query: () => '/departamentos?tamano=100',
      transformResponse: (respuesta: Pagina<Departamento>) => respuesta.contenido,
      providesTags: (lista = []) => [
        ...lista.map((d) => ({ type: 'Departamento' as const, id: d.deptId })),
        { type: 'Departamento' as const, id: 'LISTA' },
      ],
    }),

    crearDepartamento: build.mutation<Departamento, Omit<Departamento, 'deptId'>>({
      query: (cuerpo) => ({ url: '/departamentos', method: 'POST', body: cuerpo }),
      invalidatesTags: [{ type: 'Departamento', id: 'LISTA' }], // recarga la lista
    }),

    guardarDepartamento: build.mutation<Departamento, Departamento>({
      query: ({ deptId, ...cuerpo }) => ({
        url: `/departamentos/${deptId}`,
        method: 'PUT',
        body: cuerpo,
      }),
      // Actualización optimista: cambia la caché antes de que responda el servidor
      async onQueryStarted(editado, { dispatch, queryFulfilled }) {
        const parche = dispatch(
          empresaApi.util.updateQueryData('departamentos', undefined, (borrador) => {
            const dept = borrador.find((d) => d.deptId === editado.deptId)
            if (dept) Object.assign(dept, editado)
          }),
        )
        try {
          await queryFulfilled
        } catch {
          parche.undo() // ha fallado: deshace el cambio
        }
      },
    }),
  }),
})

export const {
  useEntrarMutation,
  useDepartamentosQuery,
  useCrearDepartamentoMutation,
  useGuardarDepartamentoMutation,
} = empresaApi

src/store.ts. Hay que registrar el reductor de la caché y el middleware. Si olvidas el middleware, nada funciona bien: es el que gestiona la caché y las invalidaciones:

import { configureStore, createSlice } from '@reduxjs/toolkit'
import { setupListeners } from '@reduxjs/toolkit/query'
import { empresaApi } from './api'

const sesionSlice = createSlice({
  name: 'sesion',
  initialState: { token: null as string | null, nombre: null as string | null },
  reducers: {},
  extraReducers: (builder) => {
    // Escucha la mutación de login: el token queda en un slice normal
    builder.addMatcher(empresaApi.endpoints.entrar.matchFulfilled, (estado, { payload }) => {
      estado.token = payload.token
      estado.nombre = payload.usuario.nombre
    })
  },
})

export const store = configureStore({
  reducer: {
    sesion: sesionSlice.reducer,
    [empresaApi.reducerPath]: empresaApi.reducer,
  },
  middleware: (getDefault) => getDefault().concat(empresaApi.middleware),
})

setupListeners(store.dispatch) // activa refetchOnFocus y refetchOnReconnect

export type RootState = ReturnType<typeof store.getState>

src/App.tsx:

import { Provider, useSelector } from 'react-redux'
import {
  useCrearDepartamentoMutation,
  useDepartamentosQuery,
  useEntrarMutation,
  useGuardarDepartamentoMutation,
} from './api'
import { store, type RootState } from './store'

const euros = new Intl.NumberFormat('es-ES', {
  style: 'currency',
  currency: 'EUR',
  maximumFractionDigits: 0,
})

function Cabecera() {
  const nombre = useSelector((e: RootState) => e.sesion.nombre)
  const [entrar, { isLoading }] = useEntrarMutation()
  // El mismo hook que Tabla: comparten caché y solo hay una petición
  const { data = [] } = useDepartamentosQuery()
  const total = data.reduce((suma, d) => suma + (d.presupuesto ?? 0), 0)

  return (
    <div className="fila separada">
      <strong>Presupuesto total: {euros.format(total)}</strong>
      {nombre ? (
        <span>{nombre}</span>
      ) : (
        <button
          disabled={isLoading}
          onClick={() => entrar({ email: 'ana.garcia@empresa.es', password: 'secreto' })}
        >
          Entrar como Ana
        </button>
      )}
    </div>
  )
}

function Tabla() {
  const { data = [], isLoading, isFetching, error } = useDepartamentosQuery()
  const [guardar, resultado] = useGuardarDepartamentoMutation()
  const [crear] = useCrearDepartamentoMutation()

  if (isLoading) return <p>Cargando...</p>
  if (error) return <p className="error">No se pudo cargar</p>

  return (
    <section className="tarjeta">
      <h2>Departamentos {isFetching && <small className="apagado">actualizando...</small>}</h2>
      <table>
        <tbody>
          {data.map((d) => (
            <tr key={d.deptId}>
              <td>{d.nombre}</td>
              <td className="num">{euros.format(d.presupuesto ?? 0)}</td>
              <td className="num">
                <button
                  onClick={() =>
                    guardar({ ...d, presupuesto: (d.presupuesto ?? 0) + 1000 })
                  }
                >
                  +1000
                </button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
      <p className="fila">
        <button
          onClick={() =>
            crear({ nombre: `NUEVO ${Date.now() % 1000}`, localidad: 'Valencia', presupuesto: 1000 })
          }
        >
          Nuevo departamento
        </button>
        {resultado.isError && (
          <span className="error" role="alert">
            No se guardó: el cambio se ha deshecho
          </span>
        )}
      </p>
    </section>
  )
}

export default function App() {
  return (
    <Provider store={store}>
      <Cabecera />
      <Tabla />
    </Provider>
  )
}

Cabecera con presupuesto total de 1.800.000 euros y tabla de departamentos con botones para sumar 1000 euros

Figura 8. Dos componentes leen la misma consulta: el total de la cabecera y la tabla.

He contado las peticiones reales de una sesión completa. Primero, sin haber iniciado sesión, pulsé +1000. Luego entré como Ana, repetí +1000 y creé un departamento:

GET  /api/departamentos?tamano=100
PUT  /api/departamentos/10              (401: sin sesión)
POST /api/auth/login
PUT  /api/departamentos/10
POST /api/departamentos
GET  /api/departamentos?tamano=100

Hay varias cosas que aprender de esta lista:

  • Un solo GET al empezar, aunque dos componentes usan el hook y Strict Mode ejecuta los efectos dos veces. RTK Query agrupa las peticiones por endpoint y argumentos.
  • El primer PUT falla con 401. La fila había pasado a 501.000 € de forma optimista, y parche.undo() la devolvió a 500.000 €. Aparece el mensaje de error.
  • El segundo PUT no provoca ningún GET. guardarDepartamento actualiza la caché con updateQueryData y no invalida ninguna etiqueta: no hace falta volver a pedir la lista.
  • El POST sí provoca un GET: crearDepartamento invalida la etiqueta LISTA.

Etiquetas

TanStack Query invalida por clave de consulta. RTK Query usa un sistema de etiquetas: las consultas declaran qué datos aportan con providesTags, y las mutaciones declaran qué datos dejan obsoletos con invalidatesTags.

La consulta departamentos aporta etiquetas por identificador y una etiqueta LISTA; la mutación crearDepartamento invalida LISTA y la consulta se repite

Figura 9. Una mutación no conoce las consultas: solo dice qué etiquetas ha cambiado.

El patrón { type, id: 'LISTA' } es el habitual:

  • Crear invalida la etiqueta LISTA, porque la lista tiene un elemento nuevo.
  • Modificar invalida solo { type: 'Departamento', id }.
  • Borrar invalida el id borrado.

Opciones de los hooks

Opción o resultado Qué hace
isLoading Primera carga, sin datos
isFetching Hay una petición en curso, también si ya hay datos
skip: true No hace la consulta. También skipToken como argumento
pollingInterval: 30000 Repite la consulta cada 30 s
refetchOnMountOrArgChange Repite la consulta al montar, aunque esté en caché
refetchOnFocus Repite la consulta al volver a la pestaña. Necesita setupListeners
selectFromResult Selecciona una parte del resultado, para evitar renders
keepUnusedDataFor Segundos que dura en caché sin suscriptores

Además de useDepartamentosQuery, se genera useLazyDepartamentosQuery, que devuelve una función para lanzar la consulta a mano. El ejercicio 8 usa build.infiniteQuery para listas infinitas.

RTK Query frente a TanStack Query

  TanStack Query RTK Query
Definición Cada useQuery lleva su clave y su función Una API central con todos los endpoints
Invalidación Por clave, con invalidateQueries Por etiquetas declaradas
Dependencias Ninguna más Redux, react-redux
Datos Fuera de tu estado Dentro del store, visibles en DevTools
Con Suspense useSuspenseQuery Sin hook propio

14. Comparativa: qué elegir

Medí el aumento real de tamaño compilando con vite build una aplicación mínima con cada librería. React 19.3.1 y react-dom solos ocupan 68,61 kB comprimidos con gzip:

Librería Total gzip Añade
Solo React 68,61 kB  
Zustand 69,13 kB 0,5 kB
Redux Toolkit + react-redux 76,95 kB 8,3 kB
TanStack Query 78,65 kB 10,0 kB
Redux Toolkit + RTK Query 93,76 kB 25,2 kB
  Context Zustand Redux Toolkit
Instalación Viene con React Una dependencia Dos dependencias
Proveedor Sí No Sí
Renders selectivos No Sí, con selectores Sí, con selectores
Código repetitivo Medio Muy poco Medio
Inmutabilidad Tú Tú, o immer Immer incluido
Asincronía Tú Funciones async Thunks, RTK Query
Persistencia Tú persist incluido Middleware propio o paquete externo
DevTools React DevTools Extensión Redux, con devtools Extensión Redux, activa por defecto
Uso fuera de React No Directo store.dispatch, store.getState
Ideal para Tema, idioma, usuario Estado global pequeño o medio Estado complejo, equipos grandes, Redux existente

Una guía para decidir:

  1. Primero, lleva lo que puedas a su sitio: datos de servidor a TanStack Query, filtros a la URL, formularios a su librería. Puede que ya no necesites nada más.
  2. Lo que queda cambia poco: usa Context.
  3. Lo que queda cambia a menudo: usa Zustand. Es la opción por defecto para un proyecto nuevo.
  4. Mucho estado de cliente, con reglas complejas, o un equipo grande que necesita una estructura impuesta y trazabilidad: usa Redux Toolkit.
  5. Ya tienes Redux: quédate, migra a RTK si aún no lo has hecho, y usa RTK Query.

Este curso usa TanStack Query para los datos y Zustand o Context para lo demás, y así lo hará el proyecto final. Si encuentras Redux en el trabajo, lo que has aprendido aquí te bastará para ponerte al día.


15. Ejercicios

Ejercicio 1: ¿Dónde vive cada dato?

Decide dónde guardarías cada uno de estos datos de la aplicación EMPRESA y por qué: (a) la lista de pedidos, (b) el texto de búsqueda de empleados, que debe poder compartirse por enlace, (c) el modal “confirmar borrado”, (d) el tema claro u oscuro, (e) el token de sesión, (f) las líneas de un carrito que debe sobrevivir a una recarga, (g) los campos del formulario de alta de un empleado, (h) la página actual de una tabla.

Ver solución
Dato Dónde Por qué
(a) Pedidos TanStack Query Estado de servidor: necesita caché e invalidación
(b) Búsqueda compartible URL, useSearchParams Debe sobrevivir a un enlace
(c) Modal useState local Solo le importa a un componente
(d) Tema Context, o Zustand con persist Global, cambia poco. Si debe recordarse, un store con persist lo resuelve con menos código
(e) Token Store con persist en sessionStorage, o mejor una cookie HttpOnly Global, y se necesita fuera de React
(f) Carrito Zustand con persist Global, cambia a menudo, no viene del servidor
(g) Formulario useState o React Hook Form Es un borrador: no interesa a nadie hasta que se envía
(h) Página URL Recargar y volver atrás deben conservarla

Observa que de ocho datos, solo dos o tres acaban en un store.

Ejercicio 2: Preferencias persistentes

Crea un store usePreferencias con tema ('claro' u 'oscuro'), densidad ('compacta' o 'normal') y panelAbierto. Debe recordar tema y densidad tras recargar, pero no panelAbierto. El tema debe reflejarse en document.documentElement.dataset.tema.

Ver solución

src/store.ts:

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

type Tema = 'claro' | 'oscuro'
type Densidad = 'compacta' | 'normal'

interface Preferencias {
  tema: Tema
  densidad: Densidad
  panelAbierto: boolean // no se guarda
  setTema: (tema: Tema) => void
  setDensidad: (densidad: Densidad) => void
  alternarPanel: () => void
}

export const usePreferencias = create<Preferencias>()(
  persist(
    (set) => ({
      tema: 'claro',
      densidad: 'normal',
      panelAbierto: false,
      setTema: (tema) => set({ tema }),
      setDensidad: (densidad) => set({ densidad }),
      alternarPanel: () => set((p) => ({ panelAbierto: !p.panelAbierto })),
    }),
    {
      name: 'empresa-preferencias',
      partialize: ({ tema, densidad }) => ({ tema, densidad }),
    },
  ),
)

src/App.tsx. Escribir en document es sincronizar con algo externo a React, así que va en un efecto:

import { useEffect } from 'react'
import { usePreferencias } from './store'

export default function App() {
  const tema = usePreferencias((p) => p.tema)
  const densidad = usePreferencias((p) => p.densidad)
  const panelAbierto = usePreferencias((p) => p.panelAbierto)
  const setTema = usePreferencias((p) => p.setTema)
  const setDensidad = usePreferencias((p) => p.setDensidad)
  const alternarPanel = usePreferencias((p) => p.alternarPanel)

  useEffect(() => {
    document.documentElement.dataset.tema = tema // sincroniza con algo externo a React
  }, [tema])

  return (
    <section className="tarjeta" style={{ padding: densidad === 'compacta' ? 8 : 16 }}>
      <h2>Tema {tema}, densidad {densidad}</h2>
      <div className="fila">
        <button onClick={() => setTema(tema === 'claro' ? 'oscuro' : 'claro')}>Cambiar tema</button>
        <button onClick={() => setDensidad(densidad === 'normal' ? 'compacta' : 'normal')}>
          Cambiar densidad
        </button>
        <button onClick={alternarPanel}>Panel</button>
      </div>
      {panelAbierto && <p>Panel abierto</p>}
    </section>
  )
}

Tras cambiar el tema y abrir el panel, localStorage contiene {"state":{"tema":"oscuro","densidad":"normal"},"version":0}. Al recargar, el tema sigue en oscuro, dataset.tema vale oscuro y el panel está cerrado.

Ejercicio 3: Encuentra el render sobrante

Este código renderiza Fila y Pie en cada cambio de salario, aunque solo muestran el nombre. Hay dos causas distintas. Encuéntralas y corrígelas.

function Fila() {
  const estado = useFicha()
  return <p>{estado.nombre}</p>
}

function Pie() {
  return <small>Ficha de {useFicha.getState().nombre}</small>
}

export default function App() {
  const salario = useFicha((f) => f.salario)
  return (
    <>
      <h2>{salario} €</h2>
      <Fila />
      <Pie />
    </>
  )
}
Ver solución
  1. Fila llama a useFicha() sin selector: se suscribe a todo el estado.
  2. App se suscribe al salario y, al renderizarse, arrastra a sus hijos. Esto afecta también a Pie, que no tiene ninguna suscripción.

Además, Pie tiene otro fallo, que no causa renders de más, sino de menos: getState() en el render no se suscribe, así que si cambia el nombre, Pie mostrará el antiguo.

function Salario() {
  const salario = useFicha((f) => f.salario)
  return <h2>{salario} €</h2>
}

function Fila() {
  const nombre = useFicha((f) => f.nombre)
  return <p>{nombre}</p>
}

function Pie() {
  const nombre = useFicha((f) => f.nombre)
  return <small>Ficha de {nombre}</small>
}

export default function App() {
  return (
    <>
      <Salario />
      <Fila />
      <Pie />
    </>
  )
}

Ejercicio 4: Migrar datos guardados

Tu carrito se guardó con dos formatos antiguos. En la versión 0, era { items: [{ id, titulo, precio }] }. En la versión 1, { lineas: [{ id, titulo, precio, cantidad }] }. La versión actual, la 2, usa productoId y nombre. Escribe migrate de forma que un usuario de cualquier versión conserve su carrito. Pruébalo escribiendo a mano la clave en localStorage.

Ver solución
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

export interface LineaV2 {
  productoId: number
  nombre: string
  precio: number
  cantidad: number
}

interface LineaV1 {
  id: number
  titulo: string
  precio: number
  cantidad: number
}

interface EstadoV0 {
  items: { id: number; titulo: string; precio: number }[]
}

interface EstadoCarrito {
  lineas: LineaV2[]
  vaciar: () => void
}

export const useCarrito = create<EstadoCarrito>()(
  persist(
    (set) => ({ lineas: [], vaciar: () => set({ lineas: [] }) }),
    {
      name: 'empresa-carrito-migrar',
      version: 2,
      migrate: (guardado, versionGuardada) => {
        let estado = guardado as { lineas?: LineaV1[] } & Partial<EstadoV0>

        // De la 0 a la 1: "items" pasa a "lineas" y aparece la cantidad
        if (versionGuardada < 1) {
          estado = { lineas: (estado.items ?? []).map((i) => ({ ...i, cantidad: 1 })) }
        }

        // De la 1 a la 2: id y titulo cambian de nombre
        const lineas = (estado.lineas ?? []).map(({ id, titulo, ...resto }) => ({
          ...resto,
          productoId: id,
          nombre: titulo,
        }))
        return { lineas } as EstadoCarrito
      },
    },
  ),
)

Los pasos se encadenan: un dato de la versión 0 pasa primero a la 1 y luego a la 2. Así, cada cambio de formato añade un paso y no tienes que combinar todas las versiones entre sí.

Resultado real con cada entrada, ya guardado en el nuevo formato:

Entrada v0: {"state":{"items":[{"id":6,"titulo":"Raton","precio":19.9}]},"version":0}
Salida:     {"state":{"lineas":[{"precio":19.9,"cantidad":1,"productoId":6,"nombre":"Raton"}]},"version":2}

Entrada v1: {"state":{"lineas":[{"id":6,"titulo":"Raton","precio":19.9,"cantidad":3}]},"version":1}
Salida:     {"state":{"lineas":[{"precio":19.9,"cantidad":3,"productoId":6,"nombre":"Raton"}]},"version":2}

Ejercicio 5: Avisos desde cualquier sitio

Crea un sistema de avisos (toasts): una función avisar(texto, tipo) que se pueda llamar desde un componente o desde una función normal, y un componente Avisos que los muestre. Cada aviso desaparece a los 3 segundos o al cerrarlo. Provoca un error real borrando el departamento 10 sin sesión.

Ver solución

src/avisos.ts. avisar es una función de módulo, no una acción del store. Podría serlo, pero así se importa directamente y no hay que pasar por getState:

import { create } from 'zustand'

type TipoAviso = 'info' | 'error'

interface Aviso {
  id: number
  texto: string
  tipo: TipoAviso
}

interface EstadoAvisos {
  avisos: Aviso[]
  quitar: (id: number) => void
}

export const useAvisos = create<EstadoAvisos>()((set) => ({
  avisos: [],
  quitar: (id) => set((e) => ({ avisos: e.avisos.filter((a) => a.id !== id) })),
}))

let siguienteId = 1

// Función normal: se puede llamar desde un fetch, un store o un componente
export function avisar(texto: string, tipo: TipoAviso = 'info', duracion = 3000) {
  const id = siguienteId++
  useAvisos.setState((e) => ({ avisos: [...e.avisos, { id, texto, tipo }] }))
  setTimeout(() => useAvisos.getState().quitar(id), duracion)
  return id
}

src/App.tsx:

import { avisar, useAvisos } from './avisos'

function Avisos() {
  const avisos = useAvisos((e) => e.avisos)
  const quitar = useAvisos((e) => e.quitar)
  return (
    <div aria-live="polite" style={{ position: 'fixed', top: 12, right: 12, width: 260 }}>
      {avisos.map((a) => (
        <div key={a.id} className="tarjeta fila separada">
          <span className={a.tipo === 'error' ? 'error' : undefined}>{a.texto}</span>
          <button aria-label="Cerrar aviso" onClick={() => quitar(a.id)}>x</button>
        </div>
      ))}
    </div>
  )
}

// Esta función no es un componente, y aun así puede avisar
async function borrarDepartamento() {
  const respuesta = await fetch('http://localhost:3000/api/departamentos/10', { method: 'DELETE' })
  const datos = await respuesta.json()
  avisar(`${respuesta.status}: ${datos.mensaje}`, 'error')
}

export default function App() {
  return (
    <section className="tarjeta">
      <h2>Avisos</h2>
      <div className="fila">
        <button onClick={() => avisar('Empleado guardado')}>Aviso</button>
        <button onClick={borrarDepartamento}>Provocar un error</button>
      </div>
      <Avisos />
    </section>
  )
}

Dos avisos en la esquina superior derecha: Empleado guardado y 401 Debes iniciar sesión

Figura 10. El segundo aviso lo lanza una función que no es un componente.

A los 3 segundos no queda ningún aviso. aria-live="polite" hace que un lector de pantalla los anuncie, como verás en el módulo 24.

Ejercicio 6: Filtros con createSelector

Crea un slice con una lista de empleados y tres filtros: texto, departamento y orden (nombre o salario). Escribe selectVisibles y selectSalarioMedio con createSelector. El segundo debe partir del resultado del primero.

Ver solución

src/slice.ts:

import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit'

export interface Empleado {
  empId: number
  nombre: string
  oficio: string
  salario: number
  deptId: number
}

export interface EstadoFiltros {
  lista: Empleado[]
  texto: string
  deptId: number | null
  orden: 'nombre' | 'salario'
}

const estadoInicial: EstadoFiltros = { lista: [], texto: '', deptId: null, orden: 'nombre' }

const filtrosSlice = createSlice({
  name: 'filtros',
  initialState: estadoInicial,
  reducers: {
    setLista(estado, accion: PayloadAction<Empleado[]>) {
      estado.lista = accion.payload
    },
    setTexto(estado, accion: PayloadAction<string>) {
      estado.texto = accion.payload
    },
    setDept(estado, accion: PayloadAction<number | null>) {
      estado.deptId = accion.payload
    },
    setOrden(estado, accion: PayloadAction<EstadoFiltros['orden']>) {
      estado.orden = accion.payload
    },
  },
})

export const { setLista, setTexto, setDept, setOrden } = filtrosSlice.actions
export default filtrosSlice.reducer

type EstadoRaiz = { filtros: EstadoFiltros }

export const selectVisibles = createSelector(
  [
    (e: EstadoRaiz) => e.filtros.lista,
    (e: EstadoRaiz) => e.filtros.texto,
    (e: EstadoRaiz) => e.filtros.deptId,
    (e: EstadoRaiz) => e.filtros.orden,
  ],
  (lista, texto, deptId, orden) => {
    const buscado = texto.toLowerCase()
    return lista
      .filter((e) => e.nombre.toLowerCase().includes(buscado))
      .filter((e) => deptId === null || e.deptId === deptId)
      .toSorted((a, b) =>
        orden === 'nombre' ? a.nombre.localeCompare(b.nombre, 'es') : b.salario - a.salario,
      )
  },
)

export const selectSalarioMedio = createSelector([selectVisibles], (visibles) =>
  visibles.length === 0
    ? 0
    : visibles.reduce((suma, e) => suma + e.salario, 0) / visibles.length,
)

src/App.tsx:

import { configureStore } from '@reduxjs/toolkit'
import { useEffect } from 'react'
import { Provider, useDispatch, useSelector } from 'react-redux'
import filtros, {
  selectSalarioMedio,
  selectVisibles,
  setDept,
  setLista,
  setOrden,
  setTexto,
} from './slice'

const store = configureStore({ reducer: { filtros } })
type RootState = ReturnType<typeof store.getState>
const useAppSelector = useSelector.withTypes<RootState>()

function Empleados() {
  const dispatch = useDispatch()
  const texto = useAppSelector((e) => e.filtros.texto)
  const deptId = useAppSelector((e) => e.filtros.deptId)
  const orden = useAppSelector((e) => e.filtros.orden)
  const visibles = useAppSelector(selectVisibles)
  const medio = useAppSelector(selectSalarioMedio)

  useEffect(() => {
    fetch('http://localhost:3000/api/empleados?tamano=100')
      .then((r) => r.json())
      .then((pagina) => dispatch(setLista(pagina.contenido)))
  }, [dispatch])

  return (
    <section className="tarjeta">
      <h2>{visibles.length} empleados, salario medio {medio.toFixed(0)} €</h2>
      <div className="fila">
        <input
          aria-label="Buscar"
          value={texto}
          onChange={(e) => dispatch(setTexto(e.target.value))}
        />
        <select
          aria-label="Departamento"
          value={deptId ?? ''}
          onChange={(e) => dispatch(setDept(e.target.value ? Number(e.target.value) : null))}
        >
          <option value="">Todos</option>
          {[10, 20, 30, 40, 50, 60, 70].map((id) => (
            <option key={id} value={id}>{id}</option>
          ))}
        </select>
        <button onClick={() => dispatch(setOrden(orden === 'nombre' ? 'salario' : 'nombre'))}>
          Orden: {orden}
        </button>
      </div>
      <ul>
        {visibles.slice(0, 5).map((e) => (
          <li key={e.empId}>{e.nombre}: {e.salario} €</li>
        ))}
      </ul>
    </section>
  )
}

export default function App() {
  return (
    <Provider store={store}>
      <Empleados />
    </Provider>
  )
}

Sin filtros muestra 24 empleados, salario medio 2829 €. Con el departamento 30, 6 empleados, salario medio 3133 €, y al ordenar por salario, Marta (5000 €) queda la primera.

toSorted devuelve una copia ordenada. Aquí sort también funcionaría, porque filter ya ha creado un array nuevo, pero toSorted te protege si un día quitas los filtros: sort sobre e.filtros.lista lanzaría un error, porque ese array está congelado. Encadenar selectores así es la gran ventaja de createSelector: selectSalarioMedio no recalcula si selectVisibles devuelve el mismo array.

Estos filtros, en una aplicación real, irían mejor en la URL. Aquí están en Redux para practicar.

Ejercicio 7: Pedidos con thunks

Crea un slice de pedidos con dos thunks: cargarPedidos, que trae los 5 últimos y no debe lanzar una segunda petición si ya hay una en marcha, y cambiarEstado, que hace PATCH /api/pedidos/:id. Despacha cargarPedidos dos veces seguidas y comprueba que solo sale una petición.

Ver solución

src/pedidosSlice.ts:

import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'

const API = 'http://localhost:3000/api'

export interface Pedido {
  pedidoId: number
  cliente: string
  estado: string
  total: number
}

interface EstadoPedidos {
  lista: Pedido[]
  estado: 'inactivo' | 'cargando' | 'listo' | 'error'
  error: string | null
}

const estadoInicial: EstadoPedidos = { lista: [], estado: 'inactivo', error: null }

export const cargarPedidos = createAsyncThunk<
  Pedido[],
  void,
  { state: { pedidos: EstadoPedidos }; rejectValue: string }
>(
  'pedidos/cargar',
  async (_, { rejectWithValue }) => {
    const respuesta = await fetch(`${API}/pedidos?tamano=5&orden=-fechaPedido`)
    if (!respuesta.ok) return rejectWithValue(`Error ${respuesta.status}`)
    const pagina = await respuesta.json()
    return pagina.contenido
  },
  {
    // Se evalúa antes de lanzar nada: si devuelve false, el thunk no hace ni el pending
    condition: (_, { getState }) => getState().pedidos.estado !== 'cargando',
  },
)

export const cambiarEstado = createAsyncThunk<
  Pedido,
  { pedidoId: number; estado: string; token: string },
  { rejectValue: string }
>('pedidos/cambiarEstado', async ({ pedidoId, estado, token }, { rejectWithValue }) => {
  const respuesta = await fetch(`${API}/pedidos/${pedidoId}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
    body: JSON.stringify({ estado }),
  })
  const datos = await respuesta.json()
  if (!respuesta.ok) return rejectWithValue(datos.mensaje)
  return datos
})

const pedidosSlice = createSlice({
  name: 'pedidos',
  initialState: estadoInicial,
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(cargarPedidos.pending, (estado) => {
        estado.estado = 'cargando'
      })
      .addCase(cargarPedidos.fulfilled, (estado, accion) => {
        estado.estado = 'listo'
        estado.lista = accion.payload
      })
      .addCase(cargarPedidos.rejected, (estado, accion) => {
        estado.estado = 'error'
        estado.error = accion.payload ?? 'Error'
      })
      .addCase(cambiarEstado.fulfilled, (estado, accion) => {
        const pedido = estado.lista.find((p) => p.pedidoId === accion.payload.pedidoId)
        if (pedido) pedido.estado = accion.payload.estado
      })
      .addCase(cambiarEstado.rejected, (estado, accion) => {
        estado.error = accion.payload ?? 'Error'
      })
  },
})

export default pedidosSlice.reducer

src/App.tsx:

import { configureStore } from '@reduxjs/toolkit'
import { useEffect } from 'react'
import { Provider, useDispatch, useSelector } from 'react-redux'
import pedidos, { cambiarEstado, cargarPedidos } from './pedidosSlice'

const store = configureStore({ reducer: { pedidos } })
type RootState = ReturnType<typeof store.getState>
type AppDispatch = typeof store.dispatch

function Pedidos() {
  const dispatch = useDispatch<AppDispatch>()
  const { lista, estado, error } = useSelector((e: RootState) => e.pedidos)

  useEffect(() => {
    void dispatch(cargarPedidos())
    void dispatch(cargarPedidos()) // el segundo lo descarta condition
  }, [dispatch])

  async function cancelar(pedidoId: number) {
    const login = await fetch('http://localhost:3000/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email: 'ana.garcia@empresa.es', password: 'secreto' }),
    }).then((r) => r.json())
    await dispatch(cambiarEstado({ pedidoId, estado: 'CANCELADO', token: login.token }))
  }

  return (
    <section className="tarjeta">
      <h2>Últimos pedidos ({estado})</h2>
      {error && <p className="error">{error}</p>}
      <table>
        <tbody>
          {lista.map((p) => (
            <tr key={p.pedidoId}>
              <td>{p.pedidoId}</td>
              <td>{p.cliente}</td>
              <td>{p.estado}</td>
              <td className="num">
                <button disabled={p.estado === 'CANCELADO'} onClick={() => cancelar(p.pedidoId)}>
                  Cancelar
                </button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </section>
  )
}

export default function App() {
  return (
    <Provider store={store}>
      <Pedidos />
    </Provider>
  )
}

Peticiones reales, aunque el componente despacha dos veces y Strict Mode repite el efecto, tras pulsar un Cancelar:

GET   /api/pedidos?tamano=5&orden=-fechaPedido
POST  /api/auth/login
PATCH /api/pedidos/5024

La fila pasa a CANCELADO sin volver a pedir la lista, porque cambiarEstado.fulfilled actualiza el pedido con la respuesta del servidor. El login dentro de cancelar es solo para que el ejemplo se pueda probar: en una aplicación real, el token saldría del store.

Ejercicio 8: Lista infinita con RTK Query

Muestra los empleados de 5 en 5 ordenados por apellidos, con un botón Cargar más, usando build.infiniteQuery. Compáralo con useInfiniteQuery del módulo 19.

Ver solución

src/api.ts:

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export interface Empleado {
  empId: number
  nombre: string
  apellidos: string
}

interface Pagina<T> {
  contenido: T[]
  pagina: number
  paginas: number
}

export const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: 'http://localhost:3000/api' }),
  endpoints: (build) => ({
    // <resultado de una página, argumento de la consulta, tipo del parámetro de página>
    empleados: build.infiniteQuery<Pagina<Empleado>, string, number>({
      infiniteQueryOptions: {
        initialPageParam: 1,
        getNextPageParam: (ultima) =>
          ultima.pagina < ultima.paginas ? ultima.pagina + 1 : undefined,
      },
      query: ({ queryArg, pageParam }) =>
        `/empleados?q=${encodeURIComponent(queryArg)}&pagina=${pageParam}&tamano=5&orden=apellidos`,
    }),
  }),
})

export const { useEmpleadosInfiniteQuery } = api

src/App.tsx:

import { configureStore } from '@reduxjs/toolkit'
import { Provider } from 'react-redux'
import { api, useEmpleadosInfiniteQuery } from './api'

const store = configureStore({
  reducer: { [api.reducerPath]: api.reducer },
  middleware: (getDefault) => getDefault().concat(api.middleware),
})

function Lista() {
  const { data, fetchNextPage, hasNextPage, isFetching } = useEmpleadosInfiniteQuery('')
  const empleados = data?.pages.flatMap((p) => p.contenido) ?? []
  return (
    <section className="tarjeta">
      <h2>{empleados.length} empleados cargados</h2>
      <ul>
        {empleados.map((e) => (
          <li key={e.empId}>{e.apellidos}, {e.nombre}</li>
        ))}
      </ul>
      <button disabled={!hasNextPage || isFetching} onClick={() => fetchNextPage()}>
        {hasNextPage ? 'Cargar más' : 'No hay más'}
      </button>
    </section>
  )
}

export default function App() {
  return (
    <Provider store={store}>
      <Lista />
    </Provider>
  )
}

Tras pulsar Cargar más hay 10 empleados, y las peticiones son pagina=1 y pagina=2. Igual que en TanStack Query, data tiene pages y el componente las aplana. La diferencia es que query recibe un objeto con queryArg y pageParam.

Ejercicio 9: Probar sin React

Instala Vitest (npm install -D vitest) y prueba el reductor del carrito del apartado 10, el store real con su selector, y un store de Zustand. No debes renderizar nada. El módulo 22 cubre las pruebas a fondo.

Ver solución

src/carrito.test.ts:

import { describe, expect, it } from 'vitest'
import { configureStore } from '@reduxjs/toolkit'
import { create } from 'zustand'
import reductor, {
  agregar,
  quitar,
  selectTotal,
  vaciar,
} from './carritoSlice'

const raton = { productoId: 6, nombre: 'Ratón inalámbrico', precio: 19.9 }

describe('reductor del carrito', () => {
  it('es una función pura: estado y acción entran, estado sale', () => {
    const inicial = reductor(undefined, { type: '@@INIT' })
    const siguiente = reductor(inicial, agregar(raton))
    expect(inicial.lineas).toHaveLength(0) // el estado anterior no cambia
    expect(siguiente.lineas).toEqual([{ ...raton, cantidad: 1 }])
  })

  it('suma cantidad si el producto ya está', () => {
    const estado = [agregar(raton), agregar(raton)].reduce(reductor, undefined as never)
    expect(estado.lineas[0].cantidad).toBe(2)
  })

  it('quita y vacía', () => {
    const conUno = reductor(undefined, agregar(raton))
    expect(reductor(conUno, quitar(6)).lineas).toEqual([])
    expect(reductor(conUno, vaciar()).lineas).toEqual([])
  })
})

describe('store real', () => {
  it('calcula el total con el selector', () => {
    const store = configureStore({ reducer: { carrito: reductor } })
    store.dispatch(agregar(raton))
    store.dispatch(agregar(raton))
    expect(selectTotal(store.getState())).toBeCloseTo(39.8)
  })
})

describe('Zustand sin React', () => {
  const useContador = create<{ n: number; sumar: () => void }>()((set) => ({
    n: 0,
    sumar: () => set((e) => ({ n: e.n + 1 })),
  }))

  it('el estado inicial permite reiniciar entre pruebas', () => {
    const inicial = useContador.getInitialState()
    useContador.getState().sumar()
    expect(useContador.getState().n).toBe(1)
    useContador.setState(inicial, true)
    expect(useContador.getState().n).toBe(0)
  })
})
Tests  5 passed (5)

Un reductor es la lógica más fácil de probar que existe: una función pura sin React, sin red y sin simulaciones. En Zustand, recuerda que el store es de módulo y sobrevive entre pruebas: reinícialo con setState(getInitialState(), true). El true sustituye todo el estado en lugar de fusionarlo.

Ejercicio 10 (propuesto): Confirmar el pedido

Une el carrito del apartado 4 y la sesión del apartado 6. Añade un botón Confirmar pedido que haga POST /api/pedidos con { clienteId: 1001, lineas: [{ productoId, cantidad }] }. Usa pedirApi y, si todo va bien, vacía el carrito. Muestra los errores 409 de falta de stock.

Ejercicio 11 (propuesto): De Zustand a Redux Toolkit

Reescribe el carrito completo del apartado 4 con Redux Toolkit, incluida la persistencia: escribe en localStorage con store.subscribe y recupera el estado con preloadedState en configureStore. Cuenta las líneas de cada versión y anota qué te ha resultado más claro en cada una.

Ejercicio 12 (propuesto): API completa con etiquetas

Amplía empresaApi con un empleados que acepte deptId, y crearEmpleado y borrarEmpleado. Diseña las etiquetas para que, al borrar un empleado, se actualicen su lista y el número de empleados de su departamento, sin recargar nada más.


Resumen

Concepto API Idea clave
Clasificar el estado Servidor, URL, formulario, local, global La mayoría de los datos no necesitan un store
Límite de Context <Contexto value> Todos los consumidores se renderizan, lean o no el dato que cambia
Store de Zustand create<T>()((set, get) => ...) Un hook sin proveedor; estado y acciones juntos
Selector useStore((e) => e.dato) Compara con Object.is: solo renderiza si cambia el resultado
Varios datos useShallow Sin él, un objeto nuevo en el selector crea un bucle infinito
Persistencia persist, partialize, version, migrate Sube la versión con una migración o el usuario pierde sus datos
Fuera de React getState, setState, subscribe Para funciones y pruebas; en el render, siempre el hook
Organización Varios stores, StateCreator Mejor varios stores pequeños que uno enorme
Store por árbol createStore, useStore Varias instancias, props iniciales y SSR
Flujo de Redux Acción, reductor, store Las acciones describen qué ha pasado; solo los reductores cambian el estado
Slice createSlice, PayloadAction Reductores y acciones juntos; Immer permite escribir como si se mutara
Store de Redux configureStore, Provider Trae thunks y comprobaciones en desarrollo
Hooks tipados useDispatch.withTypes, useSelector.withTypes Se definen una vez
Memorización createSelector Un filter en un selector da renders de más
Normalización createEntityAdapter ids y entities, con reductores y selectores ya hechos
Peticiones con thunks createAsyncThunk, extraReducers Tres acciones: pending, fulfilled, rejected
Caché de servidor createApi, fetchBaseQuery Hooks generados; una petición aunque haya varios lectores
Invalidación providesTags, invalidatesTags Las mutaciones dicen qué ha cambiado, no qué recargar
Optimismo onQueryStarted, updateQueryData, undo Cambia la caché y deshaz si falla
Elección Context, Zustand, Redux Toolkit Context si cambia poco, Zustand por defecto, RTK para lo complejo