Navegar como side effect: la orden que se ejecuta una sola vez
El enfoque mayoritario en la industria trata la navegación como un efecto de entrega única, y esta lección explica por qué esa decisión es coherente y qué precio tiene. Se aplica la prueba de la reconstrucción al acto de navegar, se muestra por qué un campo booleano de tipo debeNavegar produce saltos fantasma en cuanto el estado se reemite, y se construye el patrón completo con Orbit: un efecto sellado que transporta la ruta tipada, un componible de ruta que lo traduce a navigate y una pantalla pura que nunca ve el NavController. Se cierra con los tres costes reales del enfoque: la pila deja de ser observable, los deep links exigen un camino aparte y la restauración depende íntegramente de la librería.
Pregúntale a cualquier equipo cómo navega desde su modelo de vista y te describirá, con distintos nombres, la misma solución: un canal de eventos, un tipo sellado con destinos y una recolección atada al ciclo de vida en el extremo componible. No es una coincidencia ni una moda; es la conclusión a la que se llega por eliminación en cuanto se intenta lo contrario. Poner la navegación en el estado parece más simple durante exactamente una tarde, hasta la primera rotación de pantalla, cuando la aplicación salta sola a una pantalla que el usuario ya había abandonado. Esta lección justifica el consenso con precisión, construye el patrón hasta su última línea y luego hace algo que casi nunca se hace: enumerar honestamente lo que el consenso pierde, para que la lección siguiente pueda discutirlo.
- Aplicar la prueba de la reconstrucción al acto de navegar y derivar de ahí su clasificación.
- Diagnosticar el bug del salto fantasma que produce cualquier campo de navegación retenido en el estado.
- Construir el patrón completo con un efecto sellado que transporta la ruta tipada.
- Enumerar los tres costes reales del enfoque para poder compararlo con la alternativa.
La prueba, aplicada a un salto
Ya conoces el criterio: si la pantalla se destruyera y se reconstruyera desde su último estado conocido, ¿esto debería volver a ocurrir? Aplícalo a navegar y la respuesta es inmediata y contundente. Que el usuario haya ido de la lista al detalle es un hecho consumado; volver a ejecutarlo tras una rotación no es refrescar la interfaz, es apilar un segundo detalle sobre el que ya estaba, o peor, teletransportar al usuario desde donde había llegado.
La formulación equivalente en términos de flujo es más útil todavía. El estado vive en un flujo conflado que entrega su último valor a cada nuevo suscriptor: esa reemisión es precisamente lo que hace correcto al estado y devastador al acto guardado dentro de él. Un campo llamado debeIrAlDetalle no es un dato sobre el mundo, es una orden disfrazada de dato, y el flujo la volverá a entregar cada vez que alguien se suscriba.
// Sintoma: la navegacion como campo del estado
data class LoginState(
val cargando: Boolean = false,
val error: String? = null,
val irAInicio: Boolean = false,
)
// En la interfaz, el disparo condicional
if (estado.irAInicio) {
nav.navigate(Inicio)
}
Ese fragmento contiene tres fallos encadenados y ninguno produce un aviso del compilador. Ejecuta un efecto en el cuerpo de un componible, con lo que se dispara en cada recomposición que cumpla la condición. Retiene la orden en un flujo que la reemite tras cada rotación. Y obliga a inventar un intento de limpieza —una llamada de vuelta que ponga el campo a falso— que abre una carrera entre la navegación y la reducción. La corrección no es blindar el condicional con más banderas: es sacar el dato del estado.
sequenceDiagram participant U as Usuario participant VM as ViewModel participant S as Estado conflado participant UI as Pantalla U->>VM: intent de login VM->>S: reduce irAInicio true S->>UI: emite estado UI->>UI: navega a Inicio Note over UI: rotacion del dispositivo S->>UI: reemite el ultimo estado UI->>UI: navega otra vez y apila un duplicado
El patrón: un efecto que transporta la ruta
La corrección consiste en darle a la navegación su propio canal, con semántica de entrega única. En Orbit ese canal ya existe y está declarado en la firma del contenedor, de modo que basta con modelar los destinos como un tipo sellado y emitirlos con postSideEffect. Y como las rutas ahora son valores tipados, el efecto puede transportar la ruta entera en vez de un enumerado que la interfaz tenga que reinterpretar.
sealed interface LoginEfecto {
data class Navegar(val destino: Any) : LoginEfecto
data class Avisar(val mensaje: String) : LoginEfecto
data object Volver : LoginEfecto
}
class LoginViewModel(
private val auth: AuthRepo,
) : ContainerHost<LoginState, LoginEfecto>, ViewModel() {
override val container = container<LoginState, LoginEfecto>(LoginState())
fun entrar(usuario: String, clave: String) = intent {
reduce { state.copy(cargando = true, error = null) }
when (val r = auth.entrar(usuario, clave)) {
is Ok -> {
reduce { state.copy(cargando = false) }
postSideEffect(LoginEfecto.Navegar(Inicio))
}
is Fallo -> {
reduce { state.copy(cargando = false, error = r.mensaje) }
postSideEffect(LoginEfecto.Avisar(r.mensaje))
}
}
}
}
Fíjate en la coreografía del caso de éxito, que es la lección entera en cuatro líneas: el indicador de carga se apaga por reduce, porque describe cómo están las cosas y debe repintarse cuantas veces haga falta; el salto se emite por postSideEffect, porque es un acto que ya ocurrió y repetirlo sería un fallo visible. El mismo suceso produce a la vez una descripción nueva y una orden puntual, y la arquitectura obliga a repartirlos por dos puertas distintas.
Hay dos escuelas y las dos funcionan. Una hace que el efecto lleve el destino ya construido, como en el fragmento: el modelo de vista conoce los tipos de ruta y la interfaz solo obedece. La otra hace que el efecto exprese la intención en términos de dominio —por ejemplo, un efecto llamado LoginCorrecto— y deja que la capa de navegación decida a dónde lleva eso. La segunda mantiene el modelo de vista completamente ignorante del grafo, lo que facilita reutilizar la pantalla en dos flujos distintos, y a cambio dispersa la decisión de navegación en más sitios. Elige una y sé consistente: mezclarlas produce grafos donde nadie sabe dónde se decide el siguiente paso.
Quién obedece: la ruta, nunca la pantalla
Emitir bien no sirve de nada si se consume mal, y el consumo de un efecto de navegación tiene una exigencia adicional respecto a los demás: navegar con la actividad en segundo plano puede lanzar una excepción. Por eso la recolección debe atarse al ciclo de vida y no a la composición, y por eso el estado mínimo del ciclo de vida importa aquí más que en cualquier otro efecto.
@Composable
fun LoginRuta(
vm: LoginViewModel = hiltViewModel(),
alNavegar: (Any) -> Unit,
alVolver: () -> Unit,
) {
val estado by vm.collectAsState()
val anfitrion = remember { SnackbarHostState() }
vm.collectSideEffect { efecto ->
when (efecto) {
is LoginEfecto.Navegar -> alNavegar(efecto.destino)
is LoginEfecto.Avisar -> anfitrion.showSnackbar(efecto.mensaje)
LoginEfecto.Volver -> alVolver()
}
}
LoginPantalla(estado, anfitrion, onEntrar = vm::entrar)
}
El componible de ruta recibe lambdas de navegación en vez del controlador entero, y esa decisión no es cosmética. Una pantalla que recibe el NavController conoce el grafo completo, puede saltar a cualquier sitio y solo se puede probar levantando un anfitrión de navegación. Una pantalla que recibe lambdas declara su contrato de salida en su propia firma, se previsualiza sin nada montado y se reutiliza en otro flujo cambiando quién le pasa las lambdas.
Recolectar los efectos sin ciclo de vida mantiene viva la colección con la aplicación en segundo plano, y el primer efecto de navegación que llegue en ese momento intentará manipular la pila cuando el sistema ya guardó su estado. El resultado va desde la excepción hasta la navegación silenciosamente perdida, según la versión de la librería. Con el estado mínimo iniciado, la colección se cancela al ocultarse la pantalla y los efectos pendientes esperan en el búfer; subir al estado reanudado es más estricto y más seguro cuando hay diálogos o pantallas parcialmente visibles, a cambio de retrasar la orden hasta el primer plano real.
Lo que este enfoque pierde
Aquí conviene ser honesto, porque el consenso se defiende mejor conociendo sus grietas que ignorándolas. El enfoque de navegación como efecto tiene tres costes reales.
La pila deja de ser observable
Nadie en tu arquitectura sabe dónde está el usuario. Esa información vive dentro del NavController, en una estructura que tu lógica no lee ni prueba, y cualquier decisión que dependa de ella exige preguntarle a la librería.
Los deep links entran por otra puerta
Una intención externa no pasa por tu contenedor: la resuelve el grafo antes de que ningún efecto se emita. Acabas con dos caminos hacia el mismo destino y con la obligación de mantenerlos coherentes a mano.
La restauración es opaca
Tras la muerte del proceso, quien reconstruye la pila es la librería a partir de su propio estado guardado. Tu modelo no participa, no puede verificarlo en un test unitario y solo puede confiar.
Ninguno de los tres es un defecto de la técnica: son consecuencias de haber delegado la propiedad de la pila. Al decidir que la navegación es una orden y no un dato, aceptaste que el registro de por dónde ha pasado el usuario pertenece a un componente externo, y todo lo que se sigue de ahí es coherente. La lección siguiente explora qué ocurre si se toma la decisión contraria, y por qué esa alternativa tampoco es gratis.
La razón profunda por la que este patrón se impuso no es empírica sino gramatical, y verla así ahorra mucha discusión. El estado es un conjunto de proposiciones: afirma que hay tres mensajes sin leer, que la petición está en vuelo, que el campo de la tarjeta es inválido. Una proposición se puede consultar cuantas veces se quiera sin alterar el mundo, y de esa inocuidad depende todo el modelo declarativo, porque Compose recompone cuando quiere y una interfaz solo es sana si repintar es gratis. Navegar, en cambio, es un imperativo: no describe, ordena; no afirma que el usuario está en el detalle, manda que vaya. Y un imperativo tiene una propiedad que ninguna proposición tiene: ejecutarlo dos veces no es redundancia sino duplicación. Al meter la orden en el flujo de proposiciones se produce un error de categoría que ningún compilador puede detectar, porque en el nivel de los tipos ambas cosas son un Boolean; el error solo se manifiesta cuando el mecanismo de reemisión hace exactamente lo que promete y vuelve a entregar la última proposición, que resulta ser una orden ya cumplida. La década que la industria pasó inventando envoltorios de un solo uso, banderas que se limpian solas y eventos consumibles fue una década de intentar simular semántica imperativa dentro de una estructura declarativa, y todos aquellos artefactos fallaban por el mismo sitio. La salida no era mejor fontanería: era aceptar que una pantalla produce dos salidas de naturaleza distinta —una descripción que se contempla y una lista de órdenes que se cumplen— y darles dos canales con garantías opuestas. Que la navegación sea el caso más doloroso de esta confusión no es casualidad: es el imperativo cuyo efecto secundario es más visible, porque el usuario acaba en otra pantalla.
- Busca en tu proyecto un campo booleano de estado cuyo único propósito sea disparar una navegación y documenta el bug exacto que produce al rotar.
- Reescríbelo como un caso de un tipo sellado de efectos, decidiendo si transporta la ruta construida o la intención de dominio. Justifica la elección.
- Adapta el componible de ruta para consumir el efecto con ciclo de vida y comprueba que la pantalla pura no menciona el
NavController. - Emite dos efectos de navegación seguidos con la pantalla en segundo plano y describe qué ocurre exactamente al volver al primer plano.
- Redacta en tres frases qué información deja de ser observable en tu arquitectura por haber delegado la pila, y quién la necesitaría.