El reducer puro: de estado e intent al siguiente estado
El reducer es la función que dado el estado actual y un intent devuelve el estado siguiente, y su valor arquitectónico no está en lo que hace sino en lo que tiene prohibido hacer. Esta lección define la firma canónica en Kotlin, desmenuza qué significa pureza en términos operativos —sin entrada oculta, sin salida oculta, sin dependencia del reloj ni del azar—, muestra cómo la inmutabilidad con copy y el when exhaustivo hacen del reducer una máquina de estados legible entera, y demuestra por qué esa pureza convierte los tests en aritmética: una tabla de estado inicial e intent frente al estado esperado, sin mocks ni esperas.
La función más importante de una aplicación MVI cabe en una línea de firma y no hace absolutamente nada interesante: recibe un estado y un intent, devuelve otro estado, y ahí termina su ambición. Esa modestia es su poder. Un reducer no llama a la red, no toca el disco, no lee el reloj, no escribe en un log, no lanza corrutinas, no conoce Android. Toda la tentación de hacer cosas se ha desplazado fuera de él, y lo que queda dentro es únicamente una tabla de decisiones sobre datos: si el estado era así y ocurrió esto, entonces el estado pasa a ser así otro. Lo que ganas a cambio de esa renuncia es una propiedad rara en el software de interfaz: la de poder afirmar con certeza qué va a pasar, sin ejecutar la aplicación, sin emulador y sin suerte.
- Escribir la firma canónica del reducer y entender por qué devuelve estado en vez de mutarlo.
- Enunciar la pureza en términos verificables: sin entradas ni salidas ocultas, sin reloj ni azar.
- Construir el cuerpo con
whenexhaustivo ycopypara producir estados inmutables. - Convertir la comprobación del reducer en una tabla de casos sin mocks, esperas ni instrumentación.
La firma manda sobre el cuerpo
Antes de escribir una sola rama, escribe la firma. Es la que impone la disciplina y la que hace imposibles categorías enteras de error.
fun reducir(estado: CarritoState, intent: CarritoIntent): CarritoState =
when (intent) {
is CarritoIntent.CantidadCambiada ->
estado.copy(lineas = estado.lineas.conUnidades(intent.linea, intent.unidades))
is CarritoIntent.CuponEscrito ->
estado.copy(cupon = intent.texto, errorCupon = null)
CarritoIntent.ReintentoPulsado ->
estado.copy(cargando = true, error = null)
CarritoIntent.PantallaMostrada ->
estado.copy(cargando = true)
else -> estado
}
Antes de mirar el cuerpo, repara en dónde está escrita esa función: es una función libre, fuera de la clase, sin this que la ate a un ViewModel. Esa colocación no es un capricho de estilo. Una función libre no puede leer propiedades de la instancia por accidente, no arrastra el ciclo de vida de Android y se importa desde un test de JVM sin ninguna ceremonia. Cuando el reducer vive dentro del ViewModel, la tentación de leer un campo de la clase está siempre a un carácter de distancia, y basta con que alguien ceda una vez para que la pureza deje de ser cierta sin que nada lo señale.
Fíjate ahora en tres detalles del cuerpo que tampoco son accidentales. El estado entra como parámetro y no se lee de ninguna propiedad de la clase: no hay entrada oculta. El resultado sale por el return y no por un emit ni una asignación: no hay salida oculta. Y cada rama produce un valor nuevo con copy en lugar de mutar el que recibió: el estado que entró sigue existiendo intacto, lo que permite compararlos, guardarlos y reproducirlos.
El else del ejemplo es una comodidad, no una virtud. Si lo suprimes y enumeras cada intent, el compilador te obligará a decidir explícitamente qué hace el reducer con cada uno, incluido el caso legítimo de devolver el estado sin cambios. Esa obligación es justamente lo que buscas: quieres que añadir un intent nuevo rompa el build hasta que alguien tome la decisión, no que caiga silenciosamente en un cajón de sastre donde nadie lo eche de menos.
Qué significa puro, operativamente
Pureza suena a categoría teórica y en realidad es una lista corta de prohibiciones comprobables. Un reducer es puro si, ejecutado dos veces con los mismos argumentos, devuelve valores iguales y no deja rastro en ninguna parte.
Sin entrada oculta
Todo lo que necesita llega por parámetro. Nada de System.currentTimeMillis, ni Random, ni leer una preferencia o un singleton.
Sin salida oculta
No escribe en disco, no publica en un flujo, no lanza notificaciones. Su único efecto observable es el valor devuelto.
Sin mutación
Trabaja con data class inmutables y copy. El estado recibido queda disponible para comparar, registrar o rebobinar.
Determinista
Los mismos argumentos producen siempre el mismo resultado, hoy, mañana y en el ordenador de otra persona.
Hay una prueba casera para verificarlo sin filosofar: intenta mover el reducer a un módulo Kotlin puro, sin dependencia de Android. Si compila, es casi seguro que es puro, porque la mayoría de las impurezas que importan llegan por esa vía —contexto, recursos, registro, preferencias, tiempo del sistema—. Si no compila, el compilador acaba de señalarte con precisión cada línea que había que sacar. Muchos equipos convierten esa prueba en estructura permanente y colocan el reducer y el estado en un módulo compartido, precisamente para que la barrera la vigile la máquina y no la buena voluntad.
Las dos infracciones más frecuentes son también las más inocentes de aspecto. La primera es el reloj: calcular dentro del reducer si un cupón caducó consultando la hora actual convierte una función determinista en una función que falla a medianoche. La solución es transportar el instante como dato dentro del intent, de modo que el reducer siga siendo una tabla y el tiempo entre por la puerta. La segunda es el registro: una llamada a Log parece inofensiva y ya es un efecto, además de una dependencia de Android que impide correr el reducer en un test de JVM puro. Sácala al borde, donde los efectos son legítimos y están vigilados.
Queda una duda razonable: si el reducer no puede hacer nada, ¿de dónde salen las llamadas a la red? La respuesta corta es que el reducer no las hace, las provoca, y que quien las ejecuta es otra capa que verás en la lección siguiente. Lo importante ahora es que esa separación no debilita la pureza: el reducer decide qué estado corresponde y, como mucho, deja constancia en el estado de que hay trabajo pendiente. Nunca lo realiza.
La comprobación se vuelve aritmética
Cuando la pureza se respeta, el test deja de ser un ejercicio de andamiaje y se convierte en una afirmación sobre valores. No hay ViewModel que construir, ni dispatcher que sustituir, ni tiempo que adelantar, ni repositorio que fingir: hay un estado, un intent y un estado esperado.
@Test
fun `escribir cupon limpia el error anterior`() {
val inicial = CarritoState(cupon = "", errorCupon = "Cupon no valido")
val siguiente = reducir(inicial, CarritoIntent.CuponEscrito("VERANO"))
assertEquals("VERANO", siguiente.cupon)
assertNull(siguiente.errorCupon)
}
No hay runTest, ni Dispatchers.setMain, ni un repositorio falso, ni una espera. Tampoco hay margen para el test que pasa por casualidad: si la aserción se cumple una vez, se cumple siempre, porque no queda ninguna variable oculta que pueda cambiar entre ejecuciones. Un test así no es lento ni frágil por construcción, no por disciplina.
flowchart LR E0[Estado inicial] --> R[reducir funcion pura] I[Intent] --> R R --> E1[Estado siguiente] E1 --> A[Asercion directa sobre el valor] style R fill:#cba6f7,color:#11111b style A fill:#a6e3a1,color:#11111b
De aquí nace la práctica de la tabla: una lista de tripletas de estado inicial, intent y estado esperado, recorrida en un test parametrizado. Esa tabla es la especificación ejecutable de la pantalla, y crece de forma lineal con las reglas en vez de exponencialmente con las combinaciones de infraestructura. También es la base de la comprobación basada en propiedades: como el reducer es una función total y determinista, se le pueden lanzar secuencias aleatorias de intents y afirmar invariantes —que el total nunca sea negativo, que cargando y error jamás sean ciertos a la vez— sin que ninguna aserción dependa del entorno.
Y de la misma pureza sale, casi gratis, la reproducción de una sesión entera. Si el reducer es una función de dos argumentos, entonces aplicarlo sobre una lista de intents es un pliegue, y el estado final de una sesión es simplemente el resultado de doblar la historia sobre el estado inicial.
val estadoFinal = trazaDeIntents.fold(CarritoState()) { acc, i -> reducir(acc, i) }
Esa línea es, literalmente, la reproducción de un bug reportado por un usuario: guarda los intents, dóblalos y observa. Con la lista de estados intermedios en la mano tienes también el viaje en el tiempo, y con dos versiones del reducer aplicadas a la misma traza tienes una comparación exacta de qué cambió tu refactor.
Un reducer que se puede componer
Una pantalla grande no necesita un when de doscientas ramas. Como el reducer es una función, se compone igual que cualquier otra: se parte por secciones del estado y se delega, y la función de nivel superior solo enruta. La pureza se conserva en la composición porque componer funciones puras da funciones puras.
fun reducir(estado: PedidoState, intent: PedidoIntent): PedidoState = when (intent) {
is PedidoIntent.DelCarrito ->
estado.copy(carrito = reducirCarrito(estado.carrito, intent.hijo))
is PedidoIntent.DelPago ->
estado.copy(pago = reducirPago(estado.pago, intent.hijo))
PedidoIntent.ConfirmarPulsado ->
estado.copy(fase = Fase.Confirmando)
}
Cada subreducer se prueba con su propia tabla y desconoce el estado de los demás, que es justo lo que quieres: la sección de pago no puede corromper la de carrito porque ni siquiera la recibe. El reducer padre queda como un enrutador legible de un vistazo, y las reglas que cruzan secciones —que confirmar exige carrito no vacío y pago válido— viven en él, que es el único punto que ve ambas cosas a la vez. Esa es también la señal para dividir: si una regla necesita mirar dos secciones, pertenece al padre; si solo mira una, baja.
En Orbit el bloque reduce recibe state y devuelve el estado siguiente, que es exactamente la firma de esta lección con otro envoltorio. La forma más limpia de combinar ambas cosas es que el cuerpo del reduce no contenga lógica, sino una llamada a tu función libre: reduce { reducir(state, intent) }. Así conservas el embudo secuencial que la librería te da y, a la vez, toda la lógica queda en una función que se prueba sin construir ningún container.
Conviene resistir dos tentaciones en este punto. La primera es hacer el reducer parcial, devolviendo un valor nulo cuando el intent no le corresponde: una función total que devuelve el estado sin cambios es más fácil de razonar y no obliga a nadie a comprobar nulos. La segunda es meter en el reducer un cálculo derivado caro y guardarlo en el estado por comodidad; si el valor se puede calcular de los demás campos, calcularlo en la vista o en una propiedad val del estado evita mantener dos verdades sincronizadas a mano.
El reducer puro es la pieza donde MVI paga la deuda que contrajo al obligarte a escribir un tipo para cada gesto. La recompensa no es estética sino epistemológica: la pureza es lo que hace que una afirmación sobre el comportamiento del sistema sea demostrable en lugar de plausible. En una arquitectura donde la lógica está esparcida en callbacks que tocan el reloj, la red y variables compartidas, la pregunta qué pasa si el usuario pulsa esto ahora solo se responde ejecutando y mirando, y la respuesta es válida hasta la próxima vez. Con un reducer puro esa misma pregunta se responde leyendo una rama de un when, y la respuesta vale siempre, porque no queda ningún factor externo que pueda alterarla. Hay una consecuencia más profunda todavía. La pureza es lo que permite tratar la ejecución del programa como una sucesión de valores en lugar de como una historia de mutaciones. Si cada estado es inmutable y cada transición es una función, entonces la sesión entera de un usuario es un pliegue: parte del estado inicial y aplica el reducer sobre la lista de intents. De ahí salen, sin trabajo adicional, el viaje en el tiempo —basta guardar los estados—, la reproducción de bugs —basta guardar los intents—, la comparación de dos versiones del reducer sobre la misma traza, y la posibilidad de razonar sobre concurrencia sin pensar en hilos, porque una función pura no tiene sección crítica que proteger. Nada de esto es un añadido de la librería; todo se sigue de haber prohibido a una función hacer cosas. Por eso la disciplina del reducer merece defenderse con obstinación frente al atajo de meter dentro una llamadita, un log o una lectura del reloj: cada una de esas concesiones no rompe una regla de estilo, cancela una propiedad, y con ella todas las garantías que colgaban de esa propiedad.
Vale la pena cerrar con la advertencia que la experiencia impone: la pureza no se pierde de golpe sino por erosión. Nadie decide un martes que el reducer va a llamar a la red; lo que ocurre es que alguien añade un registro para depurar y no lo quita, y meses después otra persona, viendo que ya hay una llamada externa, añade una segunda con menos escrúpulo. Por eso la defensa efectiva no es la vigilancia en la revisión de código sino la barrera estructural: un módulo sin dependencias de Android, un reducer que es función libre y una batería de tests que corren en milisegundos y dejarían de hacerlo en cuanto alguien introdujera una espera.
- Extrae la lógica de transición de un ViewModel tuyo a una función libre con la firma de estado e intent al estado siguiente, sin referencias a la clase.
- Busca en ella toda entrada oculta: relojes, aleatoriedad, singletons, preferencias. Conviértelas en datos que viajen dentro del intent y anota qué ganaste en cada caso.
- Elimina la rama
elsedelwheny decide explícitamente el resultado de cada intent, incluidos los que devuelven el estado sin cambios. - Escribe una tabla de al menos ocho tripletas de estado inicial, intent y estado esperado, y ejecútala como test parametrizado sin ningún mock.
- Formula dos invariantes que tu estado deba cumplir siempre, genera secuencias aleatorias de intents y comprueba que ninguna combinación las viola.