intent y reduce: transformar el estado con seguridad
intent abre una corrutina donde vive una operación de negocio; dentro de ella, reduce es la única forma de transformar el estado, y lo hace de forma secuencial y segura. Esta lección explica por qué reduce recibe el estado actual como state y devuelve uno nuevo, en lugar de asignar directamente; por qué esa forma funcional elimina la clase entera de bugs de las actualizaciones perdidas cuando varias corrutinas concurrentes tocan el mismo estado; y cómo Orbit serializa las reducciones para que cada una vea el resultado de la anterior. También cubre blockingIntent para el caso del campo de texto y por qué el determinismo de reduce hace los tests triviales.
Ya sabes dónde vive el estado —en el container— y qué formas tiene de salir. Falta la pregunta central: ¿cómo se cambia? Orbit responde con dos operaciones que trabajan juntas. intent abre una corrutina, un espacio donde una operación de negocio puede suspenderse, esperar la red y orquestar trabajo asíncrono. Y dentro de ese espacio, reduce es el único gesto capaz de mover el estado, con una firma que parece un detalle y es el corazón de todo: no asigna un valor nuevo, sino que recibe el actual y devuelve el siguiente. Esa diferencia —transformar en vez de asignar— es lo que convierte la concurrencia, normalmente una fuente inagotable de bugs sutiles, en algo aburridamente correcto.
- Entender
intentcomo la corrutina donde vive una operación de negocio. - Leer la firma de
reduce: recibestateactual y devuelve el estado siguiente. - Comprender por qué Orbit serializa las reducciones y qué bug —la actualización perdida— elimina.
- Conocer
blockingIntentpara el campo de texto y el determinismo que hace triviales los tests.
intent: la corrutina de una operación de negocio
intent no es un método que ejecutas: es un bloque que lanza una corrutina sobre el scope del container. Dentro, estás en tierra suspend. Puedes llamar funciones que se suspenden, esperar un repositorio, coleccionar un Flow, hacer varias cosas en secuencia o en paralelo. No hay callbacks ni hilos que gestionar: es Kotlin asíncrono normal, con la única particularidad de que el receptor del bloque te da acceso a reduce y postSideEffect.
fun cargarPerfil(id: String) = intent {
reduce { state.copy(cargando = true) }
val perfil = repo.obtener(id) // suspende, no bloquea
reduce { state.copy(cargando = false, perfil = perfil) }
}
Léelo de arriba abajo: marca cargando, espera la red, guarda el resultado y desmarca cargando. Tres pasos secuenciales, dos de los cuales tocan el estado y uno espera. Que intent sea una corrutina significa que ese repo.obtener(id) puede tardar un segundo sin bloquear nada; la corrutina se suspende y el hilo queda libre. Volveremos sobre esta integración con corrutinas en la última lección; aquí lo que importa es que intent es el dónde y reduce es el cómo del cambio de estado.
reduce: transformar, no asignar
Mira la firma con cuidado, porque toda la seguridad de Orbit está en ella. Dentro de reduce, state es el estado actual del container, y lo que el bloque devuelve es el estado siguiente.
reduce { state.copy(total = state.total + 1) }
No escribes state = nuevoEstado ni container.state = .... Escribes una función pura del estado viejo al nuevo, casi siempre con copy de una data class para producir una copia inmutable con un campo cambiado. Es el mismo principio del reducer puro que viste en Redux, reducido a su expresión mínima: (estadoActual) -> estadoSiguiente. La inmutabilidad no es un capricho: es lo que permite que stateFlow compare referencias y solo notifique cuando el estado de verdad cambió.
La razón de que sea una transformación y no una asignación se vuelve evidente en cuanto hay concurrencia. Imagina dos intent que se ejecutan casi a la vez y ambos quieren incrementar el total. Con asignación directa, ambos leerían el mismo valor viejo, ambos calcularían el mismo nuevo y una de las dos escrituras se perdería: la actualización perdida, el bug concurrente más clásico que existe. Con reduce, cada bloque recibe el estado más reciente en el momento de ejecutarse, no una copia congelada de antes.
Por qué las reducciones son secuenciales
Orbit garantiza que las reducciones se aplican una a una, en orden, aunque muchos intent corran concurrentemente. El container serializa las reducciones: mientras una se ejecuta, ninguna otra puede colarse en medio, y la siguiente siempre parte del resultado de la anterior. Es como si todas las escrituras al estado pasaran por un embudo de un solo carril, aunque el trabajo asíncrono que las precede —las esperas de red— sí ocurra en paralelo.
fun incrementarDosVeces() {
intent { reduce { state.copy(total = state.total + 1) } }
intent { reduce { state.copy(total = state.total + 1) } }
// Garantizado: el total sube en 2, nunca en 1
}
Aquí está la clave de la ergonomía: consigues correctitud concurrente sin escribir un solo Mutex, synchronized ni AtomicInteger. La parte cara de la concurrencia —esperar la red, hacer trabajo pesado— ocurre en paralelo dentro de los intent; la parte peligrosa —mutar el estado compartido— está serializada por construcción. Separar esos dos ejes es exactamente lo que uno querría hacer a mano y casi siempre hace mal.
flowchart TD I1[intent A trabajo async en paralelo] --> R[Embudo de reduce un solo carril] I2[intent B trabajo async en paralelo] --> R I3[intent C trabajo async en paralelo] --> R R -->|una reduccion a la vez, en orden| S[Estado siguiente coherente] style R fill:#cba6f7,color:#11111b style S fill:#a6e3a1,color:#11111b
Existe un caso donde la asincronía estorba: el campo de texto con enlace de dos vías. Si cada pulsación lanza un intent normal, el reduce puede llegar un instante tarde y el cursor salta o se pierden letras. Para eso está blockingIntent: aplica el reduce de forma síncrona respecto a la emisión, manteniendo el texto y el estado perfectamente alineados. Regla práctica: intent para casi todo; blockingIntent solo para el enlace directo de un TextField.
El logro de reduce es psicológico además de técnico: elimina el miedo. Programar estado compartido bajo concurrencia es, en casi cualquier lenguaje, un campo minado —cerrojos que se olvidan, órdenes que se invierten, actualizaciones que se pisan, bugs que solo aparecen en producción una vez de cada mil—. Orbit desactiva la mina entera con una sola decisión de diseño: que la única forma de tocar el estado sea una transformación pura estado -> estado que el container serializa. Fíjate en la elegancia de la división de trabajo. La concurrencia real —lo que tarda— se queda dentro del intent, donde las corrutinas la manejan sin bloquear. La mutación —lo que es peligroso compartir— se estrangula a un solo carril donde el orden y la atomicidad están garantizados. Nunca lees un estado obsoleto porque reduce te entrega siempre el más reciente; nunca pierdes una escritura porque nadie más puede escribir mientras tú reduces. Y como cada reducción es una función pura, es determinista y por tanto trivial de testear: dado este estado y esta intención, sale exactamente este estado, sin escenarios ni mocks de temporización. Lo que en otras arquitecturas exige razonar sobre entrelazados de hilos, aquí se reduce a leer una función de un argumento. Esa es la promesa madura de MVI cumplida en su forma más pequeña: no que la concurrencia desaparezca, sino que se confine a un lugar donde ya no puede hacerte daño.
- Escribe un
intentque marquecargando, espere un repositorio y guarde el resultado, todo conreduceycopy. - Explica, con el ejemplo de dos incrementos concurrentes, por qué la asignación directa perdería una actualización y
reduceno. - Distingue qué parte de un
intentcorre en paralelo y qué parte está serializada, y por qué esa división es deseable. - Identifica en una app tuya el único sitio donde usarías
blockingIntenty justifica por qué el resto no lo necesita. - Escribe con palabras un test de una intención: qué estado inicial afirmas, qué intención invocas y qué estado y side effect esperas. Observa que no necesitas hablar de hilos.