Navegar como estado: la pila de retroceso convertida en dato
El enfoque contrario al consenso sostiene que la navegación sí es estado, siempre que se modele lo correcto: no la orden de saltar, sino la pila entera como lista inmutable de destinos. Esta lección desarrolla esa tesis con rigor, muestra por qué la prueba de la reconstrucción la aprueba sin contradicción, la implementa como reducción sobre una lista de rutas tipadas y la conecta con la generación de librerías que la adoptan de forma nativa. Se examinan sus ventajas reales —deep linking sin camino paralelo, restauración verificable, pruebas de flujo sin emulador, interfaces adaptativas con varios paneles— y también su coste: la pila deja de ser un detalle de la librería para convertirse en responsabilidad tuya.
La lección anterior cerró con tres pérdidas: la pila deja de ser observable, los deep links entran por otra puerta y la restauración es opaca. Las tres tienen la misma causa y por tanto admiten la misma cura. Si el problema es que el registro de por dónde ha pasado el usuario vive dentro de un componente que tu arquitectura no lee, la solución evidente es sacarlo de ahí. Y en cuanto se formula así, la objeción de la lección anterior se disuelve: lo que no puede ser estado es la orden de navegar, porque es un imperativo. Pero la pila no es una orden, es una descripción perfectamente proposicional del mundo —el usuario está en el detalle del pedido siete, habiendo pasado por la lista— y las descripciones son exactamente lo que el estado sabe representar.
- Distinguir la orden de navegar, que nunca es estado, de la pila de destinos, que sí puede serlo.
- Modelar la pila como lista inmutable de rutas tipadas y las transiciones como reducciones puras.
- Evaluar las ventajas reales del enfoque en deep linking, restauración, pruebas e interfaces adaptativas.
- Reconocer el coste que se asume al tomar la propiedad de la pila y decidir cuándo compensa.
Lo que la prueba de la reconstrucción aprueba
Vuelve al criterio y aplícalo con cuidado, porque la aparente contradicción con la lección anterior se resuelve en una sola distinción. Si esta pantalla se destruyera y se reconstruyera ahora mismo desde su último estado conocido, ¿debería volver a ejecutarse el salto? No. ¿Debería el usuario seguir estando en el detalle del pedido siete, con la lista debajo? Evidentemente sí. La transición no es reconstruible; la posición resultante lo es, y no solo lo es: tiene que serlo, o la aplicación pierde al usuario cada vez que el sistema mata el proceso.
Dicho en el vocabulario de las dos categorías: navigate es un verbo, y los verbos son efectos; la pila es un sustantivo, y los sustantivos son estado. Cuando la lección anterior denunciaba el campo irAInicio, no denunciaba que la navegación estuviera en el estado, sino que había un imperativo camuflado dentro de una proposición. Una lista de destinos no es un imperativo camuflado: es la respuesta a la pregunta dónde está el usuario, que es tan proposicional como cuántos mensajes tiene sin leer.
flowchart TD
A[Salida de navegacion] --> B{Es una orden o una posicion}
B -->|Orden de saltar| C[Efecto de una sola vez]
B -->|Posicion resultante| D[Estado: lista de destinos]
D --> E[Reemitir es correcto y necesario]
C --> F[Reemitir apila duplicados]
style D fill:#a6e3a1,color:#11111b
style C fill:#f9e2af,color:#11111b
style F fill:#f38ba8,color:#11111bHay una prueba complementaria que refuerza la misma conclusión y que conviene tener a mano porque es más rápida de aplicar: pregúntate si la información podría mostrarse en una interfaz. La respuesta para la pila es que sí, y de forma bien conocida: unas migas de pan que indiquen el recorrido, un panel lateral que resalte la sección activa, una barra inferior que marque la pestaña actual. Todas esas piezas son funciones de la pila, y una información que se pinta es, por definición, estado. Que hoy tengas que preguntarle al controlador para dibujarlas es precisamente el síntoma de que la verdad vive en el sitio equivocado.
La confusión histórica viene de que durante años solo existió una manera de expresar la navegación —llamar a un método imperativo sobre un controlador— y por tanto la única forma de meterla en el estado era guardar la llamada pendiente. Con rutas tipadas, en cambio, un destino es un valor con igualdad estructural, y una lista de valores es el objeto más natural del mundo para un reduce.
La pila como lista y las transiciones como reducciones
Modelar la pila es sorprendentemente aburrido, que es la mejor señal posible. Es una lista de rutas, no vacía por invariante, cuyo último elemento es el destino visible. Navegar es añadir; volver es quitar; reemplazar es sustituir la cima; limpiar hasta un punto es cortar la lista. Todas son operaciones puras sobre una estructura inmutable, comprobables en un test sin emulador.
@JvmInline
value class Pila(val destinos: List<Ruta>) {
val cima: Ruta get() = destinos.last()
fun empujar(r: Ruta) = Pila(destinos + r)
fun sacar() = if (destinos.size > 1) Pila(destinos.dropLast(1)) else this
fun reemplazar(r: Ruta) = Pila(destinos.dropLast(1) + r)
fun hasta(r: Ruta) = Pila(destinos.takeWhile { it != r } + r)
}
data class AppState(
val pila: Pila = Pila(listOf(Inicio)),
val sesion: Sesion? = null,
)
El invariante de la lista no vacía es la pieza que evita la mitad de los fallos posibles, y merece hacerse explícito en el tipo o en el constructor. Sin él, sacar sobre una pila de un elemento deja la aplicación sin nada que pintar, y ese caso llega antes de lo que parece: basta con que el gesto de retroceso del sistema no esté conectado a la salida de la actividad.
class AppViewModel : ContainerHost<AppState, AppEfecto>, ViewModel() {
override val container = container<AppState, AppEfecto>(AppState())
fun abrirPedido(id: String) = intent {
reduce { state.copy(pila = state.pila.empujar(Detalle(id))) }
}
fun atras() = intent {
if (state.pila.destinos.size == 1) postSideEffect(AppEfecto.CerrarApp)
else reduce { state.copy(pila = state.pila.sacar()) }
}
fun entrarConSesion(s: Sesion) = intent {
reduce { state.copy(sesion = s, pila = state.pila.reemplazar(Inicio)) }
}
}
Nota que ni una sola de esas funciones es asíncrona, ni toca la plataforma, ni necesita un contexto. Son reducciones puras sobre una lista, del mismo orden de complejidad que marcar un elemento como leído. Esa banalidad es el argumento entero: la navegación deja de ser un subsistema con su propia semántica y pasa a ser un campo más del modelo, sujeto a las mismas reglas, las mismas pruebas y el mismo tratamiento de errores que todo lo demás.
Observa lo que ha ocurrido con el caso del login: la sesión y la pila cambian en la misma reducción, atómicamente, y por tanto es imposible que exista un instante en que el usuario esté en la pantalla de inicio sin sesión o con sesión pero todavía en el login. Con navegación como efecto, ese instante existe siempre —el estado se reduce y el salto llega después, por otro canal— y las incoherencias momentáneas que produce son una fuente conocida de parpadeos.
Durante mucho tiempo, adoptar este enfoque significaba escribir tu propio anfitrión: un componible que leyera la cima de la lista y decidiera qué pintar, con sus animaciones y su gestión de retroceso a mano. La generación actual de librerías de navegación para Compose invierte el modelo de fábrica y expone la pila como una lista mutable observable que tú posees, mientras la librería aporta el intérprete: recorre la lista, resuelve cada clave a su contenido y se encarga de las transiciones, los ámbitos y la restauración. El patrón dejó de ser una desviación para convertirse en la dirección oficial, precisamente porque las interfaces adaptativas lo exigían.
Qué se gana
Las ventajas no son estéticas y conviene enumerarlas con el mismo cuidado con que se enumeraron los costes del enfoque anterior.
Deep linking sin puerta trasera
Una intención externa se convierte en una lista de destinos y se reduce como cualquier otro intent. No hay un segundo camino que mantener coherente: entrar desde fuera es exactamente el mismo mecanismo que entrar desde dentro, con la pila sintética incluida.
Restauración verificable
Si la pila es un dato serializable, guardarla es guardar estado, y guardar estado ya sabes hacerlo. Puedes escribir un test que la serialice, la recupere y compare, sin depender de la caja negra de la librería.
Flujos probados sin emulador
Un test unitario puede lanzar tres intents y afirmar que la lista de destinos resultante es la esperada. Los recorridos completos —incluidos los erróneos— se verifican en milisegundos y no en una granja de dispositivos.
Interfaces adaptativas
En una tableta, dos elementos de la cima se pintan a la vez en dos paneles; en un teléfono, solo uno. Si la pila es un dato, esa decisión es una función de la lista y del tamaño disponible, no una reescritura del grafo.
La cuarta tarjeta es la que ha empujado el cambio en la industria más que ninguna razón teórica. Un grafo que decide internamente qué destino es el visible presupone que hay exactamente uno, y ese presupuesto se rompe en cuanto la ventana es ancha, hay una lista con detalle al lado o el dispositivo se pliega. Cuando la pila es una lista tuya, mostrar los dos últimos elementos en vez del último es una decisión de pintado; cuando vive dentro de la librería, es una función que alguien tiene que exponerte.
Que la pila sobreviva a la muerte del proceso no significa que las pantallas reaparezcan llenas. Cada destino restaurado vuelve a ser una semilla que necesita repoblarse desde el repositorio, y si un destino intermedio dependía de datos que solo existían en memoria —un objeto pasado a mano, un resultado calculado en la pantalla anterior— la restauración lo dejará vacío o incoherente. La disciplina de la semilla mínima no se relaja al tomar la propiedad de la pila: se vuelve más importante, porque ahora la coherencia del conjunto es responsabilidad tuya.
Qué se paga
El coste es real y se puede resumir en una frase: pasas de configurar una navegación a mantenerla. Todo lo que la librería resolvía con valores por defecto razonables —el gesto de retroceso del sistema, la restauración de la posición de desplazamiento, el ámbito de los modelos de vista por destino, las animaciones de transición, el comportamiento con destino único— se convierte en decisiones que tú tomas y en invariantes que tú sostienes.
Hay además un riesgo de diseño más sutil que el trabajo extra: la tentación de meter en el estado global de navegación cosas que pertenecen a cada pantalla. Un estado raíz que acumula la pila, la sesión, el tema, el resultado pendiente y tres banderas más se convierte en el objeto que todo el mundo toca y nadie entiende. La disciplina correcta es que el estado de navegación contenga la pila y poco más, y que cada destino conserve su propio contenedor con su propio ámbito.
Merece la pena señalar además que el efecto no desaparece del todo, y quien crea que sí se llevará una sorpresa. Sigue habiendo actos que no son la pila: cerrar la aplicación, abrir una pantalla de otra aplicación, mostrar un aviso, pedir un permiso al sistema. En el fragmento anterior, el caso de retroceso sobre una pila de un solo elemento se resuelve con un efecto, y es correcto que así sea. Poseer la pila no elimina el canal de efectos; solo saca de él la única salida que nunca debió estar ahí discutida como orden, porque también era una posición.
No hay que elegir para toda la aplicación. Es perfectamente razonable poseer la pila en el nivel raíz —donde viven las decisiones de sesión, las pestañas y el reparto en paneles— y seguir usando efectos de navegación dentro de un flujo lineal concreto, como un asistente de compra de cinco pasos que nadie va a enlazar desde fuera. El error es no decidirlo: cuando ambos mecanismos operan sobre la misma pila sin una frontera declarada, aparecen estados imposibles en los que el modelo cree una cosa y la librería otra.
El criterio de elección, entonces, no es cuál enfoque es mejor sino qué necesita tu aplicación. Si es un flujo lineal de pantallas en un teléfono, la navegación como efecto es más barata y perfectamente correcta. Si tiene deep linking serio, interfaces adaptativas, flujos condicionales que dependen de la sesión o requisitos de prueba sobre recorridos completos, el coste de poseer la pila se amortiza en el primer mes.
Lo que está realmente en disputa aquí no es una técnica de navegación sino una pregunta de fondo que atraviesa toda la arquitectura de software: quién es el dueño de la verdad. El enfoque de la navegación como efecto delega esa propiedad en un componente externo y se queda con una relación de mensajería —tu lógica manda órdenes y la librería mantiene el registro— lo cual es una elección perfectamente legítima y exactamente la misma que haces al delegar la persistencia en una base de datos o el renderizado en el sistema. Toda delegación compra simplicidad al precio de la opacidad: dejas de tener que resolver un problema y a cambio dejas de poder observarlo, probarlo y razonar sobre él con tus propias herramientas. El enfoque de la pila como dato hace el movimiento contrario, que es el mismo que hizo Compose con la interfaz y que hizo MVI con el estado de pantalla: recupera la propiedad de una estructura que antes vivía en un componente imperativo y la convierte en un valor inmutable sobre el que se razona con funciones puras. Que este movimiento se repita una y otra vez en la historia del software no es casualidad; es que la cosificación tiene una recompensa acumulativa. Un dato es componible, comparable, serializable, reducible y comprobable, y cada una de esas propiedades se multiplica con las demás: una pila que es una lista puede guardarse porque es serializable, probarse porque es comparable y adaptarse a una tableta porque es recorrible, y ninguna de esas tres capacidades hubo que diseñarlas por separado. Ese es el argumento profundo a favor de la pila como estado, y también la advertencia implícita: al recuperar la propiedad recuperas todos los problemas que la delegación te ocultaba, y solo merece la pena si de verdad ibas a necesitar mirarlos.
- Escribe un tipo de pila sobre una lista de rutas tipadas con su invariante de no vacía y las cuatro operaciones básicas, todas puras.
- Traduce tres transiciones reales de tu aplicación a reducciones sobre ese tipo, incluida una que cambie sesión y pila a la vez.
- Escribe un test unitario que ejecute un flujo de cinco pasos y afirme la lista de destinos resultante, sin levantar ningún emulador.
- Serializa la pila, deserialízala y compara. Identifica qué destinos quedan vacíos por depender de datos que no viajaron.
- Argumenta en cinco frases, para tu proyecto concreto, si el coste de poseer la pila se amortiza. Nombra el requisito que decide.