wandres.dev
INTENTS Y REDUCERS · la transición

Intents que disparan trabajo: separar lo puro de lo impuro

La mayoría de los intents no solo cambian el estado: también obligan a hablar con la red, la base de datos o el sistema. Esta lección establece la separación entre la parte pura que reduce y la parte impura que ejecuta, muestra el patrón de tres tiempos —reducir a un estado de espera, lanzar el trabajo, devolver el resultado como un intent nuevo—, contrasta el efecto declarado como dato con el efecto ejecutado en sitio, explica cómo Orbit encaja la corrutina de intent con el embudo de reduce, y advierte del punto exacto donde la impureza vuelve a filtrarse hacia el reducer sin que nadie se dé cuenta.

⏱ 18 min

Un reducer puro es una promesa hermosa y, tomada al pie de la letra, una aplicación inútil: si nadie puede hablar con la red, la pantalla no tendrá jamás nada que mostrar. La cuestión no es evitar la impureza, que es el motivo mismo de que exista el programa, sino confinarla. MVI resuelve el problema con una división del trabajo tan simple que cuesta creer lo lejos que llega: la decisión sobre qué debe pasar es pura y vive en el reducer; la ejecución de lo que pasa es impura y vive en un borde estrecho, vigilado y explícito. Entre ambos no hay negociación ni zona gris, y lo que devuelve el borde no es un valor que el reducer consume por su cuenta, sino un intent nuevo que vuelve a entrar por la puerta principal. El ciclo se cierra sobre sí mismo, y esa clausura es lo que impide que la impureza se disperse.

🎯 Al terminar esta lección sabrás
  • Descomponer cada intent en la decisión pura y el trabajo impuro que puede acompañarla.
  • Aplicar el patrón de tres tiempos: reducir a espera, ejecutar en el borde, reinyectar el resultado como intent.
  • Distinguir el efecto declarado como dato del efecto ejecutado en el sitio, y saber qué gana cada opción.
  • Detectar las filtraciones habituales de impureza dentro del reducer antes de que se vuelvan estructurales.

Dos preguntas por cada intent

La distinción tiene una prueba mecánica que evita discusiones: si la respuesta a la pregunta puede escribirse sin salir del proceso y sin esperar a nadie, es pura; si requiere que algo fuera de tu programa conteste, es impura. Ante cualquier intent, plantea siempre las mismas dos preguntas en este orden. Primero: ¿cómo cambia el estado ahora mismo, con la información que ya tengo? Esa respuesta es el reducer, es inmediata y es pura. Segundo: ¿qué trabajo del mundo exterior queda pendiente por culpa de este hecho? Esa respuesta es el efecto, es diferida y es impura. Separarlas mentalmente antes de escribir código evita la mezcla que después cuesta deshacer.

🧮

Decisión

Qué estado corresponde a este intent aquí y ahora. Se calcula sin salir del proceso y se comprueba con una aserción.

🌐

Ejecución

Qué hay que pedirle al mundo. Sucede fuera, tarda, puede fallar y no tiene sitio dentro del reducer.

↩️

Retorno

El mundo contesta y su respuesta entra como un intent nuevo, con el mismo rango que los del usuario.

🧭

Orquestación

Quién decide lanzar el trabajo: la capa que ve el estado siguiente y sabe que quedó algo pendiente.

El patrón de tres tiempos

En Orbit los dos lados conviven dentro de la misma función: intent abre la corrutina donde ocurre lo impuro, y reduce es el único gesto que toca el estado, siempre con una transformación pura. La estructura canónica es esta.

fun buscar(consulta: String) = intent {
    // 1. Puro: el estado refleja que hay trabajo en curso.
    reduce { reducir(state, BusquedaIntent.ConsultaEnviada(consulta)) }

    // 2. Impuro: el borde habla con el mundo y atrapa el fallo.
    val respuesta = runCatching { repo.buscar(consulta) }
        .fold(onSuccess = Respuesta::Ok, onFailure = { Respuesta.Fallo(mapear(it)) })

    // 3. Puro: el resultado vuelve al ciclo como un intent mas.
    reduce { reducir(state, BusquedaIntent.RespuestaRecibida(respuesta)) }
}

El orden de los tres tiempos no es negociable, y el primero es el que más se olvida. Reducir antes de lanzar el trabajo significa que el estado refleja la espera desde el instante cero, sin ventana ciega: no existe ningún momento en el que el usuario haya pulsado y la pantalla siga afirmando que no pasa nada. Esa ventana, cuando se deja abierta, es la causa del doble envío clásico —el usuario pulsa dos veces porque nada le indicó que la primera surtiera efecto— y no se arregla deshabilitando el botón en la vista, sino haciendo que el estado diga la verdad antes de que empiece la espera.

Las tres líneas relevantes cuentan toda la arquitectura. La primera y la tercera son puras aunque estén escritas dentro de una corrutina, porque delegan en la misma función libre que testeas por separado. La segunda es la única que conoce el repositorio, la única que puede fallar por causas externas y la única que tarda. Y observa el detalle decisivo del tercer tiempo: la respuesta no se aplica con un copy improvisado, sino que se envuelve en RespuestaRecibida y pasa por el reducer. Gracias a eso, la regla de qué significa recibir una respuesta —limpiar el error, ordenar los resultados, decidir si la lista vacía es un estado vacío o un fallo— vive en el mismo when que todo lo demás y se comprueba igual que todo lo demás.

⚠️
Las tres filtraciones más comunes

La impureza vuelve a colarse en el reducer casi siempre por las mismas tres rendijas. Una: leer el reloj para decidir si algo caducó, en vez de recibir el instante dentro del intent. Dos: consultar un singleton de sesión o de configuración desde una rama del when, en vez de tenerlo como parte del estado. Tres: llamar a algo que suena inofensivo y guarda, como un contador de analítica o un registro. Ninguna rompe el compilador y las tres cancelan el determinismo, que es la única propiedad de la que colgaba todo lo demás.

Repara también en que el intent de retorno tiene nombre propio y está en el mismo tipo sellado que los del usuario. No es un detalle cosmético: gracias a eso, el registro de la sesión incluye lo que contestó el servidor, el when obliga a decidir qué significa cada respuesta y el test de esa reducción se escribe construyendo un valor, sin red y sin espera.

Efecto declarado frente a efecto ejecutado

Queda una pregunta de reparto que suele generar discusión: quién decide lanzar el trabajo. Hay dos escuelas y conviene conocer ambas. En la variante ejecutada, la que ilustra el ejemplo anterior, la función que orquesta llama directamente al repositorio: es directa, legible y suficiente para la inmensa mayoría de los casos. En la variante declarada —la que popularizaron Elm y The Composable Architecture— el reducer devuelve dos cosas, el estado siguiente y una descripción inerte del trabajo pendiente, y un ejecutor externo es el único que la interpreta.

data class Paso(val estado: BusquedaState, val efecto: Efecto?)

sealed interface Efecto {
    data class Buscar(val consulta: String) : Efecto
    data object CancelarBusqueda : Efecto
}

fun reducir(estado: BusquedaState, intent: BusquedaIntent): Paso = when (intent) {
    is BusquedaIntent.ConsultaEnviada -> Paso(
        estado = estado.copy(consulta = intent.texto, fase = Fase.Cargando),
        efecto = Efecto.Buscar(intent.texto),
    )
    is BusquedaIntent.RespuestaRecibida -> Paso(
        estado = estado.aplicar(intent.respuesta),
        efecto = null,
    )
}
flowchart TD
I[Intent] --> R[Reducer puro]
R --> S[Estado siguiente]
R --> E[Efecto como dato inerte]
E --> X[Ejecutor impuro unico]
X --> N[Intent de resultado]
N --> R
style R fill:#cba6f7,color:#11111b
style X fill:#f38ba8,color:#11111b
style S fill:#a6e3a1,color:#11111b

Lo que compra la variante declarada es que el reducer sigue siendo puro incluso al decidir efectos: puedes afirmar en un test que cierto intent produce el efecto de buscar sin que ninguna búsqueda ocurra, porque el efecto es un valor comparable. Lo que cuesta es una capa más y un ejecutor que hay que escribir y mantener. La regla práctica razonable es empezar con la variante ejecutada y migrar a la declarada solo cuando la orquestación se vuelva un dominio en sí misma: cancelaciones encadenadas, reintentos con retroceso, condiciones de carrera entre peticiones o dependencias entre efectos que ya no caben en una lectura lineal.

Hay un punto intermedio que muchos equipos adoptan sin darle nombre: mantener la ejecución directa pero extraer la orquestación a funciones bien delimitadas, una por caso de uso, de modo que el ViewModel quede como un índice legible de qué hechos disparan qué trabajo. No es la variante declarada, porque el efecto no llega a ser un dato, pero conserva su virtud principal: que exista un solo sitio donde mirar para saber qué puede salir de esta pantalla hacia el mundo.

Carreras: cuando dos respuestas compiten

En cuanto un intent dispara trabajo aparece un problema que el reducer puro no puede resolver solo, porque no es un problema de decisión sino de tiempo. El usuario teclea tres letras y salen tres búsquedas; la segunda tarda más que la tercera y contesta después. Si ambas reducen sin más, el estado acaba mostrando los resultados de una consulta que ya no es la vigente.

Hay dos remedios y son complementarios. El primero vive en el borde impuro y consiste en cancelar el trabajo obsoleto: en Kotlin, la forma idiomática es un flujo de consultas transformado con flatMapLatest, que cancela la petición anterior en cuanto llega una nueva. El segundo vive en el reducer y consiste en desconfiar de las respuestas: el intent de retorno transporta la consulta que lo originó, y la reducción solo acepta el resultado si coincide con la consulta vigente en el estado.

BusquedaIntent.RespuestaRecibida ->
    if (intent.consulta != estado.consulta) estado          // llegada tardia: se ignora
    else estado.copy(fase = Fase.Inactiva, resultados = intent.resultados)

La misma técnica sirve para el caso vecino y más doloroso: la respuesta que llega después de que el usuario haya cancelado, navegado o cambiado de pestaña. Si el intent de retorno lleva consigo la identidad de lo que pidió, el reducer puede descartarlo sin necesidad de saber nada del ciclo de vida, y el descarte queda escrito como una regla del dominio en lugar de como una comprobación defensiva perdida en un callback.

Esta segunda defensa merece atención porque es la que convierte una condición de carrera —un bug que solo aparece con cierta latencia y es infernal de reproducir— en una rama de un when que se comprueba con dos líneas de test. La cancelación es una optimización, valiosa porque ahorra trabajo y batería; la comprobación en el reducer es la corrección, porque no depende de que la cancelación llegue a tiempo. Cuando puedas, ten las dos.

💡
Quién es el dueño del scope

El trabajo lanzado desde un intent debe morir con la pantalla. En Orbit el container se construye sobre el viewModelScope, de modo que sus corrutinas se cancelan solas cuando el ViewModel se destruye. Si alguna vez te ves lanzando en un scope global para que la operación sobreviva, párate: casi siempre significa que esa operación no pertenece a la pantalla sino a la aplicación, y su sitio es un componente de mayor alcance que reporte su resultado como un intent cuando alguien vuelva a mirar.

Confinar la impureza es lo que hace que el resto pueda razonarse

Conviene decirlo sin adornos: la pureza del reducer no tiene ningún valor por sí misma. Vale exactamente lo que valga la frontera que la protege, y esa frontera es lo que estás construyendo cuando separas reducir de ejecutar. La intuición correcta no es que lo impuro sea malo, sino que lo impuro es lo caro: es lo que tarda, lo que falla, lo que depende de una máquina que no controlas, lo que obliga a un test a esperar y lo que hace que un fallo sea intermitente. Toda arquitectura de interfaz seria es, en el fondo, una estrategia para que la cantidad de código afectada por ese coste sea pequeña y esté localizada. MVI la lleva a su forma extrema: hay una función que decide y no puede hacer nada, y hay un borde que hace y no puede decidir. Ninguna otra parte del sistema tiene permiso para ser ambas cosas. La consecuencia práctica es que el volumen de tu aplicación que necesita entorno para probarse encoge hasta volverse casi anecdótico, mientras que el volumen que se prueba con aritmética crece hasta abarcar toda la lógica de producto. Pero hay algo más fino todavía, y es el papel del retorno. Que el resultado del trabajo impuro vuelva como intent, y no como una asignación directa al estado, es lo que impide que aparezca una segunda puerta de entrada. Si el borde pudiera escribir en el estado por su cuenta, tendrías dos gramáticas de cambio conviviendo, una registrada y otra no, y el registro de intents dejaría de ser la historia completa de la sesión: sería una historia parcial, que es peor que ninguna porque inspira confianza injustificada. Al obligar a que el mundo conteste en el mismo idioma en el que pregunta el usuario, MVI conserva la propiedad de la que dependen la trazabilidad, la reproducción de bugs y el viaje en el tiempo. La red se convierte en un usuario más, uno que pulsa botones que nadie ve, y el ciclo sigue siendo uno solo.

Merece una nota final el caso del trabajo que no termina: los flujos. Observar la base de datos o la conectividad no es una petición con respuesta, sino una suscripción que emite muchas veces. El patrón se adapta sin esfuerzo, porque cada emisión es simplemente otro hecho: el borde colecciona el flujo y, por cada valor, reinyecta un intent de actualización. La única disciplina adicional es que la suscripción se abra una sola vez, atada al ciclo de vida del contenedor, y no en cada reducción, para no acumular observadores duplicados que multipliquen los intents por cada cambio.

⚔️ Separa decisión de ejecución
  1. Toma un intent tuyo que hoy llama al repositorio y reescríbelo con el patrón de tres tiempos, dejando las dos reducciones delegadas en una función pura.
  2. Comprueba que la respuesta del repositorio entra como intent propio y no como un copy improvisado; escribe el test de esa reducción sin tocar la red.
  3. Audita tu reducer buscando las tres filtraciones clásicas —reloj, singleton y registro— y elimínalas transportando el dato en el intent.
  4. Modela el mismo caso en la variante declarada, con un tipo suma de efectos, y escribe un test que afirme qué efecto se produce sin ejecutarlo.
  5. Argumenta, para tu pantalla concreta, si la orquestación justifica ya el ejecutor externo o si la variante ejecutada sigue siendo la elección correcta.