El debate: canal de efectos frente a estado consumible
No hay consenso en el ecosistema sobre cómo modelar los eventos de una sola vez, y el desacuerdo no es de gusto sino de premisas. Un bando defiende el canal de efectos por su semántica nativa de entrega única y su claridad de tipos; el otro sostiene que todo debe ser estado, incluidos los eventos, consumidos explícitamente mediante una intención de vuelta, porque solo así se conserva una fuente única de verdad y se sobrevive a la muerte del proceso. Esta lección expone ambos argumentos en su versión más fuerte, señala qué falla en cada uno y ofrece un criterio de arbitraje basado en la naturaleza del efecto y no en la lealtad a una escuela.
Después de cuatro lecciones defendiendo el canal, toca hacer lo que exige la honestidad intelectual: exponer el argumento contrario en su versión más fuerte, no en su caricatura. Porque existe una posición seria, sostenida por gente que ha pensado el problema con cuidado, según la cual el canal de efectos es un parche elegante a un problema mal planteado y la solución correcta es que todo sea estado, incluidos los eventos, consumidos explícitamente mediante una intención que los retira del modelo. No es una postura ingenua: nace de tomarse en serio la fuente única de verdad, la muerte del proceso y la reproducibilidad, tres cosas que el canal maneja peor de lo que su elegancia sugiere. Conocer los dos bandos y saber qué premisa acepta cada uno es lo que distingue a quien elige de quien repite.
- Reconstruir el argumento del canal de efectos en su formulación más fuerte y localizar sus grietas.
- Reconstruir el argumento del estado consumible y entender qué problema real intenta resolver.
- Evaluar los cuatro ejes del desacuerdo: fuente de verdad, muerte del proceso, multiplicidad de consumidores y ergonomía.
- Aplicar un criterio de arbitraje basado en la naturaleza del efecto y no en la escuela de procedencia.
La tesis del canal
El argumento del canal parte de una observación categórica: hay dos clases de salidas con semánticas incompatibles —convergencia y entrega única— y ninguna estructura puede ofrecer ambas a la vez. De ahí se sigue que deben ser dos canales con dos tipos, y que la separación debe ser estructural para que el compilador y el runtime la sostengan.
El argumento tiene además un componente de economía cognitiva que no conviene despreciar. Toda garantía que el sistema sostiene por ti es una regla menos que recordar, y el número de reglas que un equipo puede sostener a la vez es finito y bastante bajo. Al elegir el canal, la regla “acuérdate de consumir el evento” desaparece del manual del equipo, y con ella desaparece la clase entera de fallos que nacía de olvidarla.
Sus ventajas prácticas son considerables. La entrega única sale gratis, sin código de consumo ni banderas. El tipo sellado de efectos documenta, en un solo sitio y de forma exhaustiva, todo lo que la pantalla puede provocarle al mundo. El estado queda limpio de campos que solo existen para disparar cosas y por tanto sigue siendo comparable por igualdad, que es lo que permite recomposiciones baratas. Y el flujo permanece unidireccional de verdad: la interfaz no escribe nunca en el modelo, ni siquiera para decir “ya lo he visto”.
Los defensores honestos del canal reconocen dos debilidades. La primera es que el efecto en vuelo no está en ninguna fotografía: si el proceso muere entre la emisión y el consumo, el efecto se pierde sin rastro y sin posibilidad de restaurarlo desde un estado guardado. La segunda es la multiplicidad: con dos coleccionistas del mismo canal, cada efecto va a uno solo, de forma no determinista, y eso es un fallo silencioso y difícil de diagnosticar. Ambas son reales y ninguna se arregla con un Channel; se gestionan sabiendo que existen.
La tesis del estado consumible
El bando contrario empieza rechazando la premisa. Sostiene que la distinción entre convergencia y entrega única es real pero está mal ubicada: no es una propiedad del dato, sino del consumo del dato. Un evento pendiente, dicen, es perfectamente un hecho sobre el mundo —“hay un aviso sin mostrar”— y por tanto pertenece al estado; lo que no pertenece al estado es la decisión de mostrarlo, que corresponde a la interfaz y que debe comunicarse de vuelta con una intención explícita.
data class LoginState(
val cargando: Boolean = false,
val avisoPendiente: Aviso? = null,
)
// La interfaz confirma el consumo con una intencion de vuelta
fun avisoMostrado(id: String) = intent {
reduce {
if (state.avisoPendiente?.id == id) state.copy(avisoPendiente = null)
else state
}
}
Nótese que esto no es la bandera ingenua de la lección dos. Aquí el consumo es una intención que pasa por el mismo reductor que todo lo demás, es idempotente gracias a la comprobación por identificador, y por tanto no sufre las carreras del borrado manual descuidado. El evento no se pierde si nadie lo consume: permanece en el estado hasta que alguien lo consuma de verdad.
flowchart LR L[Logica] -->|efecto en el estado| E[Estado unico con aviso pendiente] E --> V[Interfaz muestra el aviso] V -->|intencion aviso mostrado| L L -->|reduce a nulo| E style E fill:#a6e3a1,color:#11111b style V fill:#89b4fa,color:#11111b
La premisa que sostiene todo el bando se puede enunciar así: si no está en el estado, no existe para el sistema. Lo que no está en el estado no se puede guardar, no se puede restaurar, no se puede inspeccionar en una herramienta de depuración ni reproducir en un informe de fallo. Desde ese punto de vista, el canal no es una solución elegante sino un agujero por el que se escapa información del sistema, y la elegancia se paga en observabilidad perdida.
Sus argumentos fuertes son cuatro. Uno: hay una única fuente de verdad, y el estado guardado describe la pantalla completa, incluidos sus pendientes, lo que permite restaurarla exactamente tras la muerte del proceso. Dos: los efectos pendientes son visibles en las herramientas de depuración y en los tests como cualquier otro campo, sin necesidad de consumir un flujo aparte. Tres: la entrega deja de depender de que exista un coleccionista en el momento exacto, con lo que desaparece la ventana de pérdida. Cuatro: con varios consumidores no hay reparto no determinista, porque todos ven el mismo estado.
Hay un quinto argumento, menos citado y quizá el más incómodo para el bando contrario: la reproducibilidad. Si toda salida está en el estado, entonces la secuencia de estados de una sesión es un registro completo de lo que ocurrió, y un fallo se reproduce reinyectando esa secuencia. Con efectos en un canal, el registro tiene huecos: hubo órdenes que se ejecutaron y que no aparecen en ninguna fotografía, de modo que reconstruir lo sucedido exige leer registros aparte y confiar en que estén completos.
Su coste, que también conviene decir sin adornos, es la ceremonia: cada efecto necesita identidad, una intención de consumo y disciplina en la interfaz para invocarla. Y es un contrato que el compilador no verifica: olvidar la intención de vuelta reintroduce el aviso zombi, exactamente el bug que se quería evitar. El canal traslada esa garantía al runtime; el estado consumible la deja en manos del programador.
Los cuatro ejes y el arbitraje
Puestos los dos casos, el desacuerdo se ordena en cuatro ejes donde cada bando gana uno o dos, y ninguno los gana todos.
Entrega — gana el canal
La garantía de una sola vez es nativa y no depende de que nadie recuerde nada. En el estado consumible, la garantía la sostiene la disciplina de invocar la intención de vuelta.
Muerte del proceso — gana el estado
Lo que está en el estado se puede guardar y restaurar. Lo que está en el búfer de un canal vive en memoria y desaparece con el proceso, sin dejar rastro que reproducir.
Varios consumidores — gana el estado
El canal reparte cada efecto a un solo coleccionista de forma no determinista. El estado lo ven todos por igual, y cada uno decide qué hace con él.
Ergonomía y tipos — gana el canal
Emitir es una línea y el tipo sellado documenta el contrato entero. El estado consumible añade identidad, intención de consumo y campos que solo existen para dispararse.
De ahí sale un criterio de arbitraje que es más útil que cualquier lealtad: decide según lo que le pase al efecto si la app muere antes de ejecutarlo. Si perderlo es aceptable —una vibración, un aviso informativo, una animación, un mensaje de validación— el canal es la elección correcta y su ergonomía es un regalo. Si perderlo es inaceptable —una navegación que forma parte de un flujo de pago, la apertura de un documento tras una subida, una confirmación que el usuario debe ver sí o sí— entonces ese efecto no era efímero del todo y probablemente merece existir en el estado, con identidad y consumo explícito, o incluso persistirse.
La guía oficial de arquitectura de Android se inclina desde hace años por el estado consumible: recomienda modelar los eventos de una vez como estado de interfaz y notificar el consumo con una llamada de vuelta, precisamente por el argumento de la fuente única de verdad. Las librerías construidas sobre MVI estricto —Orbit entre ellas— exponen en cambio un canal de efectos de primera clase, por el argumento de la semántica y los tipos. Que dos posiciones tan meditadas discrepen es la mejor prueba de que no se trata de un error de una de las partes, sino de una tensión real entre garantías que no se pueden maximizar a la vez.
La solución mixta y la regla de equipo
En una base de código real las dos soluciones conviven sin contradicción, siempre que la frontera esté escrita y no dependa del criterio del día. El reparto que funciona es sencillo: el canal para lo prescindible y el estado para lo crítico.
data class PedidoState(
val cargando: Boolean = false,
// Critico: debe sobrevivir a la muerte del proceso
val confirmacionPendiente: Confirmacion? = null,
)
sealed interface PedidoEfecto {
// Prescindibles: si se pierden, no pasa nada grave
data class Avisar(val mensaje: String) : PedidoEfecto
data object VibrarError : PedidoEfecto
}
fun confirmacionVista(id: String) = intent {
reduce {
if (state.confirmacionPendiente?.id == id) state.copy(confirmacionPendiente = null)
else state
}
}
El criterio que separa ambas listas es único y se puede enunciar en una frase que cualquiera del equipo puede aplicar sin consultar: si perder este efecto por la muerte del proceso produciría un fallo que un usuario reportaría, va al estado con consumo explícito; en cualquier otro caso, va al canal. Aplicado con honestidad, ese criterio deja el canal con la inmensa mayoría de los efectos y el estado con dos o tres casos por app, casi siempre relacionados con dinero, con documentos o con confirmaciones legales.
La objeción previsible a la solución mixta es que introduce dos mecanismos donde antes había uno, y que dos mecanismos son más difíciles de enseñar que uno. Es cierta y hay que responderla con el mismo criterio que se aplica a cualquier abstracción: la uniformidad solo vale la pena cuando los casos son uniformes. Aquí no lo son —un aviso perdido y una confirmación de pago perdida tienen consecuencias de órdenes de magnitud distintas— y forzar el mismo tratamiento para ambos significa o pagar ceremonia innecesaria en el noventa por ciento de los casos, o aceptar pérdidas inaceptables en el diez restante.
Conviene además fijar el número de casos críticos como una métrica que se vigila. Si la lista de efectos guardados en el estado crece más allá de un puñado, casi siempre significa una de dos cosas: o el equipo está clasificando por miedo en vez de por análisis, o hay un flujo que en realidad debería modelarse como una máquina de estados persistida y no como una secuencia de avisos.
Ningún mecanismo de esta lección hace idempotente una operación que no lo es del otro lado. Si la navegación a un pago puede ejecutarse dos veces por un doble toque, el canal no lo impedirá, porque emitirá dos efectos legítimos. La entrega única garantiza que cada efecto se entrega una vez, no que la lógica emita el efecto una sola vez. La protección contra la doble emisión es otro problema y se resuelve en la lógica: deshabilitando el botón mientras se procesa, con una clave de idempotencia en la petición, o comprobando el estado antes de emitir.
Si se rasca lo suficiente, este debate deja de ser una discusión de Android y se convierte en una de las preguntas recurrentes del diseño de software: cuando necesitas una garantía, ¿la codificas en la estructura o la sostienes con un protocolo? El canal codifica la entrega única en la estructura: eliges un Channel y la propiedad viene puesta, imposible de violar por descuido, al precio de que lo que viaja por él es invisible para el sistema de persistencia y opaco para la restauración. El estado consumible codifica la entrega única en un protocolo: emitir, mostrar, confirmar el consumo con una intención de vuelta; el precio es que el protocolo depende de que todos los participantes lo cumplan, y el compilador no puede obligarles. Nótese la simetría, porque es exacta: la estructura da garantías que no se pueden romper pero solo las que ella conoce; el protocolo da garantías arbitrariamente ricas pero solo mientras nadie se equivoque. Esa tensión es la misma que separa los tipos de las convenciones, las transacciones de la coordinación manual, y los sistemas que hacen imposible el error de los que lo hacen detectable. Elegir bando sin entender el eje es lo que produce discusiones eternas donde cada parte tiene razón sobre lo que mide y no oye lo que el otro mide. El programador maduro no adopta un bando: identifica qué garantía necesita este efecto —¿puede perderse?, ¿debe sobrevivir a la muerte del proceso?, ¿cuántos deben verlo?— y elige el mecanismo cuyo coste está dispuesto a pagar a cambio. Y acepta lo más incómodo de todo: que en una app real conviven las dos soluciones, porque no todos los efectos tienen la misma naturaleza, y forzar una única respuesta a una pregunta que admite varias es la forma más elegante de equivocarse.
- Reconstruye por escrito el argumento del canal y el del estado consumible, cada uno en cinco líneas, sin caricaturizar ninguno.
- Toma los efectos de una pantalla tuya y clasifícalos según el criterio de la muerte del proceso: ¿cuáles pueden perderse y cuáles no?
- Implementa uno de los efectos que no puede perderse como estado consumible, con identidad e intención de consumo idempotente.
- Explica qué ocurre exactamente si un desarrollador olvida invocar la intención de consumo, y qué mecanismo del canal hace imposible ese olvido.
- Argumenta si tu app debería usar los dos mecanismos a la vez, y escribe la regla de equipo que decidiría cuál usar en cada caso nuevo.