wandres.dev
INTENTS Y REDUCERS · la transición

Modelar los intents: la intención del usuario como tipo

Un intent no es una orden que la vista da al ViewModel, sino el acta notarial de algo que ocurrió en la interfaz. Esta lección construye el vocabulario de intents de una pantalla como tipo suma sellado en Kotlin, defiende la regla de nombrar por el hecho ocurrido y no por el remedio deseado, muestra por qué esa disciplina de nomenclatura mantiene la lógica en el reducer en lugar de filtrarla hacia la vista, distingue el intent que transporta datos del que no los necesita, y explica cómo la clausura del tipo convierte la superficie de interacción en un objeto finito, enumerable, registrable y verificable por el compilador.

⏱ 18 min

Todo el edificio de MVI descansa sobre una decisión que parece menor y es fundacional: convertir la interacción en dato. Mientras la acción del usuario viva como una llamada a un método —guardar(), recargar(), borrarFila(id)— la interfaz está dando órdenes, y quien da órdenes decide; decidir es lógica, y esa lógica acaba de mudarse a la capa que menos debería tenerla. Un intent invierte el sentido de la flecha. La vista no manda: informa. Dice “el usuario pulsó el botón de guardar”, “el usuario escribió esto en el campo”, “la pantalla apareció”, y calla. Qué se hace con esa noticia es asunto de otro. Esta lección trata de cómo se construye ese vocabulario de noticias y por qué la elección de cada nombre —un gesto que parece cosmético— determina, más que ninguna otra cosa, dónde va a terminar viviendo la lógica de tu aplicación.

🎯 Al terminar esta lección sabrás
  • Modelar la superficie de interacción de una pantalla como un tipo suma sellado de intents.
  • Aplicar la regla de nomenclatura: nombrar por el hecho ocurrido, no por la acción a ejecutar.
  • Distinguir el intent sin carga del intent que transporta el dato relevante del evento.
  • Reconocer por qué la clausura del tipo hace enumerable, registrable y verificable lo que el usuario puede provocar.

El intent como acta, no como orden

En Kotlin el vocabulario de una pantalla se escribe con un sealed interface: un tipo cuyas implementaciones están cerradas y son conocidas por el compilador. Cada miembro es un hecho que puede ocurrir. Cuando el hecho no lleva información asociada basta un data object, porque solo hay una forma posible de que ocurra. Cuando el hecho arrastra un dato —el texto tecleado, el identificador tocado— la carga viaja dentro de una data class.

sealed interface CarritoIntent {
    data object PantallaMostrada : CarritoIntent
    data class CantidadCambiada(val linea: String, val unidades: Int) : CarritoIntent
    data class LineaDeslizada(val linea: String) : CarritoIntent
    data class CuponEscrito(val texto: String) : CarritoIntent
    data object CuponAplicadoPulsado : CarritoIntent
    data object ReintentoPulsado : CarritoIntent
}

Antes de seguir, una aclaración que ahorra confusiones: este Intent no tiene ninguna relación con android.content.Intent, el mensajero del sistema que arranca actividades y servicios. Comparten nombre por un accidente histórico y nada más. Aquí un intent es un tipo tuyo, de dominio, que no sale nunca de tu módulo de presentación.

Léelo como un acta y no como una API. No dice qué debe hacer el sistema; dice qué pasó en el mundo. PantallaMostrada no ordena cargar nada: constata que la pantalla se mostró. Que de ese hecho se siga una carga de red es una consecuencia que decide el reducer, y podría cambiar mañana sin tocar ni una línea de la vista. Esa independencia entre el hecho y su consecuencia es exactamente la costura por donde MVI separa las responsabilidades.

Nombrar por lo que pasó, no por lo que hay que hacer

La regla se enuncia en una frase y se incumple todos los días: el nombre del intent describe el evento, no el remedio. CargarCarrito es un remedio; PantallaMostrada es un evento. AplicarCupon es un remedio; CuponAplicadoPulsado es un evento. La diferencia parece de estilo y es de arquitectura.

📜

Evento

LineaDeslizada, ReintentoPulsado, CuponEscrito. Describen lo que el usuario hizo. La vista solo tiene que saber qué ocurrió delante de ella.

🎯

Remedio

BorrarLinea, RecargarDatos, ValidarCupon. Describen lo que el sistema debe hacer. Para emitirlos, la vista tiene que decidir, y decidir es lógica.

🔁

Consecuencia

Un mismo hecho puede cambiar de consecuencia sin cambiar de nombre. Deslizar podía borrar y ahora archiva: cambia el reducer, no la vista.

🧪

Prueba

Un test escrito con eventos se lee como un guion de uso real; uno escrito con remedios se lee como una lista de llamadas internas.

La regla tiene una excepción aparente que conviene desactivar cuanto antes. A veces el hecho no tiene un gesto obvio detrás: el usuario no pulsa nada, simplemente la pantalla aparece. La tentación es llamar a ese intent Cargar, porque es lo que va a provocar. Resiste: el hecho es PantallaMostrada, y lo bueno de nombrarlo así se ve el día que el producto decide que al aparecer no hay que cargar sino restaurar lo cacheado, o cargar solo si pasaron cinco minutos. Con el nombre del hecho esa evolución es una rama del reducer; con el nombre del remedio es un cambio en la vista y probablemente una mentira: un intent llamado Cargar que a veces no carga.

Cuando el intent se llama BorrarLinea, la vista ya ha tomado la decisión de que deslizar significa borrar. Ese conocimiento —una regla de negocio disfrazada de gesto— queda atrapado en el Composable, fuera del alcance de los tests unitarios y fuera del reducer donde debería estar escrito. El día que el producto pida que deslizar archive en lugar de borrar, o que borre solo si la línea no está pagada, la modificación aterriza en la capa equivocada. Con LineaDeslizada el cambio ocurre donde vive la política, y la vista sigue diciendo la única verdad que le consta.

⚠️
El síntoma que delata un mal nombre

Si al escribir el nombre del intent te sale un verbo en infinitivo y en modo imperativo —cargar, guardar, validar, borrar— párate. Casi siempre significa que estás nombrando la reacción. Reescríbelo en pasado y con el sujeto de la interfaz: qué se pulsó, qué se escribió, qué se deslizó, qué apareció. Si el nombre en pasado suena raro o no sabes cómo formularlo, es señal de que el evento real es otro y aún no lo has identificado.

Un test rápido para saber si has acertado con el nombre: léelo en voz alta precedido de la frase el usuario acaba de. Si la oración resultante describe algo que un ser humano hizo —el usuario acaba de deslizar la línea, el usuario acaba de pulsar reintentar— el nombre es correcto. Si describe algo que hace el programa —el usuario acaba de recargar los datos— el nombre pertenece al reducer y no a la vista.

La superficie enumerable

Un vocabulario cerrado tiene una propiedad que ningún conjunto de métodos públicos tiene: se puede recorrer entero. El compilador conoce cada miembro del sealed interface, de modo que un when sin rama else obliga a atender todos los casos, y añadir un intent nuevo rompe la compilación en cada punto que lo consume hasta que decidas su tratamiento.

flowchart TD
V[Vista solo observa y reporta] --> I[Intent hecho ocurrido]
I --> W[when exhaustivo sobre el tipo sellado]
W --> R[Reducer decide la consecuencia]
R --> S[Estado siguiente]
S --> V
style I fill:#f9e2af,color:#11111b
style R fill:#cba6f7,color:#11111b
style S fill:#a6e3a1,color:#11111b

La exhaustividad tiene además un efecto social que no conviene subestimar: convierte la ampliación del producto en una conversación obligatoria. Cuando alguien añade un intent porque el diseño incorporó un gesto nuevo, el build rompe en cada pantalla y en cada test que consumía ese tipo, y quien lo añadió tiene que decidir explícitamente qué ocurre en cada punto. No hay forma de introducir una acción a medias, funcionando en el camino feliz y olvidada en el resto.

Esa clausura habilita cosas que en el Android imperativo eran imposibles. Registrar cada intent produce la crónica exacta de la sesión, en orden y sin huecos, porque no hay ninguna vía alternativa por la que la interacción pueda entrar. Serializar esa crónica y reinyectarla sobre el mismo estado inicial reproduce el bug que un usuario reportó, sin conjeturas. Y probar una interacción se reduce a construir un valor y pasarlo: sin instrumentación, sin pantalla, sin esperas.

Hay una condición para que todo esto se sostenga, y conviene enunciarla ahora porque será la fuente de casi todas las regresiones: el intent tiene que ser un dato inerte. Nada de lambdas dentro, nada de referencias a la vista, nada de objetos que arrastren un contexto de Android. En cuanto un miembro del tipo transporta una función, deja de poderse comparar, serializar y reinyectar, y la propiedad se pierde para toda la pantalla, no solo para ese caso. Un intent debe poder escribirse en un fichero y volver a leerse mañana sin haber perdido nada.

// Inerte: comparable, serializable, registrable.
data class LineaDeslizada(val linea: String) : CarritoIntent

// Contaminado: la logica viaja dentro y ya nada de lo anterior es cierto.
data class LineaDeslizada(val alConfirmar: () -> Unit) : CarritoIntent

La misma exigencia se aplica a la carga: los tipos que viajan dentro de un intent deben ser datos de dominio, no envoltorios de infraestructura. Un identificador como String o como un tipo de valor propio, sí; un cursor de base de datos, una vista de Android o un objeto que solo tiene sentido dentro de un framework, no. El criterio es el mismo de siempre y se comprueba igual de rápido: si no puedes escribirlo en un fichero y volver a leerlo, no es un intent.

Los hechos que no vienen del usuario

El vocabulario de una pantalla no se agota en los gestos. Hay hechos que ocurren sin que nadie toque nada y que el estado debe reflejar igual: el resultado de una petición que vuelve, un permiso que el sistema concede, un flujo de la base de datos que emite una fila nueva, la conectividad que cae. Todos ellos son noticias, y por tanto son intents con el mismo rango que los del usuario.

sealed interface CarritoIntent {
    // Del usuario
    data class CantidadCambiada(val linea: String, val unidades: Int) : CarritoIntent
    data object CuponAplicadoPulsado : CarritoIntent

    // Del mundo
    data class CarritoRecibido(val resultado: Resultado<List<Linea>>) : CarritoIntent
    data class ConectividadCambiada(val hayRed: Boolean) : CarritoIntent
    data class RelojAvanzado(val instante: Long) : CarritoIntent
}

Meter estos hechos en el mismo tipo tiene dos consecuencias que compensan de sobra la incomodidad inicial. La primera es que el reducer sigue siendo el único lugar donde el estado cambia, incluso cuando el cambio lo provoca la red: no aparece una segunda gramática de mutación por la puerta de atrás. La segunda es que el registro de intents vuelve a ser la historia completa, y no una versión parcial que solo cuenta lo que hizo la persona. Reproducir un bug exige exactamente eso: saber también qué contestó el servidor y en qué orden llegó.

Conviene fijarse en cómo está tipado CarritoRecibido: transporta un Resultado, no una lista. El fracaso viaja dentro del mismo intent que el éxito porque ambos son la misma noticia —el servidor contestó— y separarlos en dos miembros distintos suele acabar en dos ramas que repiten la mitad de la lógica. La lección siguiente vuelve sobre esto con detalle; aquí basta con notar que el error también es un hecho y también tiene su sitio en el vocabulario.

Nota el tercer miembro del ejemplo, RelojAvanzado. Es el patrón que resuelve la tentación más frecuente de todas, la de consultar la hora dentro del reducer para decidir si algo caducó. Si el instante entra como dato, la caducidad se convierte en una función pura del estado y del intent, comprobable en un test que fija el reloj a mano en lugar de esperar a que pase el tiempo real.

ℹ️
La etiqueta importa menos que la disciplina

Buena parte del ecosistema de 2026 ha abandonado la palabra intent para esquivar el choque con android.content.Intent, y usa UiEvent, UiAction o simplemente Event. Circuit, la guía oficial y varias bases de código grandes ya lo hacen así. El patrón es idéntico y las reglas de esta lección se aplican sin cambio alguno; lo único que importa es que el equipo elija un nombre y lo mantenga, porque la ambigüedad se paga en cada revisión de código.

Nombrar el hecho es decidir dónde vivirá la lógica

Hay una razón por la que la disciplina de nombrar intents en pasado sobrevive en Elm, en Redux, en The Composable Architecture y en Orbit, a pesar de que ningún compilador la exige: es el mecanismo más barato que se conoce para impedir que la lógica se filtre hacia la interfaz. Un nombre imperativo no es un error de estilo, es una decisión tomada en el sitio equivocado y cristalizada en una etiqueta. Cuando la vista emite BorrarLinea, alguien tuvo que saber que deslizar borra; ese saber es política de producto, es lo que más cambia, es lo que hay que poder probar sin abrir la app, y acaba de quedar sepultado en un modificador de gesto dentro de un Composable. Con LineaDeslizada la vista no sabe nada: solo constata. Y esa ignorancia deliberada es su virtud, porque la ignorancia de una capa es la libertad de otra. Fíjate en la asimetría temporal que introduce la regla. Un hecho ocurrido es inmutable: que el usuario deslizara la línea será verdad para siempre, y ningún cambio futuro de requisitos puede desmentirlo. Una orden, en cambio, encarna una interpretación de ese hecho, y las interpretaciones caducan. Al construir el vocabulario sobre lo que no caduca, el sistema entero se vuelve estable en su frontera y flexible en su interior: la superficie de intents se toca poco, el reducer se toca mucho, y esa es exactamente la proporción sana. Es también la razón por la que un registro de intents envejece bien y un registro de llamadas a métodos no: el primero es historia, el segundo es implementación. Cuando dentro de dos años alguien lea la lista de intents de una pantalla, no leerá cómo funcionaba tu código; leerá qué podía hacer un ser humano frente a esa pantalla. Ese documento —finito, cerrado, verificado por el compilador en cada build— es lo más parecido a una especificación viva que una aplicación puede tener.

Una última observación práctica sobre el sitio del tipo. El sealed interface de intents pertenece al mismo archivo o al mismo paquete que el estado y el reducer de esa pantalla, no a un cajón común de tipos compartidos. La razón es de acoplamiento: cuando dos pantallas comparten un vocabulario de intents, cualquier gesto añadido en una obliga a la otra a atenderlo, y lo que empezó siendo reutilización termina siendo un tipo que ninguna de las dos puede modificar sin negociar. Los intents son locales por naturaleza; lo que se comparte entre pantallas son los modelos de dominio que viajan dentro de ellos.

⚔️ Construye el vocabulario de una pantalla
  1. Toma una pantalla real de una app tuya y escribe su sealed interface de intents completo, sin omitir el evento de aparición ni los de error reintentado.
  2. Revisa cada nombre y marca los que estén en imperativo; reescríbelos en pasado nombrando el hecho, y anota qué decisión de negocio estabas escondiendo en cada uno.
  3. Decide para cada intent si necesita carga: justifica por qué el dato viaja dentro del intent en vez de leerse de una variable de la vista.
  4. Escribe el when exhaustivo sin else, añade después un intent nuevo y observa exactamente en qué archivos te obliga a intervenir el compilador.
  5. Coloca un registro en el punto de entrada, usa la pantalla un minuto y reconstruye tu sesión leyendo solo la lista de intents; señala qué información te falta y añade el intent que la aportaría.