wandres.dev
STACKSTATE · navegación en pila

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`.

⏱ 20 min

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.

🎯 Al terminar esta lección sabrás
  • Apilar desde el reducer con append y distinguir cuándo eso es preferible a un enlace declarativo.
  • Desapilar con removeLast y con popFrom, 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 un TestStore.

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.

💡
Empuja el estado final, no un identificador

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.

La navegación deja de tener historia y pasa a tener valor

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.

⚔️ Escribe un flujo largo y termínalo con un salto
  1. 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.
  2. 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.
  3. 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.
  4. Guarda el id del segundo paso y añade un salto con pop(to:). Repite con pop(from:) y anota exactamente qué pantalla queda visible en cada caso.
  5. Escribe un TestStore que 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.