La lección anterior terminó con una pregunta abierta. useFiltroResponsable funcionaba porque el filtro vivía en App y desde ahí bajaba a los dos componentes que lo necesitaban. Pero en cuanto ese mismo filtro lo necesitan también el enrutador, la cabecera, el informe y un botón enterrado a seis niveles de profundidad, empiezan los problemas: props que atraviesan componentes que no las usan, copias del mismo dato en dos sitios que se desincronizan, y un flujo de datos que ya no cabe en la cabeza. Esta lección trata de eso, y no empieza por Redux: empieza por el problema, sigue por las alternativas más baratas —elevar el estado, Context, un almacén externo— y solo llega a Redux cuando las anteriores se quedan cortas, porque la mayoría de las aplicaciones no necesitan Redux y decirlo es parte de enseñarlo bien. Verás los tres principios de Redux y el ciclo acción → reductor → nuevo estado → render, con el paralelismo directo con tu Tablero de caché por versión de 09-02 y tus CustomEvent de 06-04; aprenderás Redux Toolkit, que es la forma correcta y actual de usarlo, con configureStore, createSlice, Immer, useSelector/useDispatch y los selectores memoizados de createSelector; resolverás la lógica asíncrona con createAsyncThunk y los tres estados de una petición aplicados a tu listarTareas de 07-02, y conocerás RTK Query como la herramienta específica para estado de servidor; verás las DevTools con su viaje en el tiempo y por qué la trazabilidad es la ventaja real; normalizarás 600 tareas con createEntityAdapter; y terminarás con los errores clásicos y una tabla honesta de alternativas ligeras. Todo aplicado al filtro y al tablero de Nómada Tareas.

Contenido

  1. El problema antes de la herramienta
  2. Prop drilling: el filtro que atraviesa seis componentes
  3. Estado duplicado y desincronización
  4. Las alternativas por orden de coste
  5. Alternativa 1: elevar el estado
  6. Alternativa 2: Context de React
  7. Qué es Context y qué no es
  8. Alternativa 3: un almacén externo
  9. Cuándo cada una es suficiente
  10. Los tres principios de Redux
  11. El ciclo acción → reductor → nuevo estado → render
  12. El paralelismo con tu Tablero y tus CustomEvent
  13. Redux Toolkit: por qué nadie escribe Redux a mano
  14. configureStore: el almacén
  15. createSlice: acciones y reductores juntos
  16. Immer: escribir mutaciones que producen estado inmutable
  17. useSelector y useDispatch
  18. Selectores memoizados con createSelector
  19. Lógica asíncrona con createAsyncThunk
  20. Los tres estados de una petición
  21. RTK Query: la herramienta para el estado de servidor
  22. Las DevTools y el viaje en el tiempo
  23. Normalizar datos con createEntityAdapter
  24. Nómada Tareas: el filtro y el tablero completos
  25. Alternativas ligeras: Zustand, Jotai y los almacenes nativos
  26. Errores Comunes y Consejos
  27. Ejercicios
  28. Conclusión

  1. El problema antes de la herramienta

Redux tiene mala fama, y se la ganó. Durante años fue la respuesta automática a la pregunta «¿cómo gestiono el estado?», incluso cuando la pregunta correcta era «¿de verdad necesito gestionar estado global?». Se escribieron miles de aplicaciones con tres carpetas de boilerplateactions/, reducers/, constants/— para guardar si un modal estaba abierto.

Esa época pasó. Redux moderno, a través de Redux Toolkit, es mucho más conciso, y —más importante— la industria aprendió a distinguir cuándo hace falta y cuándo no. Esta lección respeta ese aprendizaje: primero el problema, después las alternativas baratas, y Redux al final, cuando se ha ganado el sitio.

El problema tiene tres caras, y conviene verlas con el filtro de responsable de Nómada Tareas, que es un caso perfectamente realista.

  1. Prop drilling: el filtro que atraviesa seis componentes

Imagina que Nómada Tareas ha crecido. La pantalla del tablero tiene esta estructura de componentes:

graph TD
  App --> Cabecera
  App --> Panel
  App --> PieDePagina
  Cabecera --> FiltroResponsable
  Cabecera --> ResumenCabecera
  Panel --> ListaTareas
  Panel --> BarraLateral
  ListaTareas --> TarjetaTarea
  BarraLateral --> InformeCarga
  PieDePagina --> ContadorVisibles

El filtro de responsable lo cambia FiltroResponsable, y lo necesitan:

  • ListaTareas, para filtrar.
  • ResumenCabecera, para decir «3 de 6 tareas · 25 h».
  • InformeCarga, para destacar a la persona filtrada.
  • ContadorVisibles, en el pie.
  • El enrutador, para reflejar el filtro en la URL (?responsable=Ivan) como hiciste con la History API en 07-06.

Si el estado vive en App —que es el ancestro común—, hay que pasarlo hacia abajo:

// ❌ El filtro atraviesa componentes a los que no les importa
function App() {
  const [responsable, setResponsable] = useState(null);

  return (
    <>
      <Cabecera responsable={responsable} alCambiar={setResponsable} />
      <Panel responsable={responsable} />
      <PieDePagina responsable={responsable} />
    </>
  );
}

function Cabecera({ responsable, alCambiar }) {
  // Cabecera no usa `responsable` para nada propio: solo lo transporta
  return (
    <header>
      <FiltroResponsable valor={responsable} alCambiar={alCambiar} />
      <ResumenCabecera responsable={responsable} />
    </header>
  );
}

function Panel({ responsable }) {
  // Panel tampoco lo usa: lo transporta
  return (
    <div className="panel">
      <ListaTareas responsable={responsable} />
      <BarraLateral responsable={responsable} />
    </div>
  );
}

A eso se le llama prop drilling: perforar el árbol de componentes con una prop que solo interesa en las hojas. Sus costes son concretos:

Coste Qué significa
Ruido Cabecera y Panel tienen en su firma una prop que no usan. Su contrato miente sobre lo que hacen
Cambios en cascada Añadir un segundo filtro (por prioridad) obliga a tocar los seis componentes intermedios
Reutilización rota Panel ya no se puede usar en otra pantalla sin inventarle un responsable
Renderizados de más Cambiar el filtro vuelve a renderizar Cabecera y Panel enteros, aunque solo cambie lo de dentro
Pruebas más pesadas Probar ListaTareas obliga a saber por dónde le llega la prop

Un matiz honesto que casi nunca se menciona: con dos o tres niveles, el prop drilling no es un problema. Es explícito, se lee bien, y no necesita ninguna herramienta. El problema aparece con profundidad real y con varios datos compartidos a la vez. No corras a instalar nada por pasar una prop dos niveles.

  1. Estado duplicado y desincronización

El segundo síntoma es más grave, y es el problema 1 de 10-01 reaparecido. Cuando pasar la prop se hace incómodo, la tentación es que cada componente guarde su propia copia:

// ❌ Dos copias del mismo dato
function ResumenCabecera() {
  const [responsable, setResponsable] = useState(null);   // copia 1
  // …
}

function ListaTareas() {
  const [responsable, setResponsable] = useState(null);   // copia 2
  // …
}

Ahora hay dos fuentes de verdad para un solo hecho. En cuanto una cambia sin la otra —y ocurrirá—, la cabecera dice «3 de 6 tareas» mientras la lista muestra las seis. El usuario ve una pantalla que se contradice a sí misma.

Este síntoma tiene una variante más sutil y muy frecuente: guardar valores derivados. Si además de responsable guardas visibles y horasVisibles como estado, tienes tres cosas que sincronizar en lugar de una que se calcula. Es exactamente el error del apartado 16 de 10-02, elevado a global.

La regla que resuelve las dos caras: para cada hecho de la aplicación debe haber un solo sitio donde vive, y todo lo demás se calcula a partir de él. La pregunta que queda es dónde está ese sitio, y ahí es donde hay que elegir herramienta.

  1. Las alternativas por orden de coste

Esta es la tabla más importante de la lección. Léela de arriba abajo y quédate en la primera fila que resuelva tu problema.

# Alternativa Coste Resuelve Se queda corta cuando…
0 Nada: dejarlo local Cero El 70 % de los casos Dos componentes hermanos necesitan el mismo dato
1 Elevar el estado al ancestro común Cero Casi todo lo demás El ancestro está a cinco niveles y las props atraviesan componentes ajenos
2 Context de React Bajo El transporte: elimina el prop drilling Hay muchas escrituras y todo lo que consume el contexto se repinta
3 Almacén externo ligero (Zustand, Jotai) Medio Transporte + suscripción selectiva Hace falta trazabilidad, middleware, o disciplina de equipo
4 Redux Toolkit Alto Todo lo anterior + trazabilidad, DevTools, convenciones Casi nunca se queda corto; el problema es que sobra
Librería de datos (TanStack Query, RTK Query) Medio El estado de servidor, que es otro problema No sustituye al estado de interfaz

La última fila es la trampa en la que cae medio sector. Antes de decidir qué almacén usar, decide qué estado tienes. Si haces el inventario de una aplicación real, el reparto suele ser este:

Tipo de estado Proporción típica Dónde debe vivir
De servidor (listas, detalles, catálogos) ~60 % Caché de datos (RTK Query, TanStack Query)
Local de un componente (abierto/cerrado, texto en curso) ~25 % useState
De URL (filtros, página, ordenación) ~10 % La URL, con el enrutador
Verdaderamente global (usuario, tema, permisos) ~5 % Un almacén compartido

Ese 5 % es el territorio de Redux. Si has hecho bien el reparto, el almacén global de una aplicación grande es sorprendentemente pequeño. Cuando alguien dice «Redux es demasiado boilerplate para esto», casi siempre lo que ocurre es que está metiendo en Redux el 60 % que no le corresponde.

Detalle importante para Nómada Tareas: el filtro de responsable pertenece a la fila del estado de URL. Que Marta pueda enviar por chat el enlace ?responsable=Ivan y su compañero vea lo mismo es una funcionalidad, no un capricho. La solución más barata para el filtro no es Redux ni Context: es la URL, que ya sabes manejar desde 07-06. Lo usaremos igualmente como ejemplo porque ilustra bien el mecanismo, pero conviene tenerlo presente.

  1. Alternativa 1: elevar el estado

Es lo que ya hiciste en 10-02 y en tu app.js de JavaScript puro con el objeto estado. El dato sube al ancestro común más cercano de todos los que lo necesitan.

A favor: coste cero, flujo explícito, cualquiera que lea el código ve de dónde viene cada dato, no hay dependencias nuevas.

En contra: cuando el ancestro común es la raíz y hay cinco niveles de por medio, aparece el prop drilling del apartado 2.

Antes de descartarla, hay una técnica que la lleva mucho más lejos de lo que la gente cree: componer con children en lugar de perforar.

// En lugar de pasar `responsable` a Panel para que lo pase a ListaTareas…
function App() {
  const [responsable, setResponsable] = useState(null);

  return (
    <>
      <Cabecera>
        <FiltroResponsable valor={responsable} alCambiar={setResponsable} />
        <ResumenCabecera responsable={responsable} />
      </Cabecera>

      <Panel
        lista={<ListaTareas responsable={responsable} />}
        lateral={<InformeCarga responsable={responsable} />}
      />
    </>
  );
}

function Panel({ lista, lateral }) {
  // Panel ya no sabe nada de `responsable`: solo coloca lo que le dan
  return <div className="panel">{lista}<aside>{lateral}</aside></div>;
}

El elemento se crea en App, donde vive el estado, y se coloca en Panel, que solo aporta la estructura. Panel recupera su firma honesta y su reutilización. Esta técnica elimina una cantidad enorme de prop drilling sin ninguna librería, y por eso está antes que Context en la lista de costes.

  1. Alternativa 2: Context de React

Context permite que un componente ponga un valor a disposición de todo su subárbol, sin pasarlo por las props intermedias.

// src/contexto/FiltroContexto.jsx
import { createContext, useContext, useState, useMemo } from 'react';

const FiltroContexto = createContext(null);

export function ProveedorFiltro({ children }) {
  const [responsable, setResponsable] = useState(null);

  // El valor se memoiza: si no, sería un objeto nuevo en cada render
  // y TODOS los consumidores se repintarían siempre.
  const valor = useMemo(() => ({ responsable, setResponsable }), [responsable]);

  return <FiltroContexto.Provider value={valor}>{children}</FiltroContexto.Provider>;
}

/** Hook de acceso, con comprobación de uso correcto. */
export function useFiltro() {
  const contexto = useContext(FiltroContexto);
  if (contexto === null) {
    throw new Error('useFiltro debe usarse dentro de <ProveedorFiltro>');
  }
  return contexto;
}

Y el consumo, desde cualquier profundidad:

function ResumenCabecera({ tareas }) {
  const { responsable } = useFiltro();     // sin props intermedias
  const visibles = responsable === null ? tareas : tareas.filter((t) => t.responsable === responsable);
  return <p>{visibles.length} de {tareas.length} tareas</p>;
}

Los componentes intermedios (Cabecera, Panel) recuperan su firma limpia. El prop drilling desaparece.

  1. Qué es Context y qué no es

Aquí está el malentendido más extendido de todo React, y merece ser explícito:

Context es un mecanismo de transporte, no un gestor de estado.

Context resuelve «cómo hago llegar este valor hasta allí abajo». No resuelve «cómo organizo los cambios», «cómo evito renderizados innecesarios» ni «cómo sé quién cambió qué». El estado sigue viviendo en un useState corriente; Context solo evita las escaleras de props.

Y tiene una limitación técnica con consecuencias reales: cuando el valor del contexto cambia, se vuelven a renderizar todos los componentes que lo consumen, sin importar qué parte del valor usen.

// Un contexto con varias cosas dentro
const valor = useMemo(() => ({ usuario, tema, filtro, setFiltro }), [usuario, tema, filtro]);

Si cambia filtro, se repinta también el componente que solo lee tema. Con un contexto pequeño y cambios poco frecuentes (el usuario identificado, el idioma, el tema claro/oscuro) es completamente irrelevante. Con un contexto que cambia en cada pulsación de tecla y cuarenta consumidores, es un problema de rendimiento medible.

Los dos paliativos habituales:

  1. Partir en varios contextos por frecuencia de cambio: uno para lo que casi nunca cambia (usuario, tema) y otro para lo que cambia mucho (filtros). Es la misma idea que el manualChunks por frecuencia de cambio de 09-05.
  2. Separar valor y acciones en dos contextos: los componentes que solo despachan acciones no se repintan cuando cambia el valor, porque las funciones son estables.
Context sirve para… Context NO sirve para…
Valores estables: usuario, tema, idioma, configuración Estado que cambia muchas veces por segundo
Inyectar dependencias (un cliente de API, un servicio) Suscripción selectiva a un trozo del estado
Evitar el prop drilling Trazabilidad de quién cambió qué y cuándo
Aplicaciones pequeñas y medianas Lógica compleja con efectos derivados

La conclusión práctica: Context + useState cubre a la perfección la inmensa mayoría de aplicaciones. Si esa combinación te llega, no necesitas nada más y este es el final de la lección para ti. Los apartados siguientes explican qué pasa cuando no llega.

  1. Alternativa 3: un almacén externo

Un almacén (store) es un objeto que vive fuera del árbol de componentes, guarda el estado, permite suscribirse a él y notifica a los suscriptores cuando cambia.

La diferencia decisiva con Context es la suscripción selectiva: cada componente declara qué trozo del estado le interesa, y solo se vuelve a renderizar si ese trozo concreto cambia. Es, conceptualmente, la diferencia entre difundir a todos y notificar a los interesados — y si te suena, es porque es exactamente la diferencia entre el DOM virtual y las señales de 10-01, aplicada al estado en lugar de a la pantalla.

Y algo que se aprecia más tarde: como el almacén vive fuera de React, se puede usar desde fuera de React. Tu CanalTablero de 07-04 puede despachar acciones al recibir un mensaje del WebSocket sin estar dentro de ningún componente. Un service worker puede consultarlo. Una prueba puede montarlo sin renderizar nada.

Redux es un almacén de este tipo, con tres decisiones muy concretas encima. Vamos a ellas.

  1. Cuándo cada una es suficiente

Antes de entrar en Redux, la tabla de decisión que evita el 90 % de los errores:

Situación Herramienta suficiente
Un desplegable abierto en una tarjeta useState local
El filtro compartido por dos hermanos Elevar el estado
El filtro compartido por cinco componentes en dos ramas Composición con children, o Context
Filtro que debe poder compartirse por enlace La URL y el enrutador (07-06)
Tema claro/oscuro, idioma, usuario identificado Context
Lista de tareas del servidor Caché de datos (RTK Query, TanStack Query)
Carrito de la compra con reglas, descuentos e historial Almacén externo
Editor colaborativo con deshacer/rehacer Redux (el viaje en el tiempo es literalmente esto)
Aplicación grande, equipo de seis, tres años de vida Redux Toolkit, por la disciplina y la trazabilidad
Depurar «el estado se corrompe y no sé quién lo toca» Redux, sin duda: es su ventaja real

  1. Los tres principios de Redux

Redux se define por tres reglas. No son arbitrarias: cada una compra una propiedad concreta.

Principio 1 · Fuente única de verdad. Todo el estado de la aplicación vive en un único objeto, dentro de un único almacén.

  • Lo que compra: no hay copias que desincronizar, el estado completo se puede volcar a un fichero, guardar en localStorage (07-01), enviar en un informe de error o restaurar tal cual.

Principio 2 · El estado es de solo lectura. La única forma de cambiarlo es despachar una acción: un objeto plano que describe qué ha pasado.

{ type: 'filtro/responsableCambiado', payload: 'Iván' }
{ type: 'tareas/estadoCambiado', payload: { id: 6, destino: 'en-curso' } }
  • Lo que compra: todos los cambios pasan por un solo punto. Se pueden registrar, grabar, reproducir y revertir. Nadie puede cambiar el estado a escondidas.

Fíjate en el vocabulario: la acción describe qué ha pasado (responsableCambiado), no qué hacer (cambiarResponsable). Es una convención con consecuencias: una sola acción puede afectar a varias partes del estado, y el registro de acciones se lee como una crónica de lo que hizo la usuaria.

Principio 3 · Los cambios se hacen con funciones puras. Un reductor es una función (estadoActual, accion) => estadoNuevo. Pura: sin efectos, sin peticiones, sin Math.random(), sin Date.now(), sin mutar la entrada.

function reductorFiltro(estado = { responsable: null }, accion) {
  switch (accion.type) {
    case 'filtro/responsableCambiado':
      return { ...estado, responsable: accion.payload };   // objeto NUEVO (04-07)
    default:
      return estado;
  }
}
  • Lo que compra: la misma secuencia de acciones produce siempre el mismo estado. Eso es lo que hace posible el viaje en el tiempo, la reproducción de errores y las pruebas triviales.

Y sí, la palabra «reductor» es la de 04-05: la firma (acumulado, elemento) => nuevoAcumulado es idéntica a la de reduce. El estado de tu aplicación es literalmente el resultado de reducir el historial de acciones sobre un estado inicial.

  1. El ciclo acción → reductor → nuevo estado → render

graph TD
  U["Usuaria pulsa el filtro"] --> D["dispatch de la acción<br/>type: filtro/responsableCambiado<br/>payload: Iván"]
  D --> M["Middleware<br/>(registro, asincronía, DevTools)"]
  M --> R["Reductores<br/>(estadoActual, accion) =&gt; estadoNuevo"]
  R --> S["Almacén: estado nuevo"]
  S --> N["Notificación a los suscriptores"]
  N --> C["Los componentes cuyo trozo cambió<br/>se vuelven a renderizar"]
  C --> U

Cinco propiedades de este ciclo que conviene fijar:

  1. Es unidireccional. Los datos van siempre en el mismo sentido. No hay atajos: un componente no puede escribir en el estado directamente.
  2. Es síncrono en su núcleo. dispatch → reductores → estado nuevo ocurre de una vez. La asincronía se maneja fuera, en el middleware (apartado 19).
  3. Es serializable. Acciones y estado son datos planos, así que se pueden guardar, enviar y reproducir.
  4. Es auditable. Cada cambio tiene un nombre y unos datos. Ninguno es anónimo.
  5. Es interceptable. El middleware puede registrar, retrasar, cancelar o transformar acciones sin tocar reductores ni componentes.

  1. El paralelismo con tu Tablero y tus CustomEvent

Ahora la parte que hace que todo esto no suene abstracto. Ya has construido las tres piezas de este ciclo, por separado y con otros nombres.

Pieza de Redux Tu equivalente en Nómada Tareas Lección
Almacén con estado único El objeto estado = { tablero, filtros, orden } de app.js 06-06
Acción (qué ha pasado) El CustomEvent con EVENTOS.TAREA_AVANZADA y su detail 06-04
dispatch emitir(EVENTOS.TAREA_AVANZADA, { id }) 06-04
Suscripción addEventListener sobre el contenedor 06-04
Reductor El manejador que aplica el cambio y llama a render() 06-06
Invalidación de derivados #version += 1 en cada mutación del Tablero 09-02
Selector memoizado La caché { version, hoy, valor } de resumen() 09-02

La equivalencia de las dos últimas filas es especialmente exacta. Tu Tablero hace esto:

// js/modelo/tablero.js — 09-02
resumen(hoy = HOY) {
  if (this.#cacheResumen?.version === this.#version && this.#cacheResumen.hoy === hoy) {
    return this.#cacheResumen.valor;         // acierto de caché: cero trabajo
  }
  const valor = this.#calcularResumen(hoy);
  this.#cacheResumen = { version: this.#version, hoy, valor };
  return valor;
}

Y createSelector de Redux hace exactamente lo mismo, con una diferencia: donde tú comparas un contador de versión, él compara las referencias de las entradas. Como el estado es inmutable, si la referencia no ha cambiado, el contenido tampoco. La inmutabilidad hace innecesario el contador de versión: la referencia es el contador.

Y hay una diferencia real a favor de Redux que conviene reconocer. Tus CustomEvent son un mecanismo de difusión: cualquiera puede escuchar, no hay registro central de quién escucha qué, y para seguir el flujo hay que buscar la cadena 'tarea:avanzada' por todo el proyecto. Redux impone que todos los cambios pasen por un punto que se puede observar. Esa es la ventaja real, y no es de rendimiento: es de trazabilidad.

  1. Redux Toolkit: por qué nadie escribe Redux a mano

El Redux clásico exigía escribir, para cada cambio, una constante, un creador de acción, un caso del switch y una actualización inmutable a mano:

// Redux clásico: cuatro sitios para un solo cambio
export const RESPONSABLE_CAMBIADO = 'filtro/responsableCambiado';

export const responsableCambiado = (nombre) => ({ type: RESPONSABLE_CAMBIADO, payload: nombre });

export function reductorFiltro(estado = INICIAL, accion) {
  switch (accion.type) {
    case RESPONSABLE_CAMBIADO:
      return { ...estado, responsable: accion.payload };
    default:
      return estado;
  }
}

Multiplica eso por cuarenta acciones y por actualizaciones anidadas del tipo {...estado, tareas: {...estado.tareas, [id]: {...estado.tareas[id], estado: 'hecha'}}} y entenderás la mala fama.

Redux Toolkit (RTK) es la respuesta oficial, y hoy es la forma correcta de usar Redux. No es una alternativa ni una capa opcional: la documentación oficial desaconseja escribir Redux sin él. Trae cuatro cosas:

Pieza Qué resuelve
configureStore Monta el almacén con DevTools, middleware y comprobaciones de desarrollo ya configurados
createSlice Genera acciones y reductor a la vez, a partir de un solo objeto
Immer (incluido) Permite escribir mutaciones que producen estado inmutable
createAsyncThunk / RTK Query Asincronía y estado de servidor
npm install @reduxjs/toolkit react-redux

Dos paquetes: @reduxjs/toolkit (el almacén, independiente de la vista) y react-redux (los hooks que lo conectan con React).

  1. configureStore: el almacén

// src/almacen/index.js
import { configureStore } from '@reduxjs/toolkit';
import filtroReducer from './filtroSlice.js';
import tareasReducer from './tareasSlice.js';

export const almacen = configureStore({
  reducer: {
    filtro: filtroReducer,     // el estado quedará en estado.filtro
    tareas: tareasReducer      // y en estado.tareas
  }
});

Y la conexión con React, una sola vez, en la raíz:

// src/main.jsx
import { Provider } from 'react-redux';
import { almacen } from './almacen/index.js';

createRoot(document.getElementById('raiz')).render(
  <StrictMode>
    <Provider store={almacen}>
      <App />
    </Provider>
  </StrictMode>
);

configureStore viene con cosas activadas por defecto que merecen conocerse, porque son de las que más ayudan en el día a día:

  • Las DevTools están conectadas sin configurar nada.
  • Un comprobador de mutaciones avisa en desarrollo si algún reductor muta el estado fuera de Immer.
  • Un comprobador de serializabilidad avisa si metes en el estado algo que no es un dato plano: una Date, un Map, una promesa, una instancia de clase. Esto molesta al principio y luego se agradece, porque es lo que protege el principio 1.

Ese último punto tiene una consecuencia directa para Nómada Tareas: tus instancias de Tarea con #estado privado no pueden ir al almacén. En Redux, el estado son datos planos y las reglas viven en funciones. Es el mismo intercambio que ya asumiste en 10-02 al elegir objetos literales para el estado de React, ahora convertido en norma comprobada.

  1. createSlice: acciones y reductores juntos

Un slice es una porción del estado con sus acciones y su reductor, definidos de una vez:

// src/almacen/filtroSlice.js
import { createSlice } from '@reduxjs/toolkit';

const filtroSlice = createSlice({
  name: 'filtro',
  initialState: {
    responsable: null,      // R8: null, nunca ''
    texto: '',
    orden: 'prioridad'
  },
  reducers: {
    responsableCambiado(estado, accion) {
      estado.responsable = accion.payload;    // ← parece mutación; no lo es (apartado 16)
    },
    textoCambiado(estado, accion) {
      estado.texto = accion.payload;
    },
    ordenCambiado(estado, accion) {
      estado.orden = accion.payload;
    },
    filtrosLimpiados(estado) {
      estado.responsable = null;
      estado.texto = '';
    }
  }
});

export const { responsableCambiado, textoCambiado, ordenCambiado, filtrosLimpiados } =
  filtroSlice.actions;

export default filtroSlice.reducer;

Qué genera createSlice automáticamente:

  • Los creadores de acción: responsableCambiado('Iván') devuelve { type: 'filtro/responsableCambiado', payload: 'Iván' }. El type se compone con name + nombre del reductor, así que no hay constantes que mantener ni riesgo de colisión.
  • El reductor combinado, que despacha internamente por type.

Compara con el Redux clásico del apartado 13: cuatro sitios se han convertido en uno.

  1. Immer: escribir mutaciones que producen estado inmutable

estado.responsable = accion.payload parece violar el principio 3. No lo hace, y entender por qué evita mucha desconfianza.

RTK incluye Immer, una librería que envuelve el estado en un Proxy (el mismo mecanismo que la reactividad de Vue de 10-04). Ese proxy intercepta las escrituras y, en lugar de aplicarlas al objeto real, apunta qué se ha querido cambiar. Al terminar el reductor, Immer construye un objeto nuevo aplicando esos cambios, reutilizando todo lo que no se ha tocado.

graph LR
  A["Estado actual<br/>(congelado)"] --> B["Borrador<br/>(Proxy que registra)"]
  B --> C["Tu reductor escribe<br/>estado.responsable = 'Iván'"]
  C --> D["Immer aplica los cambios"]
  D --> E["Estado nuevo<br/>(referencias compartidas<br/>con lo no modificado)"]

El valor de esto se ve de verdad con estado anidado. Sin Immer:

// ❌ Actualización inmutable a mano de una tarea dentro de un array
return {
  ...estado,
  tareas: estado.tareas.map((t) =>
    t.id === accion.payload.id ? { ...t, estado: accion.payload.destino } : t
  )
};

Con Immer:

// ✅ Lo mismo, legible
const tarea = estado.tareas.find((t) => t.id === accion.payload.id);
if (tarea) tarea.estado = accion.payload.destino;

Y una propiedad valiosa que se pierde de vista: Immer produce actualizaciones estructuralmente compartidas. Las tareas que no cambian conservan su referencia exacta. Eso es justo lo que necesita React.memo de 10-02 para no repintar las 599 tarjetas que no han cambiado.

Tres reglas de Immer que hay que respetar:

  1. O mutas el borrador, o devuelves un valor nuevo. Nunca las dos cosas.
// ❌ Las dos cosas a la vez: comportamiento indefinido
reducers: {
  mal(estado, accion) {
    estado.responsable = accion.payload;
    return { ...estado, texto: '' };
  }
}
  1. Para reemplazar el estado entero, devuélvelo, no reasignes el parámetro:
reducers: {
  reiniciado() {
    return { responsable: null, texto: '', orden: 'prioridad' };   // ✅
  }
}
  1. Immer solo funciona dentro de los reductores de RTK. Fuera —en un componente, en un selector— las reglas de inmutabilidad de 04-07 siguen vigentes tal cual.

  1. useSelector y useDispatch

Los dos hooks que conectan los componentes con el almacén:

// src/componentes/FiltroResponsable.jsx
import { useSelector, useDispatch } from 'react-redux';
import { responsableCambiado } from '../almacen/filtroSlice.js';

export function FiltroResponsable({ responsables }) {
  const responsable = useSelector((estado) => estado.filtro.responsable);
  const despachar = useDispatch();

  return (
    <label htmlFor="filtro-responsable">
      Responsable:
      <select
        id="filtro-responsable"
        value={responsable ?? ''}
        onChange={(e) => despachar(responsableCambiado(e.target.value || null))}
      >
        <option value="">Todos</option>
        {responsables.map((n) => <option key={n} value={n}>{n}</option>)}
      </select>
    </label>
  );
}

Fíjate en lo que ha desaparecido: este componente no recibe ninguna prop relacionada con el filtro. Ni valor ni alCambiar. Los seis niveles de prop drilling del apartado 2 se han evaporado, porque el componente habla directamente con el almacén desde donde esté.

useSelector(fn) ejecuta fn(estado) y devuelve el resultado, suscribiéndose a él. Y aquí está la diferencia clave con Context: el componente solo se vuelve a renderizar si el resultado del selector cambia, no si cambia cualquier cosa del almacén. Cambiar estado.tareas no repinta este <select>.

La comparación se hace por defecto con Object.is, la misma referencia de 10-02. De ahí sale el error más frecuente con Redux:

// ❌ Devuelve un ARRAY NUEVO en cada llamada: se repinta siempre
const visibles = useSelector((e) => e.tareas.lista.filter((t) => t.estado !== 'hecha'));

// ❌ Objeto nuevo en cada llamada: lo mismo
const { responsable, texto } = useSelector((e) => ({ ...e.filtro }));

Tres soluciones, por orden de preferencia:

// ✅ 1 · Un useSelector por valor primitivo
const responsable = useSelector((e) => e.filtro.responsable);
const texto = useSelector((e) => e.filtro.texto);

// ✅ 2 · Devolver una referencia estable del estado
const filtro = useSelector((e) => e.filtro);       // el mismo objeto si no ha cambiado

// ✅ 3 · Un selector memoizado (apartado 18)
const visibles = useSelector(seleccionarVisibles);

useDispatch() devuelve la función dispatch. Es estable entre renderizados, así que se puede usar en dependencias de efectos sin problemas — a diferencia de las funciones que creas tú.

  1. Selectores memoizados con createSelector

Un selector es una función que extrae o calcula algo a partir del estado. Escribirlos aparte tiene dos ventajas: se reutilizan, y desacoplan los componentes de la forma del estado. Si mañana estado.filtro.responsable se mueve a estado.ui.filtros.responsable, solo cambia el selector.

// src/almacen/selectores.js
import { createSelector } from '@reduxjs/toolkit';
import { PESOS } from '../dominio/reglas.js';

// Selectores de entrada: baratos, sin cálculo
export const seleccionarTareas     = (estado) => estado.tareas.lista;
export const seleccionarResponsable = (estado) => estado.filtro.responsable;
export const seleccionarTexto      = (estado) => estado.filtro.texto;
export const seleccionarOrden      = (estado) => estado.filtro.orden;

// Selector derivado y memoizado
export const seleccionarVisibles = createSelector(
  [seleccionarTareas, seleccionarResponsable, seleccionarTexto, seleccionarOrden],
  (tareas, responsable, texto, orden) => {
    const t = texto.trim().toLowerCase();
    return tareas
      .filter((x) => responsable === null || x.responsable === responsable)
      .filter((x) => t === '' || x.titulo.toLowerCase().includes(t))
      .toSorted(COMPARADORES[orden] ?? COMPARADORES.prioridad);
  }
);

export const seleccionarResumenVisible = createSelector(
  [seleccionarVisibles],
  (visibles) => ({
    total: visibles.length,
    horasAbiertas: visibles.filter((t) => t.estado !== 'hecha')
                           .reduce((s, t) => s + t.horasEstimadas, 0),
    esfuerzo: visibles.reduce((s, t) => s + t.horasEstimadas * PESOS[t.prioridad], 0)
  })
);

const COMPARADORES = {
  prioridad: (a, b) => PESOS[b.prioridad] - PESOS[a.prioridad] || a.id - b.id,
  fecha:     (a, b) => a.fechaLimite.localeCompare(b.fechaLimite),
  horas:     (a, b) => b.horasEstimadas - a.horasEstimadas
};

Cómo funciona createSelector, que es donde se cierra el paralelismo con 09-02:

  1. Ejecuta los selectores de entrada, que son baratos.
  2. Compara sus resultados con los de la llamada anterior usando Object.is.
  3. Si todos son idénticos, devuelve el resultado cacheado sin ejecutar el cálculo.
  4. Si alguno cambió, recalcula y guarda.

Es tu caché por versión, con la referencia del estado inmutable haciendo de número de versión. Y tiene un efecto que no es de rendimiento sino de corrección: como devuelve la misma referencia mientras las entradas no cambien, resuelve el problema de useSelector del apartado anterior. Sin memoizar, filter crea un array nuevo cada vez y el componente se repinta siempre.

Dos avisos:

  • Por defecto la caché es de tamaño uno. Si dos componentes llaman al mismo selector con estados distintos —típico con selectores parametrizados por id—, se anulan mutuamente. RTK ofrece formas de crear una instancia por componente o ampliar la caché.
  • Memoizar selectores triviales no aporta nada. (e) => e.filtro.responsable no necesita createSelector: no calcula nada y ya devuelve una referencia estable. Es el mismo aviso de 09-01 y de 10-02: memoizar tiene coste.

  1. Lógica asíncrona con createAsyncThunk

Los reductores son puros: no pueden pedir datos. La asincronía vive en el middleware, y RTK trae ya configurado el thunk, que permite despachar una función en lugar de un objeto.

createAsyncThunk construye esa función y despacha automáticamente tres acciones por cada petición:

// src/almacen/tareasSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { listarTareas } from '../datos/api-tareas.js';   // tu módulo de 07-02

export const cargarTareas = createAsyncThunk(
  'tareas/cargar',
  async ({ responsable } = {}, { signal, rejectWithValue }) => {
    try {
      // `signal` lo aporta RTK: es el AbortController de 07-03, ya integrado
      return await listarTareas({ responsable, signal });
    } catch (error) {
      // ErrorDeApi de 07-03: extraemos lo serializable (principio 1)
      return rejectWithValue({ mensaje: error.message, estado: error.estado ?? null });
    }
  }
);

Tres detalles importantes:

  • signal viene de regalo. RTK crea un AbortController por cada ejecución del thunk y lo cancela si se llama a promesa.abort() o si se descarta la petición. Tu pedirJson de 07-03 lo acepta tal cual: cero adaptadores.
  • rejectWithValue existe porque un Error no es serializable y violaría el principio 1. Se guarda un objeto plano con lo que interese mostrar.
  • La deduplicación se puede configurar con la opción condition, para no lanzar la misma petición si ya hay una en vuelo.

  1. Los tres estados de una petición

Cada thunk despacha tareas/cargar/pending, tareas/cargar/fulfilled y tareas/cargar/rejected. El slice los recoge en extraReducers:

const tareasSlice = createSlice({
  name: 'tareas',
  initialState: {
    lista: [],
    fase: 'inactivo',     // 'inactivo' | 'cargando' | 'listo' | 'error'
    error: null
  },
  reducers: {
    estadoCambiado(estado, accion) {
      const { id, destino } = accion.payload;
      const tarea = estado.lista.find((t) => t.id === id);
      if (!tarea) return;
      if (SIGUIENTE[tarea.estado] !== destino) return;   // R6: transición no permitida
      tarea.estado = destino;                             // Immer
    }
  },
  extraReducers: (constructor) => {
    constructor
      .addCase(cargarTareas.pending, (estado) => {
        estado.fase = 'cargando';
        estado.error = null;
      })
      .addCase(cargarTareas.fulfilled, (estado, accion) => {
        estado.fase = 'listo';
        estado.lista = accion.payload;
      })
      .addCase(cargarTareas.rejected, (estado, accion) => {
        estado.fase = 'error';
        estado.error = accion.payload ?? { mensaje: accion.error.message };
      });
  }
});

Y el consumo:

function Tablero() {
  const despachar = useDispatch();
  const fase = useSelector((e) => e.tareas.fase);

  useEffect(() => {
    const promesa = despachar(cargarTareas({}));
    return () => promesa.abort();      // cancelación al desmontar (10-02, apartado 24)
  }, [despachar]);

  if (fase === 'cargando') return <p role="status">Cargando tareas…</p>;
  if (fase === 'error')    return <p role="alert">No se han podido cargar las tareas.</p>;
  return <ListaTareas />;
}

Cuatro puntos:

  • La condición de carrera de 10-02 sigue existiendo. Si se despachan dos cargas, la última en llegar gana. La solución es abortar la anterior, como aquí, o usar condition.
  • Un solo campo fase en lugar de tres booleanos: la regla del estado imposible de 10-02.
  • estadoCambiado implementa la R6 en el reductor, con la misma tabla SIGUIENTE de tu dominio en JavaScript puro. Las reglas de negocio no se han mudado a Redux: se consultan desde él.
  • El reductor es puro y por tanto trivial de probar con Jest, sin montar React ni el almacén: reductor(estadoPrevio, accion) y compruebas la salida. Es la mejor propiedad de Redux para las pruebas de 08-03.

  1. RTK Query: la herramienta para el estado de servidor

El apartado anterior funciona, pero repítelo para quince recursos y aparecerán las preguntas de 10-02: ¿quién cachea?, ¿quién deduplica?, ¿quién invalida al crear una tarea?, ¿quién refresca al volver a la pestaña?

RTK Query es la respuesta que RTK trae incluida, con la misma filosofía que TanStack Query:

// src/almacen/apiTareas.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const apiTareas = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api/' }),
  tagTypes: ['Tarea'],
  endpoints: (constructor) => ({
    listarTareas: constructor.query({
      query: (responsable) => (responsable ? `tareas?responsable=${responsable}` : 'tareas'),
      providesTags: ['Tarea']            // esta consulta depende de la etiqueta 'Tarea'
    }),
    cambiarEstado: constructor.mutation({
      query: ({ id, destino }) => ({
        url: `tareas/${id}`,
        method: 'PATCH',
        body: { estado: destino }
      }),
      invalidatesTags: ['Tarea']         // al mutar, se invalida y la lista se recarga sola
    })
  })
});

export const { useListarTareasQuery, useCambiarEstadoMutation } = apiTareas;
function ListaRemota({ responsable }) {
  const { data: tareas = [], isFetching, error } = useListarTareasQuery(responsable);
  const [cambiarEstado] = useCambiarEstadoMutation();

  if (error) return <p role="alert">Error al cargar</p>;

  return (
    <ul aria-busy={isFetching}>
      {tareas.map((t) => (
        <li key={t.id}>
          {t.titulo}
          <button onClick={() => cambiarEstado({ id: t.id, destino: 'hecha' })}>Hecha</button>
        </li>
      ))}
    </ul>
  );
}

Lo que ha desaparecido de este código: el useEffect, el AbortController, el estado de carga, el estado de error, el dispatch manual y —lo más importante— la recarga tras la mutación. El sistema de etiquetas la hace sola.

Problema del estado de servidor Quién lo resuelve aquí
Dos componentes piden lo mismo Deduplicación por clave de consulta
Volver atrás muestra pantalla de carga Datos cacheados mientras revalida
Datos rancios tras una hora Refresco al enfocar y al reconectar
Tras crear, la lista no se actualiza invalidatesTags
Red inestable Reintentos configurables (tu conReintentos de 07-03)
Cambio optimista y reversión onQueryStarted con patchResult.undo()

La conclusión de 10-01 se confirma: si el 60 % de tu estado es de servidor y lo gestiona RTK Query, lo que queda para los slices manuales es muy poco. Y eso es exactamente lo que debería quedar.

  1. Las DevTools y el viaje en el tiempo

Con la extensión Redux DevTools instalada y configureStore, tienes esto sin configurar nada:

Función Para qué sirve de verdad
Lista de acciones La crónica de lo que ha hecho la usuaria, en orden y con nombre
Diff por acción Qué cambió exactamente en el estado, campo a campo
Viaje en el tiempo Retroceder a cualquier acción anterior y ver la pantalla de ese momento
Saltar acción Anular una acción del historial y recalcular todo lo demás
Exportar / importar estado Reproducir el error de otra persona en tu máquina
Trazado Desde qué línea de código se despachó cada acción

El viaje en el tiempo funciona por el principio 3: como los reductores son puros, el estado en el momento n se puede recalcular reproduciendo las n primeras acciones sobre el estado inicial. No se guardan copias del estado: se guarda el historial y se recalcula. Es el mismo razonamiento de 03-07 sobre funciones deterministas, aplicado a una aplicación entera.

Y aquí está la ventaja real de Redux, que no es el rendimiento ni el prop drilling:

Cuando alguien dice «se me borra el filtro al volver del detalle y no sé por qué», con Redux abres las DevTools, reproduces el recorrido y ves la acción que lo hizo, con su nombre y su origen. Sin Redux, buscas por el proyecto y adivinas.

Compara con tu situación en Nómada Tareas: para depurar por qué el tablero cambia de forma inesperada, tienes console.log, puntos de interrupción y la búsqueda de emitir( por todo el código. Funciona —lo hiciste en 08-01— pero es reconstruir a mano lo que aquí está grabado. En una aplicación de veinte pantallas y seis personas, esa diferencia se mide en días.

El precio es igual de claro: para que esto funcione, todo cambio tiene que ser una acción con nombre. Esa disciplina es el coste, y solo se paga a gusto cuando la aplicación es lo bastante grande para necesitar la trazabilidad.

  1. Normalizar datos con createEntityAdapter

Con las 600 tareas de la prueba de carga de 09-01, guardar un array plano tiene los problemas que ya mediste en 09-02: buscar por id es O(n), y actualizar una tarea con map crea un array nuevo de 600 posiciones.

Normalizar significa guardar los datos como un diccionario por id más una lista de ids:

// En lugar de esto…
{ lista: [ {id:1, …}, {id:2, …}, … ] }

// …esto
{
  ids: [1, 2, 3, 4, 5, 6],
  entities: {
    1: { id: 1, titulo: 'Rediseñar la sala polivalente', … },
    2: { id: 2, titulo: 'Cartelería del taller de serigrafía', … }
  }
}

Es tu índice Map de 09-02, expresado con objetos planos porque el estado debe ser serializable. createEntityAdapter lo gestiona por ti:

import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';
import { PESOS } from '../dominio/reglas.js';

const adaptador = createEntityAdapter({
  sortComparer: (a, b) => PESOS[b.prioridad] - PESOS[a.prioridad] || a.id - b.id
});

const tareasSlice = createSlice({
  name: 'tareas',
  initialState: adaptador.getInitialState({ fase: 'inactivo', error: null }),
  reducers: {
    tareaAgregada: adaptador.addOne,
    tareasRecibidas: adaptador.setAll,
    tareaEliminada: adaptador.removeOne,
    estadoCambiado(estado, accion) {
      const { id, destino } = accion.payload;
      const tarea = estado.entities[id];              // O(1), como tu Map
      if (tarea && SIGUIENTE[tarea.estado] === destino) tarea.estado = destino;
    }
  }
});

export const { selectAll: seleccionarTodas, selectById: seleccionarPorId } =
  adaptador.getSelectors((estado) => estado.tareas);

Ventajas concretas, con los números de 09-02:

Operación Array plano (600 tareas) Normalizado
Buscar por id O(n) — 0,004 ms O(1) — 0,00008 ms
Actualizar una tarea Recorre las 600 con map Toca una entrada
Lote de 600 actualizaciones tras reconectar (07-04) 34,2 ms 0,8 ms
Duplicados imposibles No garantizado Por construcción

Y una desventaja real: para pintar la lista hay que reconstruir el array, cosa que hace selectAll. Por eso viene memoizado. Con seis tareas, normalizar es complicar por gusto; con seiscientas y actualizaciones frecuentes, es la diferencia entre fluido y pegajoso — la misma frontera que 09-02 marcó para el índice Map.

  1. Nómada Tareas: el filtro y el tablero completos

Juntemos las piezas en la pantalla objetivo del módulo. El dominio sigue igual, en JavaScript puro:

// src/dominio/reglas.js — sin cambios respecto a 10-02
export const SIGUIENTE = Object.freeze({ pendiente: 'en-curso', 'en-curso': 'hecha', hecha: null });
export const ETIQUETA = Object.freeze({ pendiente: 'Empezar', 'en-curso': 'Marcar hecha', hecha: 'Hecha' });
export const PESOS = Object.freeze({ alta: 3, media: 2, baja: 1 });
export const HOY = '2026-09-20';
export const estaVencida = (t, hoy = HOY) => t.estado !== 'hecha' && t.fechaLimite < hoy;

El slice de tareas:

// src/almacen/tareasSlice.js
import { createSlice } from '@reduxjs/toolkit';
import { SIGUIENTE } from '../dominio/reglas.js';
import { BACKLOG } from '../datos/backlog.js';

const tareasSlice = createSlice({
  name: 'tareas',
  initialState: { lista: BACKLOG, fase: 'listo', error: null },
  reducers: {
    /** Avanza el estado de una tarea respetando la R6. */
    estadoAvanzado(estado, accion) {
      const tarea = estado.lista.find((t) => t.id === accion.payload.id);
      if (!tarea) return;
      const destino = SIGUIENTE[tarea.estado];
      if (destino === null) return;       // desde 'hecha' no se avanza
      tarea.estado = destino;             // Immer: mutación aparente, estado nuevo real
    }
  }
});

export const { estadoAvanzado } = tareasSlice.actions;
export default tareasSlice.reducer;

Los selectores, con los responsables incluidos:

// src/almacen/selectores.js
import { createSelector } from '@reduxjs/toolkit';

export const seleccionarTareas      = (e) => e.tareas.lista;
export const seleccionarResponsable = (e) => e.filtro.responsable;

export const seleccionarResponsables = createSelector(
  [seleccionarTareas],
  (tareas) => [...new Set(tareas.map((t) => t.responsable).filter(Boolean))].sort()
);

export const seleccionarVisibles = createSelector(
  [seleccionarTareas, seleccionarResponsable],
  (tareas, responsable) =>
    responsable === null ? tareas : tareas.filter((t) => t.responsable === responsable)
);

export const seleccionarResumen = createSelector(
  [seleccionarVisibles, seleccionarTareas],
  (visibles, todas) => ({
    visibles: visibles.length,
    total: todas.length,
    horasAbiertas: visibles.filter((t) => t.estado !== 'hecha')
                           .reduce((s, t) => s + t.horasEstimadas, 0)
  })
);

Los componentes, sin una sola prop de filtro:

// src/componentes/ListaTareas.jsx
import { useSelector, useDispatch } from 'react-redux';
import { seleccionarVisibles } from '../almacen/selectores.js';
import { estadoAvanzado } from '../almacen/tareasSlice.js';
import { TarjetaTarea } from './TarjetaTarea.jsx';

export function ListaTareas() {
  const visibles = useSelector(seleccionarVisibles);   // memoizado: referencia estable
  const despachar = useDispatch();

  if (visibles.length === 0) {
    return <p className="lista-vacia">Ninguna tarea coincide con el filtro.</p>;
  }

  return (
    <ul className="lista-tareas">
      {visibles.map((tarea) => (
        <TarjetaTarea
          key={tarea.id}
          tarea={tarea}
          alAvanzar={() => despachar(estadoAvanzado({ id: tarea.id }))}
        />
      ))}
    </ul>
  );
}
// src/componentes/ResumenCabecera.jsx
import { useSelector } from 'react-redux';
import { seleccionarResumen } from '../almacen/selectores.js';

export function ResumenCabecera() {
  const { visibles, total, horasAbiertas } = useSelector(seleccionarResumen);
  return <p className="tablero__resumen">{visibles} de {total} tareas · {horasAbiertas} h abiertas</p>;
}
// src/App.jsx — mira lo que NO hay: ni una prop de filtro
import { FiltroResponsable } from './componentes/FiltroResponsable.jsx';
import { ResumenCabecera } from './componentes/ResumenCabecera.jsx';
import { ListaTareas } from './componentes/ListaTareas.jsx';

export default function App() {
  return (
    <main className="tablero">
      <header className="tablero__cabecera">
        <h1>Nómada Tareas</h1>
        <FiltroResponsable />
        <ResumenCabecera />
      </header>
      <ListaTareas />
    </main>
  );
}

Comprueba los números canónicos: sin filtro, «6 de 6 tareas · 45 h abiertas». Con «Iván», «3 de 6 · 25 h». Con «Lucía», «1 de 6 · 14 h». Con «Marta», «2 de 6 · 6 h». Al pulsar «Empezar» en Presupuesto de la carpintería se despacha tareas/estadoAvanzado con {id: 6}, y en las DevTools ves la acción, el diff exacto de esa tarea y puedes retroceder.

Y ahora el balance sincero de este apartado, que es lo que 10-01 pedía:

Aspecto Antes (10-02, estado elevado) Con Redux
Props de filtro que atraviesan el árbol 6 componentes 0
Ficheros que tocar para añadir un filtro por prioridad 6 2 (slice + selector)
Ficheros del almacén 0 4 (index, 2 slices, selectores)
Dependencias 2 4
Peso añadido (comprimido, aprox.) ~13 kB
Trazabilidad de los cambios console.log Historial completo con viaje en el tiempo
Probar la lógica sin renderizar Difícil Trivial: los reductores son funciones puras

¿Compensa para Nómada Tareas tal como está? No. Con una pantalla y dos consumidores del filtro, el estado elevado de 10-02 —o directamente la URL— es la respuesta correcta, y añadir Redux es complejidad sin contrapartida. ¿Compensaría en la versión producto para veinte talleres? Sí, y por la última fila de la tabla más que por ninguna otra.

  1. Alternativas ligeras: Zustand, Jotai y los almacenes nativos

Redux no es la única forma de tener un almacén externo, y desde hace años no es la más popular para proyectos nuevos de tamaño medio.

Zustand — un almacén minimalista con hooks y suscripción selectiva:

import { create } from 'zustand';
import { SIGUIENTE } from './dominio/reglas.js';

export const usarTablero = create((set) => ({
  tareas: BACKLOG,
  responsable: null,

  filtrarPor: (responsable) => set({ responsable }),

  avanzar: (id) => set((estado) => ({
    tareas: estado.tareas.map((t) => {
      if (t.id !== id) return t;
      const destino = SIGUIENTE[t.estado];
      return destino === null ? t : { ...t, estado: destino };
    })
  }))
}));

// En cualquier componente, a cualquier profundidad:
const responsable = usarTablero((e) => e.responsable);
const avanzar = usarTablero((e) => e.avanzar);

Sin Provider, sin acciones, sin reductores, sin boilerplate. Y con la misma suscripción selectiva de useSelector.

Jotai — estado atómico: en lugar de un objeto grande, muchos átomos pequeños que se componen. Los derivados se declaran a partir de otros átomos, en el estilo de las señales de 10-01:

import { atom, useAtom } from 'jotai';

export const tareasAtom = atom(BACKLOG);
export const responsableAtom = atom(null);

export const visiblesAtom = atom((get) => {
  const responsable = get(responsableAtom);
  const tareas = get(tareasAtom);
  return responsable === null ? tareas : tareas.filter((t) => t.responsable === responsable);
});

La tabla honesta:

Criterio Redux Toolkit Zustand Jotai Context + useState Pinia (Vue) / Señales (Angular)
Boilerplate Medio Muy bajo Muy bajo Bajo Bajo
Peso aproximado ~13 kB ~1 kB ~3 kB 0 Incluido
Suscripción selectiva Sí (por átomo) No
DevTools y viaje en el tiempo Excelente Básico Básico No Bueno
Convenciones impuestas Muchas Ninguna Pocas Ninguna Algunas
Asincronía integrada createAsyncThunk, RTK Query A mano Átomos asíncronos A mano A mano o librería
Curva Alta Muy baja Media Muy baja Baja
Bueno para Equipos grandes, trazabilidad, aplicaciones longevas Casi todo lo demás Estado muy derivado Valores estables Sus propios ecosistemas

Cómo decidir, sin dogmas:

  • Empieza sin nada. useState + elevar + composición con children llega mucho más lejos de lo que se cree.
  • Si el 60 % es estado de servidor, resuélvelo con una caché de datos; el problema global casi desaparece.
  • Si necesitas un almacén y el equipo es pequeño, Zustand o Jotai. Menos ceremonia, mismo resultado.
  • Si necesitas trazabilidad, convenciones para seis personas y una aplicación que vivirá años, Redux Toolkit gana por la última fila: el historial de acciones y las DevTools.
  • Si ya trabajas en Vue o Angular, sus almacenes nativos —Pinia y los servicios con señales— están integrados y suelen ser la respuesta correcta. Lo verás en 10-04 y 10-05.

Errores Comunes y Consejos

Instalar Redux por costumbre. Es el error histórico del ecosistema. La pregunta correcta es «¿qué estado tengo y de qué tipo?». Si al hacer el inventario resulta que casi todo es de servidor y local, Redux sobra.

Meter el estado de servidor en slices manuales. Es el 60 % del estado y tiene reglas propias (caducidad, refresco, invalidación, reintentos). Va en RTK Query o TanStack Query. Meterlo en slices es escribir a mano una caché peor.

Guardar valores derivados en el estado. Si horasAbiertas se deduce de tareas, no se guarda: se calcula con un selector. Guardarlo crea dos fuentes de verdad y reintroduce el problema 1 de 10-01. Es el mismo error que derivar estado con useEffect en 10-02.

Devolver objetos o arrays nuevos desde useSelector. Referencia nueva en cada llamada, renderizado en cada acción del almacén. Usa selectores primitivos o memoiza con createSelector.

Mutar el estado fuera de un reductor. Immer solo funciona dentro de los reductores de RTK. En un componente, un thunk o un selector, mutar el estado rompe la detección de cambios y corrompe el viaje en el tiempo. El comprobador de configureStore avisa en desarrollo: hazle caso.

Mezclar mutación y return en el mismo reductor. O mutas el borrador, o devuelves un valor nuevo. Las dos cosas a la vez tienen comportamiento indefinido.

Meter cosas no serializables en el estado. Fechas, Map, Set, promesas, funciones, instancias de clase con campos privados. Rompen el principio 1, las DevTools y la persistencia. Guarda cadenas ISO (como tu fechaLimite) y objetos planos.

Nombrar las acciones como órdenes. establecerResponsable describe qué hacer; responsableCambiado describe qué pasó. La segunda forma permite que varios reductores reaccionen al mismo hecho y hace legible el historial.

Un solo slice gigante. Divide por dominio: filtro, tareas, sesion. Un slice de 400 líneas es tan difícil de mantener como cualquier otro fichero de 400 líneas.

Consejo: los reductores son el mejor sitio para probar la lógica. Son funciones puras: reductor(estadoPrevio, accion) y comparas la salida, sin renderizar nada. Es lo más barato que ofrece Redux para las pruebas de 08-03.

Consejo: guarda en la URL lo que deba poder compartirse. Filtros, página, ordenación y pestaña activa pertenecen a la URL, no al almacén. Marta enviando ?responsable=Ivan por chat es una funcionalidad gratis, y ya sabes hacerlo desde 07-06.

Consejo: si dudas, empieza sin almacén. Añadir Redux a una aplicación con el estado bien modelado es un trabajo de un día. Quitarlo de una donde todo pasa por él es un trimestre.

Ejercicios

Ejercicio 1 · Diagnosticar antes de instalar

Para cada uno de estos cinco datos de la versión producto de Nómada Tareas, decide dónde debe vivir eligiendo entre: estado local, elevado, URL, caché de datos de servidor, o almacén global. Justifica con un criterio de esta lección y di qué pasaría si lo pusieras en el sitio equivocado.

  1. Si el menú de acciones de una tarjeta está desplegado.
  2. La lista de tareas que devuelve listarTareas().
  3. El responsable por el que se está filtrando.
  4. El usuario identificado y sus permisos.
  5. El texto que Marta lleva escrito en el formulario de nueva tarea, sin enviar.

Ejercicio 2 · Escribir un slice completo

Escribe un slice sesionSlice para la versión producto, con este estado inicial y estas tres acciones:

{ usuario: null, permisos: [], tema: 'claro' }
  • sesionIniciada: recibe { usuario, permisos } y los guarda.
  • sesionCerrada: vuelve al estado inicial.
  • temaAlternado: cambia entre 'claro' y 'oscuro'.

Después escribe:

  1. Un selector seleccionarPuedeEditar que devuelva true si los permisos incluyen 'tareas:editar'.
  2. Un selector memoizado seleccionarTareasEditables que, combinando seleccionarVisibles y los permisos, devuelva las tareas que el usuario puede editar: todas si tiene el permiso, y solo las suyas (donde es responsable) si no lo tiene.
  3. Una prueba con Jest del reductor, sin renderizar nada.

Pregunta adicional: ¿por qué temaAlternado no lleva payload?

Ejercicio 3 · Del CustomEvent a la acción

Este es un fragmento real de tu Nómada Tareas en JavaScript puro:

// js/vista/controlador.js — 06-04
contenedor.addEventListener('click', (evento) => {
  const boton = evento.target.closest('[data-accion]');
  if (!boton) return;

  const id = Number(boton.closest('[data-id]').dataset.id);

  if (boton.dataset.accion === 'avanzar') {
    estado.tablero.cambiarEstado(id, SIGUIENTE[estado.tablero.buscarPorId(id).estado]);
    emitir(EVENTOS.TAREA_AVANZADA, { id });
    render();
  }
});
  1. Tradúcelo al ciclo de Redux: identifica qué parte es la acción, cuál el reductor, cuál el dispatch y cuál el renderizado.
  2. Escribe el equivalente completo en Redux Toolkit.
  3. Explica tres cosas concretas que se ganan con la traducción y dos que se pierden.
  4. ¿Qué pasa en cada versión si cambiarEstado lanza un ErrorDeRegla por violar la R6?

Soluciones

Solución 1

# Dato Dónde Criterio Si se pone mal
1 Menú desplegado Local (useState en la tarjeta) No le importa a nadie más; muere con el componente En el almacén global: 600 entradas de basura, acciones inútiles en el historial y un objeto que crece sin límite
2 Lista de tareas del servidor Caché de datos (RTK Query) Es estado de servidor: caduca, se refresca, se invalida, puede fallar En un slice manual: nadie sabe cuándo refrescar, hay que invalidar a mano tras cada mutación y se reimplementa una caché peor
3 Responsable filtrado La URL Debe poder compartirse por enlace y sobrevivir a recargar la página En el almacén: se pierde al recargar, ?responsable=Ivan no funciona y hay que sincronizar almacén y URL a mano
4 Usuario y permisos Almacén global (o Context) Lo necesitan partes muy alejadas, cambia poquísimo, condiciona qué se pinta Local o elevado: prop drilling del peor tipo, atravesando toda la aplicación
5 Texto sin enviar del formulario Local (o no controlado, 10-02) Es efímero, cambia en cada tecla y solo le importa al formulario En el almacén: una acción por pulsación, historial ilegible en las DevTools y renderizados en cascada

El dato 3 merece un comentario: es el caso donde más gente se equivoca, precisamente porque «lo comparten varios componentes» suena a estado global. Pero compartirlo entre componentes es un problema de transporte; que sobreviva a una recarga y viaje en un enlace es un requisito de producto, y solo lo cumple la URL. Un enrutador moderno permite leer y escribir parámetros de consulta como si fueran estado, con lo que la ergonomía es la misma.

Solución 2

// src/almacen/sesionSlice.js
import { createSlice, createSelector } from '@reduxjs/toolkit';
import { seleccionarVisibles } from './selectores.js';

const INICIAL = { usuario: null, permisos: [], tema: 'claro' };

const sesionSlice = createSlice({
  name: 'sesion',
  initialState: INICIAL,
  reducers: {
    sesionIniciada(estado, accion) {
      estado.usuario = accion.payload.usuario;
      estado.permisos = accion.payload.permisos;
      // el tema NO se toca: es una preferencia que sobrevive al cierre de sesión
    },
    sesionCerrada(estado) {
      return { ...INICIAL, tema: estado.tema };   // devolver: reemplazo total (regla 2 de Immer)
    },
    temaAlternado(estado) {
      estado.tema = estado.tema === 'claro' ? 'oscuro' : 'claro';
    }
  }
});

export const { sesionIniciada, sesionCerrada, temaAlternado } = sesionSlice.actions;
export default sesionSlice.reducer;

// ── Selectores ──────────────────────────────────────────────
export const seleccionarUsuario  = (e) => e.sesion.usuario;
export const seleccionarPermisos = (e) => e.sesion.permisos;

/** Trivial: no necesita createSelector, no calcula nada caro ni crea referencias nuevas. */
export const seleccionarPuedeEditar = (e) => e.sesion.permisos.includes('tareas:editar');

/** Sí necesita memoización: devuelve un array nuevo. */
export const seleccionarTareasEditables = createSelector(
  [seleccionarVisibles, seleccionarPuedeEditar, seleccionarUsuario],
  (visibles, puedeEditar, usuario) =>
    puedeEditar ? visibles : visibles.filter((t) => t.responsable === usuario?.nombre)
);

La prueba, sin React ni almacén:

// test/sesionSlice.test.js
import reductor, { sesionIniciada, sesionCerrada, temaAlternado } from '../src/almacen/sesionSlice.js';

describe('sesionSlice', () => {
  const inicial = { usuario: null, permisos: [], tema: 'claro' };

  test('sesionIniciada guarda usuario y permisos', () => {
    const resultado = reductor(inicial, sesionIniciada({
      usuario: { nombre: 'Marta' },
      permisos: ['tareas:editar']
    }));
    expect(resultado.usuario).toEqual({ nombre: 'Marta' });
    expect(resultado.permisos).toEqual(['tareas:editar']);
  });

  test('sesionCerrada limpia la sesión pero conserva el tema', () => {
    const conSesion = { usuario: { nombre: 'Iván' }, permisos: ['x'], tema: 'oscuro' };
    expect(reductor(conSesion, sesionCerrada())).toEqual({ ...inicial, tema: 'oscuro' });
  });

  test('temaAlternado va y vuelve', () => {
    const oscuro = reductor(inicial, temaAlternado());
    expect(oscuro.tema).toBe('oscuro');
    expect(reductor(oscuro, temaAlternado()).tema).toBe('claro');
  });

  test('el reductor no muta el estado que recibe', () => {
    const congelado = Object.freeze({ ...inicial, permisos: Object.freeze([]) });
    expect(() => reductor(congelado, temaAlternado())).not.toThrow();
    expect(congelado.tema).toBe('claro');    // el original intacto
  });
});

Por qué temaAlternado no lleva payload: porque el nuevo valor se deduce del actual, no lo aporta quien despacha. Es la misma razón por la que en 10-02 se usaba setContador((n) => n + 1) en lugar de setContador(contador + 1). Si el componente calculara el tema nuevo y lo enviara como payload, dos pulsaciones muy seguidas podrían enviar el mismo valor. Con la decisión dentro del reductor, que ve siempre el estado más reciente, eso es imposible.

La última prueba merece atención: congelar el estado de entrada con Object.freeze es una técnica excelente para verificar que un reductor es realmente puro, y es el equivalente en pruebas del comprobador que configureStore activa en desarrollo.

Solución 3

1 · La traducción de las piezas:

Pieza del código original Equivalente en Redux
evento.target.closest('[data-accion]') Se queda igual: es DOM. En React sería onClick en el botón
estado.tablero.cambiarEstado(id, …) El reductor: la transformación pura del estado
emitir(EVENTOS.TAREA_AVANZADA, {id}) dispatch(estadoAvanzado({id})): notificar qué ha pasado
render() Desaparece: useSelector provoca el renderizado de quien corresponda
SIGUIENTE[...buscarPorId(id).estado] Se mueve dentro del reductor, que ya tiene el estado

Nota importante: en el original, emitir y cambiarEstado son dos cosas separadas — primero se muta y luego se avisa. En Redux son la misma cosa: el dispatch es simultáneamente el aviso y la causa del cambio. Esa unificación es la que hace que el historial de acciones sea completo por construcción; en tu versión, alguien puede llamar a cambiarEstado sin emitir el evento, y entonces el aviso se pierde.

2 · El equivalente completo:

// src/almacen/tareasSlice.js
import { createSlice } from '@reduxjs/toolkit';
import { SIGUIENTE } from '../dominio/reglas.js';

const tareasSlice = createSlice({
  name: 'tareas',
  initialState: { lista: BACKLOG, ultimoError: null },
  reducers: {
    estadoAvanzado(estado, accion) {
      estado.ultimoError = null;
      const tarea = estado.lista.find((t) => t.id === accion.payload.id);
      if (!tarea) {
        estado.ultimoError = `No existe la tarea ${accion.payload.id}`;
        return;
      }
      const destino = SIGUIENTE[tarea.estado];
      if (destino === null) {
        estado.ultimoError = `La tarea "${tarea.titulo}" ya está hecha`;   // R6
        return;
      }
      tarea.estado = destino;
    }
  }
});

export const { estadoAvanzado } = tareasSlice.actions;
export default tareasSlice.reducer;
// src/componentes/TarjetaTarea.jsx (fragmento)
const despachar = useDispatch();

<button
  type="button"
  disabled={SIGUIENTE[tarea.estado] === null}
  onClick={() => despachar(estadoAvanzado({ id: tarea.id }))}
>
  {ETIQUETA[tarea.estado]}
</button>

3 · Tres cosas que se ganan:

  • Trazabilidad completa. Cada avance queda en el historial de las DevTools con su id y su diff, y se puede retroceder. En la versión original, para saber por qué una tarea acabó en 'hecha' hay que poner un punto de interrupción y reproducir el recorrido.
  • La lógica se prueba sin DOM. reductor(estado, estadoAvanzado({id: 6})) es una llamada de función. La versión original necesita jsdom, un contenedor, una plantilla y un clic simulado (08-05).
  • Un solo camino para el cambio. No es posible cambiar el estado de una tarea sin despachar la acción, así que ninguna mutación queda fuera del registro. En la versión original, tablero.cambiarEstado(6, 'hecha') desde la consola cambia el modelo sin avisar a nadie y sin repintar.

Dos cosas que se pierden:

  • Inmediatez y peso. Cuatro ficheros nuevos, dos dependencias, ~13 kB y una capa de indirección: para seguir qué hace el botón hay que ir del componente a la acción, de la acción al slice y del slice al selector. En la versión original está todo en veinte líneas seguidas.
  • Los objetos ricos del dominio. El estado son datos planos: se pierden la clase Tarea con su #estado privado, sus getters y sus métodos, que garantizaban la R6 por encapsulación (05-03). En Redux la garantía es por convención: nada impide que otro reductor escriba tarea.estado = 'hecha' saltándose SIGUIENTE.

4 · Qué pasa si se viola la R6. En la versión original, cambiarEstado lanza ErrorDeRegla, la excepción sube por el manejador del clic y —si nadie la captura— muere en la consola: la interfaz no se entera y no se muestra nada al usuario. En la versión Redux un reductor no puede lanzar: debe ser puro y total. La transición inválida se convierte en estado (ultimoError), que la interfaz puede mostrar como un aviso accesible. Es una diferencia de filosofía que conviene entender: en Redux, los errores previsibles del dominio son datos, no excepciones; las excepciones se reservan para lo verdaderamente inesperado. Es, por cierto, exactamente el mismo razonamiento con el que 02-05 distinguía entre errores esperables y fallos de programación.

Conclusión

Has aprendido gestión de estado empezando por donde había que empezar: por el problema. Conoces sus tres caras —el prop drilling del filtro que atraviesa seis componentes que no lo usan, el estado duplicado que hace que la cabecera y la lista se contradigan, y los valores derivados guardados que reintroducen el problema 1 de 10-01 dentro del framework que venía a resolverlo—. Y tienes la tabla de alternativas por orden de coste, con la instrucción de quedarte en la primera fila que resuelva tu caso: nada, elevar el estado, Context, un almacén ligero, Redux. Con el inventario que casi nadie hace: en una aplicación real, el 60 % del estado es de servidor, el 25 % local, el 10 % de URL y solo el 5 % verdaderamente global — y ese 5 % es el territorio de Redux.

Sabes que elevar el estado llega mucho más lejos con composición: pasar elementos ya creados como children o como props de contenido elimina el prop drilling sin instalar nada. Sabes qué es Context —un mecanismo de transporte, no un gestor de estado— y cuál es su limitación real: todos los consumidores se repintan cuando cambia el valor, con los dos paliativos de partir por frecuencia de cambio y separar valor de acciones. Y sabes qué añade un almacén externo: suscripción selectiva, y la posibilidad de usarlo desde fuera de React —desde tu CanalTablero de 07-04, desde una prueba, desde donde sea—.

Conoces los tres principios de Redux y, más importante, qué compra cada uno: fuente única de verdad (nada que desincronizar, estado serializable y restaurable), estado de solo lectura mediante acciones (todos los cambios por un punto observable), y cambios con funciones puras (la misma secuencia produce siempre el mismo estado, que es lo que hace posible el viaje en el tiempo). Tienes el ciclo acción → reductor → nuevo estado → render con sus cinco propiedades, y el paralelismo exacto con lo que ya habías construido: tu objeto estado, tus CustomEvent con emitir, tu manejador que aplicaba el cambio y llamaba a render(), y tu caché por versión de 09-02 — donde la inmutabilidad hace innecesario el contador de versión porque la referencia es el contador.

Dominas Redux Toolkit como la forma correcta y actual: configureStore con sus comprobadores de mutación y serializabilidad, createSlice que convierte cuatro sitios en uno, Immer con su proxy que registra escrituras y produce estado nuevo compartiendo estructuralmente lo no modificado —con sus tres reglas: no mezclar mutación y return, devolver para reemplazar del todo, y no esperar magia fuera de los reductores—, useSelector con su suscripción selectiva y su trampa de las referencias nuevas, y createSelector como memoización de derivados. Sabes resolver la asincronía con createAsyncThunk y sus tres acciones automáticas, con el signal que integra tu AbortController de 07-03 y rejectWithValue para no meter excepciones en el estado; y sabes que el estado de servidor tiene herramienta propia, RTK Query, con caché por clave, invalidación por etiquetas, refresco automático y mutaciones optimistas.

Tienes claro dónde está la ventaja real de Redux, que no es el rendimiento ni el transporte: es la trazabilidad. El historial de acciones con nombre, el diff por acción, el viaje en el tiempo que funciona porque los reductores son puros, y la posibilidad de exportar el estado de otra persona y reproducir su error en tu máquina. Y sabes normalizar con createEntityAdapter cuando hay 600 tareas, que es tu índice Map de 09-02 expresado en datos planos, con la misma frontera: por debajo de cierto tamaño, complica sin ganar nada.

Has visto el filtro y el tablero de Nómada Tareas en Redux, con App sin una sola prop de filtro y los números canónicos intactos, y el balance honesto: cero props atravesando el árbol y trazabilidad completa, a cambio de cuatro ficheros, dos dependencias, 13 kB y una capa de indirección. Con la conclusión que 10-01 pedía: para Nómada Tareas tal como está, no compensa; para la versión producto de veinte talleres, sí. Y conoces las alternativas ligeras —Zustand, Jotai, y los almacenes nativos de otros ecosistemas— con la tabla que dice cuándo cada una es la respuesta razonable.

Todo lo visto hasta ahora ha ocurrido dentro del mismo modelo: componentes de función, DOM virtual, estado inmutable, memoización manual. La siguiente lección cambia las tres cosas. Vas a ver la misma pantalla en un framework que agrupa plantilla, lógica y estilos en un solo fichero, que detecta las dependencias automáticamente con proxies en lugar de comparar árboles, y donde el equivalente de useMemo no es una optimización sino la forma natural de escribir. Es la familia 2 de reactividad de 10-01, en su exponente más pulido: Conceptos Básicos de Vue.js.

Curso de JavaScript: De Principiante a Avanzado

Módulo 1: Introducción a JavaScript

Módulo 2: Estructuras de Control

Módulo 3: Funciones

Módulo 4: Objetos y Arrays

Módulo 5: Objetos y Funciones Avanzadas

Módulo 6: El Modelo de Objetos del Documento (DOM)

Módulo 7: APIs del Navegador y Temas Avanzados

Módulo 8: Pruebas y Depuración

Módulo 9: Rendimiento y Optimización

Módulo 10: Frameworks y Librerías de JavaScript

Módulo 11: Proyecto Final

© Copyright 2026. Todos los derechos reservados