wandres.dev
STACKSTATE · navegación en pila

`StackState` y `StackAction`: la pila de pantallas como colección

La navegación en pila deja de ser una secuencia de órdenes para convertirse en un array dentro del estado. Esta lección disecciona `StackState` como colección de estados heterogéneos gobernada por un `@Reducer enum` de destinos, la identidad opaca e irrepetible que asigna a cada pantalla apilada, los tres casos de `StackAction` —`push`, `element` y `popFrom`— con los que se conversa con la pila entera, y el operador `forEach` que multiplica el reducer de destinos a lo largo de toda la profundidad manteniendo el árbol de composición plano.

⏱ 20 min

En el Nivel 15 aprendiste que una lista de features no exige una lista de reducers: basta un reducer multiplicado por forEach sobre una colección identificada. La navegación en pila es exactamente ese mismo teorema aplicado a una dimensión distinta. Una pila de pantallas no es más que una colección ordenada de estados, donde el orden significa profundidad en vez de posición visual, y donde los elementos, a diferencia de las filas de una tabla, no son todos del mismo tipo: la primera puede ser un detalle de producto, la segunda una reseña, la tercera el perfil de quien la escribió. StackState es la estructura que sostiene esa colección heterogénea, y StackAction el sobre con el que se le habla. Juntos consiguen algo que sorprende la primera vez que se ve: cinco pantallas encadenadas producen dos envoltorios de acción, no cinco.

🎯 Al terminar esta lección sabrás
  • Declarar una pila de navegación como StackState de un @Reducer enum de destinos y entender por qué el enum es obligatorio.
  • Leer los tres casos de StackAction y saber cuál emite el usuario, cuál el sistema y cuál tu reducer.
  • Comprender la identidad opaca e irrepetible de StackElementID y qué clase de fallo elimina frente a indexar por posición.
  • Ensamblar el operador forEach sobre la pila y explicar por qué el árbol de composición no crece con la profundidad.

La pila como colección de estados heterogéneos

Una pila necesita poder contener pantallas de tipos distintos, y en un lenguaje de tipos estáticos eso solo se resuelve de una manera honesta: con una suma. Por eso StackState no se declara sobre una feature concreta sino sobre el State de un @Reducer enum que enumera los destinos alcanzables.

@Reducer
struct Tienda {
  @Reducer
  enum Camino {
    case producto(Producto)
    case resena(Resena)
    case perfil(Perfil)
  }

  @ObservableState
  struct State: Equatable {
    var destacados: [Producto.Modelo] = []
    var camino = StackState<Camino.State>()
  }

  enum Action {
    case camino(StackActionOf<Camino>)
  }
}

StackState<Camino.State> es una colección de acceso aleatorio, mutable y reemplazable por rangos: puedes recorrerla, contarla, indexarla y modificarla como cualquier array. Lo que la distingue es que cada elemento, al entrar, recibe una identidad generada por la propia estructura, y esa identidad —no la posición— es la que gobierna el enrutado. StackActionOf<Camino> es la abreviatura de StackAction<Camino.State, Camino.Action>, la misma economía notacional que ya viste en IdentifiedActionOf.

💡
Por qué el destino es un enum y no un protocolo

La tentación de modelar los destinos con un protocolo existencial desaparece en cuanto piensas en Equatable y en el testing. Un enum cerrado permite al compilador verificar que el switch de la vista cubre todos los destinos, permite comparar dos estados de pila por valor sin trucos, y permite que la macro @Reducer sintetice el reducer que despacha cada caso a su feature. Un existencial te devolvería al mundo de las conversiones dinámicas y del estado que no se puede comparar. La pila es cerrada por diseño: si aparece un destino nuevo, añades un caso y el compilador te enseña todos los sitios que hay que atender.

Los tres casos de StackAction

El sobre con el que se conversa con una pila tiene exactamente tres formas, y conviene aprender de memoria quién emite cada una porque de ahí sale la mitad del razonamiento sobre navegación.

Caso Quién lo emite Qué significa
push(id:state:) la vista, vía enlace de navegación se ha apilado una pantalla nueva
element(id:action:) una pantalla apilada esa pantalla concreta ha emitido su acción
popFrom(id:) el sistema | el gesto atrás se ha desapilado desde ese elemento

El caso element es el gemelo del que ya conoces de las colecciones identificadas: lleva remitente y mensaje, y el remitente es la identidad de la pantalla, no su profundidad. El caso popFrom es el más interesante de los tres porque llega sin que nadie de tu código lo haya pedido: cuando el usuario pulsa el botón atrás o arrastra desde el borde, SwiftUI retira la pantalla y TCA traduce ese hecho a una acción que tu reducer puede observar. El retroceso deja de ser un suceso invisible del sistema de vistas para convertirse en un dato con nombre que atraviesa el mismo canal que todo lo demás.

var body: some ReducerOf<Self> {
  Reduce { state, action in
    switch action {
    case let .camino(.popFrom(id: id)):
      print("El usuario abandonó", state.camino[id: id] as Any)
      return .none
    case .camino:
      return .none
    }
  }
  .forEach(\.camino, action: \.camino)
}

Fíjate en un detalle de secuencia que importa: cuando llega popFrom, el elemento todavía está en la pila, de modo que puedes leerlo por su id antes de que desaparezca. Es la última oportunidad de inspeccionar lo que el usuario deja atrás, y de ahí sale un patrón que aparecerá una y otra vez: guardar un borrador abandonado, registrar cuánto tiempo duró la visita, o preguntar si de verdad quiere salir sin guardar.

El caso push merece una nota aparte porque su origen desconcierta al principio. No lo emite tu reducer: cuando el usuario activa un enlace de navegación, es la vista quien anuncia que ha apilado algo, y tu reducer se entera del hecho consumado igual que se entera del retroceso. Cuando eres tú quien decide apilar —porque una respuesta de red llegó, porque una validación pasó—, no envías esa acción: mutas la colección directamente. Ambas vías conducen al mismo sitio, y esa simetría es la que hace que la pila no tenga nunca dos versiones de sí misma.

Identidad opaca: por qué no se indexa por posición

Cada elemento que entra en StackState recibe un StackElementID producido por la dependencia stackElementID. Es un valor opaco, monótonamente creciente y jamás reutilizado dentro de la vida del proceso: si desapilas la pantalla número tres y empujas otra, la nueva no hereda el identificador de la anterior.

// Enrutado por identidad, no por profundidad
state.camino[id: id]?.modifica()

// Y la colección sigue siendo una colección
state.camino.count
state.camino.last
state.camino.ids
🧱

Colección de sumas

Una pila es un array de estados de un enum de destinos, con la profundidad como orden.

🔑

Identidad irrepetible

Cada pantalla recibe un id opaco que nunca se recicla, aunque su posición sí se reutilice.

↩️

El atrás es un dato

El gesto de retroceder llega como popFrom, observable por el reducer antes de que el elemento muera.

🌳

Árbol plano

Cinco pantallas encadenadas producen dos envoltorios de acción, no cinco niveles de composición.

Las tres formas conviven sin estorbarse porque describen cosas distintas. La cuenta y el último elemento sirven para preguntar por la forma de la pila; los identificadores sirven para dirigirse a una pantalla concreta; y el subíndice por identidad es la única vía correcta de mutar un elemento, porque no depende de dónde esté.

El motivo de esa irrepetibilidad es una clase entera de fallo que desaparece. Imagina que la tercera pantalla lanzó una petición de red y el usuario retrocede antes de que responda; luego empuja otra pantalla distinta, que ocupa la misma posición tres. Si el enrutado fuese posicional, la respuesta tardía aterrizaría en una pantalla que no la pidió y mutaría datos ajenos. Con identidad opaca, esa acción tardía se dirige a un id que ya no existe: no muta nada y emite un aviso en tiempo de ejecución. Es el mismo razonamiento que sostenía IdentifiedArrayOf en el Nivel 15, ascendido a la dimensión de la profundidad.

flowchart TD
V[Enlace de navegacion en la vista] --> P[StackAction push con id nuevo]
P --> S[StackState guarda el estado del destino]
S --> F[forEach corre el reducer de ese elemento]
U[Gesto atras del usuario] --> Q[StackAction popFrom con id]
Q --> R[El padre puede leer el elemento]
R --> X[forEach cancela los efectos de ese id]
style S fill:#89b4fa,color:#11111b
style X fill:#a6e3a1,color:#11111b

Esa identidad no la inventa la estructura por su cuenta: la produce la dependencia stackElementID, que es una dependencia como cualquier otra de las del Nivel 12 y por tanto controlable. En producción entrega valores opacos e imprevisibles; bajo un TestStore entrega enteros que empiezan en cero y avanzan de uno en uno, de modo que una prueba de navegación puede nombrar sus pantallas con números pequeños y legibles en vez de con identificadores generados al azar. Es el mismo truco que hizo determinista al reloj y al generador de identificadores únicos, aplicado ahora a la profundidad.

El operador que cierra el montaje es forEach, el mismo nombre del Nivel 15 pero con una sobrecarga específica para pilas. Aplicado sobre un @Reducer enum no lleva closure final, porque la macro ya sintetizó el reducer que despacha cada caso a su feature. Y su efecto es el que ya conoces multiplicado por la profundidad: corre el reducer del destino que la identidad señale, fuerza el orden hijo antes que padre, y cancela todos los efectos en vuelo de una pantalla en el instante en que se desapila. El temporizador de la pantalla cuatro se apaga solo cuando el usuario retrocede a la tres.

Ese orden forzado tiene aquí la misma justificación que en las colecciones y conviene tenerla presente desde el principio. Cuando llega una acción dirigida a un elemento, el reducer de esa pantalla la procesa antes que el reducer raíz, de modo que la raíz puede desapilarla con seguridad al atender la misma acción: la pantalla ya reaccionó a su propio mensaje, ya canceló lo que tuviera que cancelar, y solo entonces desaparece. Si el orden fuese el inverso, cualquier reacción de la raíz que truncase la pila dejaría mensajes sin entregar en pantallas que se esfumaron a mitad de camino.

La profundidad de la navegación deja de ser profundidad de la arquitectura

Aquí ocurre algo que merece detenerse porque desmonta un reflejo adquirido en toda arquitectura anterior: el supuesto de que una jerarquía de pantallas exige una jerarquía de componentes. En el modelo mental heredado —controladores contenidos en controladores, coordinadores que instancian coordinadores— la quinta pantalla de un flujo vive cinco niveles por debajo de la raíz, y cualquier comunicación entre ella y el origen atraviesa cinco fronteras, cada una con su reenvío, su envoltorio y su oportunidad de romperse. StackState niega esa correspondencia. La pila es una colección plana: Producto, Resena y Perfil son hermanos en el tipo aunque el usuario los recorra uno tras otro, y una acción emitida en la pantalla más honda llega a la raíz con exactamente dos capas —el caso camino y el caso element— sin importar si la profundidad es de tres o de treinta. Lo que crece cuando el usuario navega no es la estructura del programa sino la longitud de un array, y eso cambia por completo la economía del diseño: añadir un destino cuesta un caso de enum, no un nivel de anidamiento; probar la pantalla más profunda cuesta lo mismo que probar la primera; y el reducer raíz, que en el modelo jerárquico solo veía a su hijo inmediato, aquí observa la pila entera como un valor que puede leer, recorrer y reescribir de una pieza. Esa última consecuencia es la que hará posible todo lo que viene en las lecciones siguientes —saltar tres niveles atrás de golpe, reaccionar a un hecho ocurrido al fondo del flujo, materializar una ruta entera desde una URL—, y todas se apoyan en la misma observación elemental: cuando el camino recorrido es un dato ordinario en el estado, ir hacia adelante y volver hacia atrás dejan de ser verbos del sistema de vistas para convertirse en operaciones sobre una secuencia.

⚔️ Modela un flujo real como una pila de datos
  1. Elige un flujo de tu app con al menos tres pantallas encadenadas de tipos distintos y declara un @Reducer enum con un caso por destino.
  2. Añade al reducer raíz un campo camino de tipo StackState sobre ese enum, un único caso de acción con StackActionOf y el modificador forEach correspondiente.
  3. Escribe la rama popFrom con un registro que imprima el estado del elemento que se abandona, y comprueba que puedes leerlo por su id antes de que desaparezca.
  4. Cuenta cuántos envoltorios lleva una acción emitida en la pantalla más profunda. Compáralo con el número de niveles que tendría la misma pantalla en una jerarquía anidada.
  5. Argumenta por qué una respuesta de red tardía de una pantalla ya desapilada no puede corromper a la pantalla que ocupó su lugar. Nombra la propiedad exacta del identificador que lo impide.