useReducer como máquina: el motor que ya está en React
React trae de fábrica una función de transición y un intérprete que la ejecuta, y casi nadie los usa como tales. Esta lección convierte el reducer en una máquina de estados de pleno derecho mediante el despacho doble sobre el par estado-evento, la unión discriminada por status y la identidad referencial como señal de transición ignorada. Después construye el intérprete que falta —acciones de entrada con efectos, cancelación estructurada, transiciones diferidas— y delimita con precisión dónde el enfoque deja de compensar frente a un motor dedicado.
La alternativa más barata a XState no se instala: viene con React desde 2019 y la mayoría de los equipos la usa como si fuera un useState con esteroides. Un reducer es literalmente (estado, evento) => estado, es decir, la función de transición del nivel uno con contexto incorporado; lo único que le falta para ser una máquina no es potencia sino disciplina, y la disciplina es gratis. Esta lección no propone escribir reducers más bonitos: propone tratar el reducer como el artefacto formal que ya es, con estados excluyentes declarados, transiciones legales explícitas y una política definida para el evento inaplicable. Cuando termines, la pregunta de si necesitas una librería habrá cambiado de forma, porque tendrás una máquina funcionando y podrás señalar exactamente qué le falta.
- Modelar el estado como unión discriminada y despachar sobre el par estado-evento, no solo sobre el evento.
- Usar la identidad referencial del estado como implementación de la política de ignorado silencioso.
- Construir el intérprete que React no trae: acciones de entrada, cancelación y transiciones diferidas.
- Delimitar el punto exacto en que el reducer deja de compensar frente a un motor dedicado.
El despacho doble: de reducer a máquina
Casi todos los reducers del mundo ramifican sobre el tipo del evento y tratan el estado como un registro mutable de campos. Eso es un almacén con historial, no una máquina. La conversión a máquina consiste en invertir el orden de la pregunta: primero dónde estoy, después qué me ha llegado. Es el despacho doble que caracteriza a δ, y en TypeScript se escribe con una unión discriminada por status.
type Estado =
| { status: 'inactivo' }
| { status: 'cargando'; intento: number }
| { status: 'listo'; datos: string[] }
| { status: 'fallo'; error: string; intento: number }
type Evento =
| { type: 'PEDIR' }
| { type: 'RESUELTO'; datos: string[] }
| { type: 'FALLADO'; error: string }
| { type: 'REINTENTAR' }
export function reducer(estado: Estado, evento: Evento): Estado {
switch (estado.status) {
case 'inactivo':
return evento.type === 'PEDIR' ? { status: 'cargando', intento: 1 } : estado
case 'cargando':
if (evento.type === 'RESUELTO') return { status: 'listo', datos: evento.datos }
if (evento.type === 'FALLADO') return { status: 'fallo', error: evento.error, intento: estado.intento }
return estado
case 'listo':
return evento.type === 'PEDIR' ? { status: 'cargando', intento: 1 } : estado
case 'fallo':
return evento.type === 'REINTENTAR' && estado.intento < 3
? { status: 'cargando', intento: estado.intento + 1 } // guarda dentro de la transicion
: estado
}
}
Fíjate en lo que la unión discriminada consigue sin ninguna librería: el campo datos solo existe en listo y el campo error solo existe en fallo, de modo que el estado imposible de tener datos y error a la vez no es un caso que evitas, sino una expresión que no compila. Ese es el mismo teorema que persiguen los estados de una máquina, obtenido aquí por el sistema de tipos en vez de por el motor. Y la guarda del reintento con límite vive dentro de la rama, exactamente donde una configuración declarativa pondría su condición.
Cada return estado implementa la política de ignorado silencioso, pero además tiene una consecuencia operativa concreta: React compara la referencia devuelta con la anterior y, si es idéntica, sale del renderizado sin recorrer el árbol. La transición ilegal no solo es inocua semánticamente, es gratuita en rendimiento. Un reducer que construye un objeto nuevo en cada rama pierde esa propiedad y renderiza de más por cada evento que su máquina debería estar ignorando.
El intérprete que React no trae
Una función de transición pura no hace nada por sí sola: necesita quien la ejecute, quien dispare los efectos al entrar en un estado y quien los cancele al salir. useReducer te da el primer papel y te deja los otros dos. La forma idiomática de cubrirlos es un efecto anclado al discriminante, que reproduce con notable fidelidad la semántica de Moore que estudiaste en el nivel nueve.
function useRecurso(url: string) {
const [estado, enviar] = useReducer(reducer, { status: 'inactivo' })
useEffect(() => {
if (estado.status !== 'cargando') return // efecto de ENTRADA en un solo estado
const ac = new AbortController()
fetch(url, { signal: ac.signal })
.then((r) => r.json())
.then((datos) => enviar({ type: 'RESUELTO', datos }))
.catch((e) => { if (e.name !== 'AbortError') enviar({ type: 'FALLADO', error: String(e) }) })
return () => ac.abort() // efecto de SALIDA: cancelacion estructurada
}, [estado.status, estado.status === 'cargando' ? estado.intento : 0, url])
return [estado, enviar] as const
}
flowchart LR A[interaccion] --> B[enviar evento] B --> C[reducer decide segun par estado evento] C --> D[nuevo estado o la misma referencia] D -->|referencia nueva| E[React renderiza] D -->|misma referencia| F[React sale sin renderizar] E --> G[useEffect anclado al status como accion de entrada] G -->|limpieza| H[cancelacion al salir del estado] style F fill:#a6e3a1,color:#11111b style G fill:#89b4fa,color:#11111b
El array de dependencias es la parte delicada y merece detenerse. Anclar el efecto a estado.status significa que se dispara al entrar en cargando y se limpia al salir, que es justo la semántica que quieres; incluir además el número de intento es lo que permite que un reintento vuelva a lanzar la petición aunque el estado nominal no haya cambiado de nombre. Ese pequeño truco expone la diferencia real entre un reducer y un motor: en XState la reentrada a un estado ya invoca de nuevo al actor porque el intérprete distingue transición externa de interna, mientras que aquí esa distinción la codificas tú en una lista de dependencias que nadie más va a leer con esa intención.
Unión discriminada
Los datos viven dentro del estado que los justifica. La combinación imposible deja de ser un caso a evitar y pasa a no compilar.
Despacho doble
Ramificar primero por status y después por type es lo que convierte un reducer cualquiera en una tabla de transiciones.
Identidad como política
Devolver el mismo objeto expresa el evento inaplicable y además ahorra un renderizado. Semántica y rendimiento coinciden.
Cleanup como salida
La función de limpieza del efecto es la acción de salida de Moore. Un AbortController te da cancelación estructurada gratis.
La ventaja menos citada de este enfoque es que la función de transición es pura y se prueba sin montar nada: sin árbol de componentes, sin DOM y sin temporizadores. Recorrer el producto de estados por eventos cuesta pocas líneas y produce algo valioso.
const muestras: Estado[] = [
{ status: 'inactivo' }, { status: 'cargando', intento: 1 },
{ status: 'listo', datos: [] }, { status: 'fallo', error: 'x', intento: 3 },
]
const eventos: Evento[] = [{ type: 'PEDIR' }, { type: 'RESUELTO', datos: [] }, { type: 'REINTENTAR' }]
test('el reducer no inventa estados ni saltos', () => {
const observadas: string[] = []
for (const e of muestras) for (const ev of eventos) {
const siguiente = reducer(e, ev)
expect(['inactivo', 'cargando', 'listo', 'fallo']).toContain(siguiente.status)
if (siguiente !== e) observadas.push(`${e.status} --${ev.type}--> ${siguiente.status}`)
}
expect(observadas).toMatchSnapshot() // la tabla que el reducer nunca declaro
})
Ese listado de transiciones observadas es la tabla que el reducer contiene pero no declara, reconstruida por fuerza bruta. Fijarla como instantánea convierte cualquier cambio de comportamiento en un cambio visible en la revisión de código, que es la mejor aproximación disponible a la legibilidad del grafo cuando la definición vive en ramas de control de flujo. Es un sustituto, no un equivalente: la reconstruyes ejecutando, no leyendo, y solo cubre las muestras de estado que se te ocurrió incluir.
Conviene señalar dónde encaja esto en el React de 2026. useActionState cubre el caso particular del reducer con una transición asíncrona ya integrada y estado de pendiente, y es preferible cuando tu máquina tiene exactamente dos estados y un envío; useSyncExternalStore es el puente cuando quieres sacar la máquina del árbol de componentes para que viva fuera de React y sobreviva al desmontaje. Entre ambos queda el territorio de useReducer como máquina: varios estados excluyentes con reglas de paso, dentro del ciclo de vida de una pantalla.
Dónde deja de compensar
El reducer como máquina cubre un tramo sorprendentemente ancho, y por eso el error habitual no es usarlo de menos sino no saber cuándo soltarlo. Estas son las cuatro señales de agotamiento, todas verificables sin discutir de gustos.
Primera: la jerarquía disfrazada. Cuando tu discriminante empieza a llamarse cargando_validando o editando_con_cambios_sin_guardar, no has nombrado estados, has aplanado un producto cartesiano. Ese guion bajo es la jerarquía de Harel pidiendo existir, y aplanarla a mano crece de forma multiplicativa.
Segunda: los efectos que no encajan en el discriminante. Si tu useEffect necesita comparar el estado anterior con el nuevo para decidir si actúa, estás intentando expresar una acción de transición al estilo Mealy con una herramienta que solo sabe de entrada y salida. Se puede, con una referencia al valor previo, pero cada vez que lo haces pagas legibilidad.
Tercera: la orquestación de varios procesos concurrentes. Dos peticiones independientes que avanzan a la vez son dos regiones ortogonales. Con un reducer plano necesitas un estado por combinación, y con dos regiones de tres estados ya son nueve.
Cuarta: nadie puede dibujar tu máquina. La definición es código, no dato, así que ninguna herramienta puede leerla. Si el equipo necesita ver el grafo para discutirlo con producto, el reducer ya no basta; ese es el eje que la lección tres y la cinco desarrollan a fondo.
Un motor rechaza una transición no declarada; un reducer simplemente ejecuta la rama que escribiste. Nada te impide devolver listo desde inactivo sin pasar por cargando si un día tecleas mal el objeto. La legalidad de las transiciones en un reducer es una convención sostenida por revisión de código y pruebas, no una propiedad garantizada por el intérprete. Esa diferencia importa poco con cuatro estados y bastante con catorce, y es el argumento más sólido para migrar que no tiene nada que ver con la potencia.
Toda la industria discute qué librería de máquinas adoptar y casi nadie discute lo único que produce el beneficio: haber decidido que el estado tiene modos excluyentes con nombre y que existen pasos ilegales entre ellos. Esa decisión es anterior a cualquier herramienta y es la que hace el trabajo; el motor solo la vigila mejor. La prueba está en este reducer, que no importa nada y sin embargo elimina los mismos estados imposibles, expresa las mismas guardas, dispara los mismos efectos de entrada y cancela con la misma limpieza que un statechart pequeño. Lo que un motor añade sobre él es real pero es de segunda derivada: garantía en lugar de convención, dato en lugar de código, jerarquía en lugar de nombres compuestos. Por eso la migración honesta nunca va de reducer a XState porque XState sea mejor, sino porque una de esas tres cosas concretas empezó a faltar. Y por eso el equipo que aún no ha convertido sus reducers en máquinas no está a una instalación de distancia de las ventajas del modelo: está a una decisión de diseño de distancia, y esa decisión no la vende nadie en un paquete. Quien la toma con useReducer ya obtuvo la mayor parte del valor y puede negociar el resto con calma; quien instala un motor sin haberla tomado se lleva la ceremonia sin el teorema.
- Elige un reducer real de tu código y reescribe su estado como unión discriminada por
status, moviendo cada campo dentro del único estado que lo justifica. - Invierte el despacho: ramifica primero por
statusy después portype. Anota cuántas transiciones descubres que eran ilegales y estaban permitidas. - Verifica que toda rama sin transición devuelve la misma referencia y comprueba con las herramientas de React que esos eventos ya no provocan renderizado.
- Ancla el efecto asíncrono al discriminante y añade un
AbortControlleren la limpieza. Comprueba que salir del estado cancela de verdad la petición en curso. - Añade un contador de intentos al estado y haz que un reintento vuelva a disparar el efecto sin cambiar el nombre del estado.
- Busca en tu proyecto un discriminante con guion bajo que junte dos dimensiones. Dibuja las dos regiones por separado y cuenta cuántos estados te ahorraría la jerarquía.