Empujar y sacar: apilar desde el reducer, retroceder y saltar niveles
Si la pila es una colección, navegar es mutarla. Esta lección recorre el vocabulario completo de esa mutación: apilar con `append` desde el reducer cuando hay trabajo previo, desapilar con `removeLast`, permitir que una pantalla se cierre a sí misma con la dependencia `dismiss` sin nombrar a nadie, y los saltos largos con `pop(to:)`, `pop(from:)` y el vaciado a la raíz. Incluye la garantía de cancelación al desapilar y la forma canónica de afirmar todos estos movimientos en un `TestStore`.
Con la pila modelada y atada a la vista, la navegación se ha reducido a un problema de manipulación de secuencias, y eso es una buena noticia porque las secuencias son el territorio mejor cartografiado de la programación. Avanzar es añadir al final, retroceder es quitar del final, y volver a un punto conocido es truncar. Lo que esta lección añade sobre esa aritmética elemental son tres cosas que no se deducen del tipo: quién tiene derecho a hacer cada movimiento, qué garantías de ciclo de vida vienen incluidas con cada uno, y cómo se afirman en una prueba. La tercera importa más de lo que parece, porque un flujo de navegación afirmado en un TestStore es un flujo que ya no puede romperse sin que alguien se entere antes del despliegue.
- Apilar desde el reducer con
appendy distinguir cuándo eso es preferible a un enlace declarativo. - Desapilar con
removeLasty conpopFrom, sabiendo qué garantía de cancelación de efectos acompaña a cada retirada. - Permitir que una pantalla se cierre a sí misma mediante la dependencia
dismiss, sin que conozca a su padre ni su profundidad. - Ejecutar saltos de varios niveles con
pop(to:),pop(from:)y el vaciado a la raíz, y afirmarlos en unTestStore.
Apilar desde el reducer
El enlace declarativo de la lección anterior cubre el caso en que el destino se conoce sin más trámite. En cuanto hay una comprobación, una carga o una decisión de por medio, el empuje pertenece al reducer, porque es ahí donde vive la lógica y donde puede consultarse el resto del estado.
case .continuarPulsado:
guard state.formulario.esValido else {
state.error = "Faltan campos"
return .none
}
state.camino.append(.resumen(Resumen.State(datos: state.formulario)))
return .none
case .comprarPulsado:
return .run { [carrito = state.carrito] send in
let pedido = try await api.crearPedido(carrito)
await send(.pedidoCreado(pedido))
}
case let .pedidoCreado(pedido):
state.camino.append(.confirmacion(Confirmacion.State(pedido: pedido)))
return .none
Ese segundo bloque es el argumento decisivo a favor de apilar desde el reducer: la pantalla de confirmación no puede existir hasta que el servidor devuelva el pedido, y por tanto el empuje tiene que ocurrir cuando llega la respuesta, no cuando el usuario toca. Un enlace declarativo no puede esperar; una acción sí. Al asignar el estado ya poblado, la pantalla nueva nace completa y sin ningún indicador de carga interno.
La costumbre heredada de las rutas por URL empuja un identificador y deja que el destino cargue lo suyo al aparecer, lo que produce un parpadeo de carga en cada transición y un estado transitorio vacío que hay que modelar. Cuando el dato ya está en el padre, pásalo entero al construir el State del destino. Reserva la carga interna para lo que de verdad no puedes saber antes de llegar, y verás desaparecer la mitad de tus estados de carga.
Retroceder: tres orígenes, un solo efecto
Retroceder ocurre por tres vías distintas y conviene tenerlas separadas en la cabeza, porque cada una nace en un sitio y solo una de ellas la escribes tú.
| Origen | Forma | Quién lo inicia |
|---|---|---|
| Gesto atrás | botón del sistema | acción popFrom(id:) |
el usuario, a través de SwiftUI |
| Decisión del padre | state.camino.removeLast() |
el reducer que posee la pila |
| Decisión de la propia pantalla | dependencia dismiss |
el hijo, sin nombrar a nadie |
La tercera vía es la más elegante y la menos conocida. Una pantalla apilada que quiere terminar no necesita una acción delegada ni saber cuán profunda es: declara la dependencia de descarte y la invoca desde un efecto.
@Reducer
struct Confirmacion {
@Dependency(\.dismiss) var dismiss
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .hechoPulsado:
return .run { _ in await self.dismiss() }
// ...
}
}
}
}
dismiss es asíncrona y por eso vive dentro de un efecto, nunca en el cuerpo síncrono del reducer. Al invocarla, TCA retira de la pila el elemento que la ejecutó, sea cual sea su posición. La feature no menciona a su padre, no conoce su profundidad y sigue siendo montable en cualquier contexto: exactamente la propiedad enchufable que persigues desde el Nivel 14.
Sea cual sea el origen, la consecuencia es idéntica y no la escribes en ninguna parte: al salir de la pila, todos los efectos en vuelo de esa pantalla se cancelan. El temporizador para, el flujo se cierra, la petición se aborta. Es la garantía de forEach del Nivel 15 operando sobre la dimensión de la profundidad.
Saltar varios niveles de golpe
Los flujos largos terminan casi siempre con un salto que atraviesa varias pantallas: se completó una compra y hay que volver al catálogo, se canceló un asistente de cinco pasos y hay que volver a la raíz. StackState ofrece un vocabulario preciso para eso.
// Volver a la raíz: vaciar la colección
state.camino.removeAll()
// Retroceder exactamente dos pantallas
state.camino.removeLast(2)
// Truncar por identidad: conservar hasta ese elemento inclusive
state.camino.pop(to: idDelCatalogo)
// Truncar por identidad: retirar ese elemento y todo lo que hay encima
state.camino.pop(from: idDelPaso2)
La diferencia entre las dos últimas es la que más se confunde y la única que hay que memorizar: pop(to:) deja vivo el elemento nombrado, pop(from:) lo mata junto con todo lo que tiene encima. Cuando el destino del salto es una pantalla concreta cuyo id no guardaste, puedes localizarlo recorriendo la colección, ya que la pila es tan inspeccionable como cualquier array.
Apilar con lógica
append desde el reducer permite empujar tras validar o tras esperar una respuesta.
Cerrarse sin saber dónde
La dependencia dismiss retira la pantalla que la invoca sin que esta nombre a nadie.
Truncar por identidad
pop(to:) conserva el elemento nombrado; pop(from:) lo elimina con todo lo que tiene encima.
Cancelación incluida
Salir de la pila apaga todos los efectos en vuelo de esa pantalla sin escribir limpieza.
flowchart TD A[Raiz] --> B[Catalogo] B --> C[Producto] C --> D[Pago] D --> E[Confirmacion] E -->|pop to id del catalogo| B E -->|removeAll| A E -->|dismiss desde el hijo| D style B fill:#89b4fa,color:#11111b style A fill:#a6e3a1,color:#11111b
Todos estos movimientos se afirman en el TestStore con la misma naturalidad con que se afirma un contador, porque no son otra cosa que mutaciones de un valor. El empuje que hace el usuario llega como acción de la pila; los que hace tu reducer se afirman como cambios de estado.
let store = TestStore(initialState: Tienda.State()) { Tienda() }
await store.send(.camino(.push(id: 0, state: .producto(Producto.State(id: 7))))) {
$0.camino[id: 0] = .producto(Producto.State(id: 7))
}
await store.send(.camino(.element(id: 0, action: .producto(.comprarPulsado))))
await store.receive(\.pedidoCreado) {
$0.camino.append(.confirmacion(Confirmacion.State(pedido: .stub)))
}
await store.send(.camino(.element(id: 1, action: .confirmacion(.hechoPulsado))))
await store.receive(\.camino.popFrom) {
$0.camino.removeLast()
}
Los identificadores de la prueba son enteros pequeños y predecibles porque el generador de identidades de pila también es una dependencia controlada: en un TestStore empieza en cero y avanza de uno en uno. Un flujo de cinco pantallas se convierte así en un guion legible que falla en cuanto alguien altera una transición.
Hay una diferencia filosófica entre las dos maneras de responder a la pregunta de dónde está el usuario, y esta lección la hace tangible. En el modelo imperativo, la respuesta es una historia: se empujó esto, luego aquello, luego se descartó lo otro, y el estado actual es el sedimento de esa secuencia de sucesos. Para llevar al usuario a otro sitio hay que razonar sobre la historia —cuántos empujes deshacer, en qué orden, con qué animación— y por eso el código de navegación imperativa está lleno de cuentas frágiles y de suposiciones sobre lo que otro trozo de código hizo antes. Cuando la pila es un valor, la historia deja de importar por completo: el destino se describe diciendo cómo debe quedar la colección, no cuántos pasos hay que deshacer para llegar. Volver al catálogo no es retroceder tres veces, es truncar hasta ese elemento; cancelar un asistente no es desapilar cinco pantallas, es vaciar el array. Y como el resultado se especifica en vez de construirse paso a paso, es imposible que una animación interrumpida, una pulsación doble o un efecto tardío dejen la navegación a medio camino: no hay medio camino, hay un valor anterior y un valor posterior, y SwiftUI se encarga de animar la diferencia. Esa es la razón por la que los flujos largos —los asistentes, los procesos de compra, los registros de varios pasos, todo aquello que en el modelo imperativo obliga a coordinadores, banderas y comprobaciones defensivas— se escriben aquí con una línea de mutación de colección, y se prueban con un guion de una docena de líneas que verifica el recorrido entero sin abrir un simulador. Describir el estado final en lugar de la secuencia de pasos es la misma inversión que ya hizo SwiftUI con el dibujo, llevada al único rincón de la app donde todavía sobrevivía la mentalidad de las órdenes.
- Construye un asistente de cuatro pasos apilados donde cada paso valide antes de empujar el siguiente desde el reducer, y comprueba que un paso inválido no altera la pila.
- Haz que el último paso lance una petición y apile la confirmación solo cuando llegue la respuesta. Verifica que la pantalla nace con los datos puestos y sin indicador de carga.
- Añade a la confirmación un botón de terminar que use la dependencia
dismiss, y otro que devuelva al usuario a la raíz vaciando la pila. Explica cuál de los dos corresponde a cada intención del usuario. - Guarda el id del segundo paso y añade un salto con
pop(to:). Repite conpop(from:)y anota exactamente qué pantalla queda visible en cada caso. - Escribe un
TestStoreque recorra el flujo completo de principio a fin, incluido el salto final. Después cambia una transición del reducer y comprueba que la prueba falla señalando el punto exacto.