wandres.dev
ORBIT MVI · container y side effects

Side effects de una sola vez: postSideEffect

Hay salidas de una pantalla que no son estado: navegar a otra ruta, mostrar un toast, disparar un snackbar, vibrar. Son eventos que ocurren una vez y no deben repetirse. postSideEffect los emite por el canal de side effects, separado del estado, y esta lección explica en profundidad por qué esa separación no es una comodidad sino una necesidad: modelar un evento como si fuera estado produce el bug clásico del toast que reaparece al girar la pantalla o al recomponerse un Composable. Se contrastan la semántica retenida y conflada del estado con la semántica de entrega única del canal, y se muestra cómo se consumen los side effects en la interfaz sin volver a dispararlos.

⏱ 16 min

No todo lo que una pantalla produce es estado. Cuando el login tiene éxito, la pantalla no entra en un estado de estar navegando: navega, una vez, y punto. Cuando algo falla, no es un estado de mostrar-un-toast: dispara un toast, una vez, y se olvida. Estos son side effects: hechos puntuales dirigidos al mundo exterior —el navegador, el sistema, el usuario— que ocurren y se consumen. Orbit les da un canal propio, postSideEffect, separado del estado por una razón que parece pedante hasta que te muerde: confundir un evento con un estado es la fuente de una familia entera de bugs que han plagado las apps móviles durante una década, y esta lección trata de por qué esa separación es estructural y no opcional.

🎯 Al terminar esta lección sabrás
  • Reconocer qué salidas son eventos de una vez y no estado: navegar, un toast, un snackbar, vibrar.
  • Emitir side effects con postSideEffect y consumirlos en la interfaz sin re-disparo.
  • Entender el bug del toast que reaparece y por qué nace de modelar un evento como estado.
  • Contrastar la semántica retenida del estado con la de entrega única del side effect.

Eventos que no son estado

La prueba para saber si algo es estado o side effect es una sola pregunta: si la pantalla se reconstruye ahora mismo desde cero, ¿esto debería seguir ahí? El nombre del usuario, sí: forma parte de la fotografía. La lista de mensajes, sí. El indicador de carga, sí. Pero “navega a la pantalla de inicio” no debería volver a ocurrir solo porque la pantalla se recreó; “muestra el error de red” tampoco. Esos son eventos: instrucciones puntuales que ya se cumplieron y cuya repetición sería un fallo.

sealed interface LoginSideEffect {
    data object NavegarAHome : LoginSideEffect
    data class MostrarError(val mensaje: String) : LoginSideEffect
}

fun entrar(usuario: String, clave: String) = intent {
    reduce { state.copy(cargando = true) }
    val resultado = repo.login(usuario, clave)
    reduce { state.copy(cargando = false) }
    when (resultado) {
        is Ok -> postSideEffect(LoginSideEffect.NavegarAHome)
        is Error -> postSideEffect(LoginSideEffect.MostrarError(resultado.mensaje))
    }
}

Fíjate en el reparto: cargando es estado —se retiene, se refleja en la interfaz, sobrevive a una rotación—, mientras que navegar y mostrar el error son postSideEffect —ocurren una vez y desaparecen—. La sealed interface de side effects, con su exhaustividad, te obliga además a manejar cada evento en la interfaz sin olvidar ninguno.

postSideEffect y el consumo en la interfaz

postSideEffect(evento) deposita el evento en el canal que viste en la lección del container. Del otro lado, la interfaz lo consume una sola vez. En Compose, collectSideEffect respeta el ciclo de vida y no reemite eventos pasados al recomponerse:

@Composable
fun LoginScreen(viewModel: LoginViewModel) {
    val state by viewModel.collectAsState()

    viewModel.collectSideEffect { efecto ->
        when (efecto) {
            LoginSideEffect.NavegarAHome -> navController.navigate("home")
            is LoginSideEffect.MostrarError -> snackbar.showSnackbar(efecto.mensaje)
        }
    }

    Button(onClick = { viewModel.entrar(usuario, clave) }, enabled = !state.cargando) {
        Text("Entrar")
    }
}

El when exhaustivo sobre la sealed interface es donde traduces cada evento abstracto —“navega”, “muestra error”— a la acción concreta de la plataforma. La lógica del ViewModel no sabe qué es un navController ni un Snackbar: solo emite intenciones abstractas, y la interfaz las interpreta. Esa indirección mantiene la lógica probable y libre de dependencias de Android.

El bug del toast que reaparece

Aquí está la razón profunda de todo. Supón que, en lugar de un canal, guardas el mensaje de error en el estado: state.copy(error = "sin conexion"). Funciona la primera vez: el estado cambia, la interfaz lo observa y muestra el toast. Pero el estado es una fotografía conflada que se reemite a cada nuevo suscriptor. Gira la pantalla: el ViewModel sobrevive, la interfaz se recrea, se resuscribe a stateFlow y recibe el último estado —que todavía contiene error = "sin conexion"—, así que el toast vuelve a aparecer, ahora sin que haya pasado nada. Recomposición en Compose: mismo problema. El evento quedó fosilizado en el estado y resucita en cada relectura.

flowchart TD
EV[Evento navegar o toast] --> Q{Lo guardas como estado o lo emites como side effect}
Q -->|Como estado| BAD[Se reemite a cada nuevo suscriptor]
BAD --> BUG[El toast reaparece al girar o recomponer]
Q -->|postSideEffect| CH[Canal de entrega unica]
CH --> OK[Se consume una vez y no vuelve]
style BUG fill:#f38ba8,color:#11111b
style OK fill:#a6e3a1,color:#11111b

La industria intentó parchear esto sin separar los conceptos: banderas booleanas que reseteas tras consumir, envoltorios Event con un flag de “ya visto”, SingleLiveEvent. Todos son epiciclos: complican el estado para simular una semántica de entrega única que el estado, por naturaleza, no tiene. Orbit corta el nudo devolviendo el evento a su canal propio, donde la entrega única es la semántica nativa y no hay nada que resetear.

⚠️
La bandera booleana es el sintoma, no la cura

Si alguna vez te ves añadiendo un mostrarError: Boolean al estado y reseteándolo a false después de mostrarlo, detente: estás reinventando un canal de eventos dentro del estado, mal. Ese patrón filtra la lógica de consumo hacia la interfaz, se rompe con dos observadores y no compone. La existencia misma de esa bandera es la señal de que ese dato era un side effect disfrazado de estado.

Estado es un sustantivo, side effect es un verbo

La distinción que Orbit cablea con dos canales es, en el fondo, gramatical, y una vez la ves ya no puedes dejar de verla. El estado es un sustantivo: describe cómo son las cosas ahora —está cargando, el usuario se llama Ana, hay tres mensajes—. Es una fotografía, y como toda fotografía su rasgo esencial es que persiste y se puede volver a mirar: por eso vive en un StateFlow conflado que reemite el último valor a quien se suscriba. El side effect es un verbo: describe algo que sucede —navega, avisa, vibra—. Es un acontecimiento, y el rasgo esencial de un acontecimiento es que ocurre una vez y pertenece al instante en que ocurrió: por eso vive en un canal que lo entrega una sola vez y no lo reemite jamás. El bug del toast que reaparece no es un descuido de implementación; es lo que pasa inevitablemente cuando conjugas un verbo como si fuera un sustantivo, cuando congelas un acontecimiento dentro de una fotografía. La grandeza de forzar esta separación a nivel de tipo es que te obliga a categorizar cada salida antes de escribirla: ¿esto describe el mundo o lo cambia? ¿Es un hecho retenido o un acto consumido? Responder bien esa pregunta, cada vez, es la diferencia entre una app que se comporta y una que resucita fantasmas cada vez que el usuario gira el teléfono. Orbit no te deja equivocarte por descuido: te da dos puertas y te obliga a elegir.

⚔️ Clasifica cada salida
  1. Lista todas las salidas de una pantalla de pago: total a cobrar, botón deshabilitado, “pago aceptado”, navegar al recibo, error de tarjeta. Marca cada una como estado o side effect.
  2. Toma una que hayas marcado como side effect y describe el bug exacto que ocurriría si la guardaras en el estado tras una rotación de pantalla.
  3. Explica por qué la sealed interface de side effects con when exhaustivo evita que olvides manejar un evento en la interfaz.
  4. Argumenta por qué el ViewModel emite NavegarAHome en vez de llamar directamente al navController, y qué gana la testabilidad con esa indirección.
  5. Reconoce en algún código tuyo una bandera booleana que en realidad era un side effect disfrazado y reescríbelo con postSideEffect.