reduce: mutar el estado sin carreras
Dentro de un intent puede pasar cualquier cosa, pero el estado solo cambia en un sitio: el bloque reduce. Esta lección explica por qué esa concentración es una necesidad técnica y no un capricho de estilo. Se estudia la firma del bloque y el receptor que expone el estado vigente, la diferencia entre leer state fuera y dentro del reductor y el bug de lectura obsoleta que aparece cuando se confunden, el mecanismo de exclusión que serializa las mutaciones aunque haya varios intents solapados, por qué el bloque debe ser puro y qué ocurre exactamente si se mete una suspensión dentro, y cómo escribir varias mutaciones pequeñas en vez de una grande sin perder coherencia visual.
Un intent es un territorio permisivo: allí se llama a la red, se lee el disco, se espera un temporizador y se toman decisiones. Pero hay una operación que ese territorio no permite hacer libremente, y es precisamente la que parece más inocente: cambiar el estado. Orbit la confina en un bloque diminuto llamado reduce, y toda la seguridad del modelo descansa en esa reclusión. La razón es aritmética antes que arquitectónica: en cuanto dos flujos de ejecución pueden leer un valor, calcular una versión modificada y escribirla, existe la posibilidad de que uno pise el trabajo del otro. La cola de intents no basta para impedirlo, porque las suspensiones ceden el turno. Hace falta algo más pequeño, más rápido y estrictamente serializado. Eso es reduce, y entender por qué su cuerpo debe ser breve y puro es entender por qué el estado nunca se corrompe.
- Leer con precisión la firma de
reducey saber qué representa el receptorstatedentro del bloque. - Distinguir la lectura de
statedentro y fuera del reductor, y reconocer el bug de estado obsoleto que produce confundirlas. - Explicar el mecanismo de exclusión que serializa las mutaciones aunque haya intents solapados.
- Escribir reductores puros y breves, y justificar por qué varias mutaciones pequeñas superan a una grande.
La firma manda: una función del estado al estado
reduce recibe un bloque que se ejecuta con el estado vigente como receptor y cuyo valor de retorno pasa a ser el estado nuevo. Nada más. No hay parámetro de entrada porque el estado está disponible como state, y no hay asignación porque el resultado de la expresión es la mutación.
fun incrementar() = intent {
reduce { state.copy(contador = state.contador + 1) }
}
Esa forma tiene una propiedad que conviene nombrar: el reductor es una función total del estado anterior al estado siguiente. Dado el mismo estado de entrada y las mismas capturas, produce siempre la misma salida. Es reproducible, es comparable y es trivialmente comprobable en una prueba sin ningún doble, porque no depende de nada externo salvo lo que la lambda haya capturado del intent que la contiene.
Y aquí aparece la distinción más importante de la lección. Dentro de un intent, state también es accesible fuera del reductor, y las dos lecturas no significan lo mismo. La de fuera devuelve el estado en ese instante, que puede quedar obsoleto en cuanto el bloque suspenda. La de dentro devuelve el estado en el momento de aplicar la mutación, ya sincronizado con cualquier cambio que otro intent haya aplicado mientras tanto.
// Incorrecto: captura el estado antes de suspender
fun anadirIncorrecto(item: Item) = intent {
val actuales = state.items // fotografia vieja
val validado = repo.validar(item) // aqui pueden entrar otras mutaciones
reduce { state.copy(items = actuales + validado) } // pisa lo ocurrido
}
// Correcto: la lectura ocurre dentro del reductor
fun anadirCorrecto(item: Item) = intent {
val validado = repo.validar(item)
reduce { state.copy(items = state.items + validado) }
}
La diferencia entre ambos fragmentos es una sola palabra y produce un fallo que en desarrollo casi nunca aparece: hace falta que dos intents se solapen y que la red tarde lo suficiente. En producción, con redes reales y usuarios impacientes, aparece como elementos que desaparecen de una lista sin motivo aparente.
Lee state fuera del reductor solo para decidir —comprobar una precondición, elegir una rama, obtener un parámetro para la petición— y nunca para calcular el estado siguiente. Todo cálculo cuyo resultado vaya a escribirse debe leer sus insumos dentro del bloque reduce. Si necesitas un valor de antes y otro de después, pásalo capturado como dato y combínalo dentro.
Por qué está aislado: exclusión sobre la cola
La lección anterior estableció que la cola serializa el inicio de los intents pero no impide que dos bloques estén vivos a la vez esperando sus respectivas respuestas. Si la mutación pudiera hacerse en cualquier punto del bloque, ese solapamiento bastaría para producir carreras. Orbit lo evita añadiendo una segunda capa: las aplicaciones de reductor se serializan entre sí mediante exclusión mutua, de modo que mientras un reductor está corriendo ningún otro puede aplicarse.
sequenceDiagram participant A as Intent A participant B as Intent B participant C as Estado del container A->>C: reduce aplica y libera A-->>A: suspende esperando la red B->>C: reduce aplica sobre el estado ya actualizado B-->>B: suspende esperando la red A->>C: reduce lee el estado vigente no el antiguo B->>C: reduce se aplica despues sin solapamiento
El diagrama muestra la propiedad esencial: aunque los dos intents se entrelazan libremente en sus esperas, las cuatro mutaciones se aplican una detrás de otra, cada una viendo el resultado de la anterior. No hay ningún instante en que dos reductores compartan una lectura del estado. Por eso la operación de lectura, cálculo y escritura, que sería insegura si estuviera repartida por el bloque, es atómica cuando está confinada dentro del reductor.
De aquí sale la razón por la que el bloque debe ser breve. Cada reductor ocupa el turno exclusivo mientras se ejecuta, y todo lo que se meta dentro retrasa a los demás. Un reductor que ordena diez mil elementos, formatea fechas o construye cadenas largas convierte una sección crítica en un cuello de botella, y lo hace además en el peor sitio posible, porque el trabajo de presentación no tiene ninguna necesidad de estar protegido.
Conviene precisar algo que confunde a quien viene de la programación con hilos: esta protección no es afinidad a un hilo. El reductor no se ejecuta siempre en el mismo hilo ni tiene por qué hacerlo; lo que se garantiza es que, sea cual sea el hilo, no hay dos aplicaciones solapadas. La distinción importa porque explica por qué no hay que sincronizar nada a mano ni marcar campos como volátiles: la exclusión y la visibilidad entre hilos las resuelve el mecanismo de suspensión de las corrutinas, no una convención sobre en qué hilo vive el estado. Razonar en términos de turnos y no de hilos es lo que hace que este modelo se pueda seguir sin pensar en memoria compartida.
Lo que sí va dentro
Copias del estado con campos sustituidos, incrementos, alternancias de banderas, adiciones y filtros baratos sobre colecciones pequeñas. Operaciones cuyo coste es despreciable frente al de mantener el turno.
Lo que va fuera
Ordenaciones costosas, transformaciones de listas grandes, formateo de texto, cálculos derivados. Calcula el valor antes en el cuerpo del intent y limita el reductor a colocarlo.
El reductor debe ser puro, y no es una recomendación
Un reductor puro no llama a la red, no escribe en disco, no emite efectos secundarios, no toca ninguna propiedad mutable y no depende de la hora actual ni de un generador aleatorio. Cada una de esas prohibiciones tiene una consecuencia concreta si se rompe.
Empezando por la más directa: reduce no es un bloque suspendible, así que ni siquiera se puede llamar dentro a una función que suspenda. El compilador lo impide, y esa imposibilidad es una barrera de diseño, no un descuido de la API. Si necesitas datos remotos para calcular el estado siguiente, tráelos antes en el cuerpo del intent y entra en el reductor con el valor ya en la mano.
Las demás prohibiciones no las impide el compilador y hay que sostenerlas por disciplina. Meter una llamada a un registro de analítica dentro de un reductor parece inocuo hasta que se recuerda que la sección crítica es corta por diseño y que esa llamada la alarga. Leer la hora del sistema o generar un identificador aleatorio dentro del bloque rompe la reproducibilidad y convierte una prueba determinista en una prueba con azar; la corrección es inyectar esos valores como dependencias y capturarlos desde fuera.
fun marcarLeido(id: MensajeId) = intent {
val instante = reloj.ahora() // dependencia inyectada, fuera del reductor
reduce {
state.copy(
mensajes = state.mensajes.map { m ->
if (m.id == id) m.copy(leidoEn = instante) else m
},
)
}
repo.marcarLeido(id, instante) // el trabajo sucio, despues
}
Fíjate en el orden. La mutación va primero para que la interfaz responda de inmediato, y la persistencia después. Si la persistencia falla, otro reduce revierte o marca el error. Esa secuencia —mutar, trabajar, mutar— es la forma canónica del intent optimista, y solo funciona porque las dos mutaciones son baratas y están aisladas.
Varias mutaciones pequeñas, no una grande
Existe la tentación de reunir todos los cambios de un intent en un único reductor final, con la idea de que así la interfaz ve una sola transición. Es un error de bulto, y por dos razones distintas.
La primera es que el estado debe reflejar el progreso mientras el trabajo ocurre. Si el reductor de carga y el de resultado son el mismo bloque al final, el usuario no ve nunca el indicador de progreso, porque el estado pasa de reposo a resultado en un solo salto. Marcar el inicio del trabajo con su propia mutación no es opcional: es la forma en que el estado deja de mentir sobre lo que la aplicación está haciendo.
La segunda es que agrupar alarga la sección crítica y acopla cambios que no tienen por qué viajar juntos. Dos mutaciones pequeñas consecutivas producen dos emisiones en el flujo de estado, y eso es exactamente lo que se quiere: cada emisión describe una situación verdadera del sistema en ese instante.
Sobre la preocupación legítima del parpadeo conviene ser preciso: el flujo de estado es conflado, de modo que si dos mutaciones se aplican con microsegundos de diferencia, un coleccionista lento puede recibir solo la última. La coalescencia la hace el flujo, no tú, y por eso no hace falta agrupar a mano para evitar redibujados. Lo que sí hace falta es que cada estado intermedio sea, por sí mismo, una descripción coherente de la pantalla: nunca una mezcla imposible como cargando y con error a la vez.
fun refrescar() = intent {
reduce { state.copy(fase = Fase.Cargando, error = null) } // estado coherente 1
val resultado = runCatching { repo.leer() }
reduce {
resultado.fold(
onSuccess = { datos -> state.copy(fase = Fase.Listo, items = datos) },
onFailure = { e -> state.copy(fase = Fase.Fallo, error = e.visible()) },
)
} // estado coherente 2
}
El uso de un tipo enumerado o sellado para la fase, en lugar de varias banderas booleanas independientes, es la forma más económica de hacer imposible la mezcla incoherente. Con tres booleanos hay ocho combinaciones y solo tres tienen sentido; con una fase hay exactamente tres. El reductor no necesita entonces protegerse de nada, porque el tipo ya impide escribir el estado imposible, y el revisor no necesita comprobar invariantes que el compilador ya sostiene.
Hay una última consideración práctica sobre el número de mutaciones. Que sean varias no significa que deban ser muchas: un intent con siete reductores casi siempre está describiendo pasos internos que a la interfaz no le importan, y cada emisión superflua es una oportunidad de que un observador vea un estado que no representa nada útil. La regla razonable es emitir una mutación por cada cambio observable en lo que el usuario ve, ni más ni menos.
Vale la pena mirar el sistema entero desde este bloque, porque desde aquí se ve su geometría. Un feature de Orbit tiene una zona ancha y desordenada —los intents, donde hay espera, latencia, fallos y entrelazado— y un punto estrecho por el que toda la información tiene que pasar para convertirse en estado. Esa forma de embudo no es un detalle de implementación: es la traducción a código de una idea muy antigua en la ingeniería de sistemas concurrentes, que dice que la concurrencia solo es manejable si se puede identificar y minimizar el conjunto de operaciones que necesitan ser atómicas. Todo lo demás puede ocurrir en cualquier orden, en cualquier hilo y en cualquier momento; lo único que exige protección es la transición del estado compartido. Lo que Orbit añade a esa idea es hacerla imposible de eludir por accidente: no ofrece ninguna otra forma legítima de escribir el estado, no permite suspender dentro de la sección crítica, y coloca el receptor del estado vigente justo donde debe leerse, de modo que escribir el código correcto es más cómodo que escribir el incorrecto. La consecuencia intelectual es enorme y suele pasar desapercibida: si todos los reductores de un feature son puros y breves, entonces la historia completa del estado de esa pantalla es una composición de funciones deterministas aplicadas en un orden total, y por tanto es reproducible, registrable y depurable como una secuencia de valores. Un sistema con hilos, redes y usuarios impredecibles queda reducido, en el único plano que importa, a una lista de transiciones puras. Esa reducción es la que convierte una aplicación concurrente en algo que un humano puede sostener en la cabeza.
- Busca en tu código una lectura de
statefuera del reductor cuyo valor acabe escribiéndose y reescríbela. Explica qué escenario concreto de solapamiento estabas tolerando. - Escribe un intent optimista que mute, persista y revierta ante fallo, y comprueba que ningún camino deja el estado incoherente.
- Mide o razona el coste de un reductor que ordena una lista grande y muévelo fuera del bloque sin cambiar el resultado visible.
- Sustituye un reductor final único por dos mutaciones y justifica qué información gana la interfaz con la primera de ellas.
- Argumenta por qué el compilador impide suspender dentro de
reducey qué invariante se rompería si lo permitiera.