Reducers puros
El reducer es el núcleo determinista de Redux: una función pura con la firma (state, action) => newState, bautizada por analogía con Array.reduce porque pliega un historial de acciones sobre un estado inicial. Esta lección disecciona las tres obligaciones de la pureza —no mutar, no producir efectos, ser determinista—, muestra el bug clásico de la mutación y cómo evitarlo con inmutabilidad, y explica por qué exactamente esa pureza es la que compra el time-travel, el testing trivial y la recarga en caliente. Cierra con Immer y createSlice en 2026: cómo puedes escribir código que parece mutar sin renunciar a una sola de las garantías.
El reducer es el único lugar de Redux donde el estado cambia, y por eso es también el único que debe ser sagrado. Su nombre viene de Array.reduce, y la analogía no es decorativa: igual que reduce pliega una lista de valores sobre un acumulador para producir un resultado, el reducer pliega un historial de acciones sobre un estado inicial para producir el estado actual. Toda la aplicación, vista así, es una sola reducción gigante que nunca termina. Y el requisito que hace que esa reducción sea confiable es uno solo, absoluto e innegociable: el reducer debe ser una función pura. Comprender qué significa eso, y sobre todo qué te compra a cambio, es entender por qué Redux pudo hacer algo que parecía magia: viajar en el tiempo.
- Leer la firma
(estado, accion) => nuevoEstadocomo un plegado deArray.reduce. - Enunciar las tres obligaciones de la pureza: no mutar, no producir efectos, ser determinista.
- Evitar el bug clásico de mutación devolviendo estado nuevo con inmutabilidad.
- Explicar por qué la pureza habilita el time-travel, y cómo Immer la preserva en 2026.
La firma es la tesis
Un reducer recibe dos cosas —el estado anterior y una acción— y devuelve una: el estado siguiente. No hay una tercera entrada oculta ni una salida por un canal lateral; todo lo que el reducer necesita entra por sus parámetros, y todo lo que produce sale por su return. Esa clausura es la que permite razonar sobre él como sobre una ecuación.
type Estado = { todos: { id: string; hecho: boolean }[] }
function reducer(estado: Estado, accion: any): Estado {
switch (accion.type) {
case 'todos/todoAgregado':
return { ...estado, todos: [...estado.todos, accion.payload] }
case 'todos/todoAlternado':
return {
...estado,
todos: estado.todos.map((t) =>
t.id === accion.payload ? { ...t, hecho: !t.hecho } : t,
),
}
default:
return estado // regla de oro: accion desconocida devuelve el estado intacto
}
}
Dos obligaciones estructurales aparecen ya en este esqueleto. La primera: ante una acción que no reconoce, el reducer debe devolver el estado sin cambios, nunca undefined ni un error; muchos reducers verán acciones que no les incumben y todos deben dejarlas pasar. La segunda: cuando el estado entrante es undefined —el primer arranque—, el reducer debe devolver su estado inicial, lo que en TypeScript se expresa con un valor por defecto en el parámetro. Un reducer que no cumple estas dos es un reducer roto aunque compile.
Las tres obligaciones de la pureza
Que un reducer sea puro significa tres cosas simultáneas, y basta violar una para perder las garantías. Determinismo: las mismas entradas producen siempre la misma salida; nada de Math.random, Date.now ni lecturas del exterior. Sin efectos: el reducer no llama APIs, no escribe en localStorage, no despacha otras acciones, no muta variables de fuera; su único trabajo es calcular y devolver. Sin mutación: no toca el estado que recibe; construye y devuelve uno nuevo, dejando el anterior exactamente como estaba.
Determinista
Mismas entradas, misma salida, siempre. Un Date.now o un Math.random dentro del reducer rompe la reproducibilidad y con ella el time-travel.
Sin efectos
Nada de llamadas a red, escrituras a disco, logs con consecuencias ni despachos. Los efectos viven fuera, en el middleware del nivel 20.
Sin mutación
El estado entrante es intocable. Devuelves un objeto nuevo con spread o map. El anterior sigue vivo e intacto: eso es lo que guarda el historial.
El error número uno del principiante es estado.todos.push(nuevo) o estado.valor++ dentro del reducer. Muta el array o el objeto que Redux ya tenía guardado, así que el estado “anterior” y el “nuevo” pasan a ser el mismo objeto en memoria. El síntoma es cruel: la comparación por referencia que usan React y las DevTools cree que nada cambió —la referencia es idéntica— y la interfaz no se actualiza, o el time-travel muestra estados corrompidos. La regla que lo previene es mecánica: en un reducer nunca uses métodos que mutan (push, splice, asignación directa); usa siempre los que devuelven copia (spread, map, filter, concat).
Por qué la pureza compra el time-travel
Aquí se paga la deuda conceptual. Si el reducer es puro, entonces el estado actual no es un valor que fue mutándose por el camino, sino el resultado exacto de plegar el historial de acciones sobre el estado inicial: historial.reduce(reducer, inicial). Y de esa igualdad se sigue todo lo demás. Rebobinar a un instante pasado es volver a plegar solo las primeras N acciones. Reproducir una sesión de usuario es aplicar su lista de acciones grabada. Saltar adelante y atrás por la línea temporal —lo que hacen las Redux DevTools— es recalcular estados a partir de un historial que, por ser las acciones datos inertes y el reducer determinista, produce siempre el mismo resultado.
flowchart LR I[estado inicial] --> S1[estado 1] A1[accion 1] --> S1 S1 --> S2[estado 2] A2[accion 2] --> S2 S2 --> S3[estado 3] A3[accion 3] --> S3 S3 -.rebobinar.-> S1 style I fill:#a6e3a1,color:#11111b style A1 fill:#f9e2af,color:#11111b style A2 fill:#f9e2af,color:#11111b style A3 fill:#f9e2af,color:#11111b style S3 fill:#cba6f7,color:#11111b
Ninguna de estas capacidades es un truco añadido a Redux; todas son consecuencias gratuitas de la pureza. Si el reducer llamara a una API o leyera la hora, reproducir el historial daría resultados distintos cada vez —la API responde otra cosa, el reloj avanza— y el time-travel se volvería ficción. La misma propiedad regala otras tres cosas: el testing se vuelve trivial —pasas un estado y una acción, afirmas sobre el resultado, sin mocks ni preparativos—; la recarga en caliente funciona —cambias el código del reducer y reproduces las acciones sobre el nuevo—; y la hidratación en servidor es directa —serializas el estado, lo mandas, lo reconstruyes—. Todas cuelgan del mismo clavo: nuevoEstado = f(estado, accion) con f pura.
Immer y los reducers en 2026
La inmutabilidad manual con spread anidado es correcta pero verbosa y fácil de equivocar: actualizar un campo tres niveles adentro exige tres niveles de copia. Aquí entra Immer, la librería que Redux Toolkit integra de fábrica. Immer te deja escribir código que parece mutar —estado.todos.push(nuevo)— pero opera sobre un borrador (draft) envuelto en un proxy; al terminar, produce un estado nuevo e inmutable aplicando structural sharing, esa técnica del nivel 7 que comparte las ramas no tocadas del árbol.
import { createSlice } from '@reduxjs/toolkit'
const todosSlice = createSlice({
name: 'todos',
initialState: { lista: [] as { id: string; hecho: boolean }[] },
reducers: {
// Parece mutacion; Immer lo convierte en un nuevo estado inmutable.
todoAgregado(estado, accion) {
estado.lista.push(accion.payload)
},
todoAlternado(estado, accion) {
const t = estado.lista.find((x) => x.id === accion.payload)
if (t) t.hecho = !t.hecho
},
},
})
La lección más profunda de los reducers puros no es sintáctica sino arquitectónica, y organiza todo lo que viene después en el track. Redux te obliga a partir tu aplicación en dos regiones con leyes distintas: un núcleo puro, determinista y comprobable —los reducers, donde vive toda la lógica de cómo cambia el estado— y un borde impuro donde ocurren las cosas del mundo real —red, tiempo, azar, disco, la propia interfaz—. Esta partición no es de Redux; es el mismo principio que estructura Elm con sus Cmd y Sub, TCA con su tipo Effect, y toda la programación funcional cuando empuja los efectos al perímetro y guarda el centro limpio. La pureza del reducer es lo que hace que el núcleo sea razonable: puedes entenderlo leyéndolo, probarlo sin montar medio mundo, y reproducirlo a voluntad, precisamente porque le has prohibido tener secretos con el exterior. Y cuando inevitablemente necesites lo impuro —cargar datos, esperar un temporizador—, la respuesta de Redux nunca es “mételo en el reducer”, sino “sácalo al middleware”, que es el tema del nivel 20. Quien entiende esto deja de ver la prohibición de efectos como una molestia y empieza a verla como la frontera que mantiene sano el sistema: el reducer no puede mentir sobre el pasado porque no tiene forma de tocarlo, y ese es exactamente el poder que compras cuando aceptas escribir funciones puras. El determinismo no es una virtud abstracta; es la capacidad concreta de reconstruir cualquier estado que tu app haya tenido, y esa capacidad vale cada spread que escribes.
- Escribe un reducer de lista de tareas con
agregar,alternaryborrar, usando solo operaciones que devuelven copia. Verifica que el estado anterior nunca cambia. - Introduce a propósito una mutación (
push) y observa cómo la UI deja de actualizarse aunque los datos “cambien”. Explica por qué la comparación por referencia falla. - Mete un
Date.now()en el reducer y razona qué le pasaría al time-travel si reprodujeras el historial dos veces. - Escribe un test que pase un estado y una acción y afirme sobre el resultado, sin ningún mock: esa facilidad es la pureza cobrando dividendos.
- Reescribe el reducer con
createSlicede Redux Toolkit y comprueba que el código “mutante” de Immer produce, de hecho, estados nuevos e inmutables.