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,setStateysubscribe - Componer los middlewares
immer,devtools,persistysubscribeWithSelector, y dividir un store en trozos - Crear un store por rama del árbol con
createStorey Context - Comprender el flujo de Redux: acción, middleware, reductor, store y selector
- Escribir Redux moderno con
configureStore,createSlicey hooks tipados - Memorizar selectores con
createSelectory normalizar datos concreateEntityAdapter - Hacer peticiones con
createAsyncThunky 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
peerDependenciespidenreact >= 18en Zustand y^18 || ^19enreact-redux9, 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.
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:

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:
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.setfusiona el objeto que le pasas con el estado actual, igual que un{ ...estado, ...cambios }. Por esoset({ valor: 0 })no borrasumar. La fusión es de un solo nivel: los objetos anidados los tienes que copiar tú.setadmite 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,
setStatey una lista de oyentes. El hook usauseSyncExternalStore, 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 conObject.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:

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,
Appleí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 componenteCabecera. 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
createsolo recibe el selector: no admite una función de igualdad como segundo argumento. Si encuentras código conuseStore(selector, shallow), no te servirá. Quien necesite comparar a su manera tienecreateWithEqualityFnenzustand/traditional, perouseShallowcubre 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>
</>
)
}

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
localStorageen 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 esskipHydration: truey llamar arehydrate()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
localStorageosessionStoragelo 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 cookieHttpOnly. Para esta API de ejemplo, que usaAuthorization: Bearer,sessionStoragees 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, porquecreateStoreyuseStoreno 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:
- Una única fuente de verdad. Todo el estado está en un solo árbol, dentro de un único store.
- El estado es de solo lectura. La única forma de cambiarlo es despachar una acción, un objeto que describe qué ha pasado.
- Los cambios los hacen funciones puras. Un reductor recibe el estado y la acción, y devuelve el estado siguiente.
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/cerradapuede 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 unProxypoco útil. Usacurrent(estado), que se importa de@reduxjs/toolkity 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>
)
}

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 enaccion.payload, en lugar del error serializado deaccion.error.signal: unAbortSignalpara pasarlo afetch, como en el módulo 09.condition: función que se evalúa antes de empezar. Si devuelvefalse, el thunk no se ejecuta ni despacha nada. Lo usas en el ejercicio 7.dispatch(thunk(...)).unwrap(): devuelve una promesa que da elpayloado lanza el error. Sirve para reaccionar en el componente:await dispatch(guardar(datos)).unwrap().createAsyncThunk.withTypes: fija los tipos de estado ydispatchuna 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.
createAsyncThunksigue 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>
)
}

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.
guardarDepartamentoactualiza la caché conupdateQueryDatay no invalida ninguna etiqueta: no hace falta volver a pedir la lista. - El POST sí provoca un GET:
crearDepartamentoinvalida la etiquetaLISTA.
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.
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
idborrado.
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:
- 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.
- Lo que queda cambia poco: usa Context.
- Lo que queda cambia a menudo: usa Zustand. Es la opción por defecto para un proyecto nuevo.
- Mucho estado de cliente, con reglas complejas, o un equipo grande que necesita una estructura impuesta y trazabilidad: usa Redux Toolkit.
- 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
Filallama auseFicha()sin selector: se suscribe a todo el estado.Appse suscribe al salario y, al renderizarse, arrastra a sus hijos. Esto afecta también aPie, 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>
)
}

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 |