wandres.dev
NAVEGACIÓN COMO DATO · el árbol de estado

Navegación en árbol y navegación en pila

Una vez aceptado que la navegación es un dato, queda la pregunta de qué forma tiene ese dato. TCA ofrece dos y solo dos: el árbol, donde cada pantalla declara estáticamente el conjunto de destinos que puede presentar y guarda a lo sumo uno en un hueco opcional, y la pila, donde una colección plana y ordenada de destinos crece y decrece sin que el tipo diga a qué profundidad se llegará. Esta lección compara ambos modelos en su estructura, en su equivalente exacto de SwiftUI, en el coste de composición que imponen y en el criterio para elegir uno u otro sin arrepentirse tres meses después.

⏱ 20 min

La lección anterior dejó establecido que la navegación es un valor. Ahora hay que decidir de qué tipo es ese valor, y la respuesta no es indiferente: determina qué trayectos son expresables, qué tan hondo puede llegar el usuario, cuántos envoltorios atraviesa una acción y hasta qué punto el compilador puede ayudarte. Hay dos formas canónicas y conviene entenderlas como lo que son, dos maneras distintas de acotar el infinito. El árbol acota por anticipado: cada pantalla enumera sus destinos posibles en un enum y la profundidad total queda fijada en el momento en que escribes los tipos. La pila renuncia a esa acotación y la sustituye por otra: no sabe cuántas pantallas habrá, pero sí que todas serán del mismo tipo suma y que estarán en fila. Una es un tipo recursivo con fondo; la otra, una secuencia homogénea sin fondo. Elegir mal no rompe nada de inmediato, y por eso el error tarda tanto en manifestarse.

🎯 Al terminar esta lección sabrás
  • Modelar navegación en árbol con @Presents, un enum de destino y ifLet, y saber qué acota exactamente ese modelo.
  • Modelar navegación en pila con StackState, StackActionOf y forEach, y entender por qué es una colección plana.
  • Emparejar cada modelo con su equivalente exacto en SwiftUI y con las acciones de descarte que le corresponden.
  • Aplicar un criterio de elección basado en profundidad, recursividad y coste de composición, incluido el caso mixto.

El árbol: destinos declarados, profundidad fijada

En la navegación en árbol cada feature declara el conjunto cerrado de cosas que puede presentar. Ese conjunto se escribe como un @Reducer enum, que la macro convierte a la vez en la suma de los estados posibles y en el reducer que despacha cada caso a su feature. El estado guarda un único hueco opcional de ese tipo, lo que hace aritméticamente imposible tener dos destinos a la vez.

@Reducer
struct Inventario {
  @Reducer
  enum Destino {
    case detalle(DetalleItem)
    case anadir(FormularioItem)
  }
  @ObservableState
  struct State: Equatable {
    var items: IdentifiedArrayOf<Item> = []
    @Presents var destino: Destino.State?
  }
  enum Action {
    case anadirTocado
    case filaTocada(Item.ID)
    case destino(PresentationAction<Destino.Action>)
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .anadirTocado:
        state.destino = .anadir(FormularioItem.State())
        return .none
      case let .filaTocada(id):
        state.destino = state.items[id: id].map { .detalle(DetalleItem.State(item: $0)) }
        return .none
      case .destino:
        return .none
      }
    }
    .ifLet(\.$destino, action: \.destino)
  }
}

La palabra árbol es literal: si DetalleItem declara a su vez su propio enum de destino, la estructura de tipos que resulta es un árbol cuya altura está escrita en el código. Eso tiene una virtud enorme —el compilador conoce todos los trayectos posibles de la app— y una limitación igual de clara: un flujo que se repita indefinidamente, como ir de un perfil a otro perfil sin final, no es expresable, porque exigiría un tipo infinitamente anidado.

La pila: una colección plana sin fondo

Cuando el trayecto no tiene un final conocido, el modelo correcto es la pila. StackState es una colección ordenada de estados de destino que vive en un solo campo del padre, y forEach corre el reducer de cada pantalla apilada gestionando su ciclo de vida y cancelando sus efectos al desapilar.

@Reducer
struct AppRaiz {
  @Reducer
  enum Camino {
    case perfil(Perfil)
    case publicacion(Publicacion)
  }
  @ObservableState
  struct State: Equatable {
    var inicio = Inicio.State()
    var camino = StackState<Camino.State>()
  }
  enum Action {
    case inicio(Inicio.Action)
    case camino(StackActionOf<Camino>)
  }
  var body: some ReducerOf<Self> {
    Scope(state: \.inicio, action: \.inicio) { Inicio() }
    Reduce { state, action in
      switch action {
      case let .inicio(.delegate(.perfilAbierto(id))),
           let .camino(.element(id: _, action: .perfil(.delegate(.otroPerfil(id))))):
        state.camino.append(.perfil(Perfil.State(id: id)))
        return .none
      default:
        return .none
      }
    }
    .forEach(\.camino, action: \.camino) { Camino() }
  }
}

Fíjate en que los dos orígenes comparten un mismo caso: da igual que el perfil lo abra la pantalla de inicio o que lo abra otro perfil, porque ambos producen un elemento hermano de la misma colección y no un anidamiento nuevo de tipos. Esa es la propiedad decisiva. La pila permite recursión en el recorrido sin recursión en los tipos, y por eso una acción emitida en la pantalla número doce llega a la raíz con dos envoltorios y no con doce: la profundidad del usuario y la profundidad de la composición dejan de ser el mismo número.

🌳

Árbol: destinos cerrados

Cada feature enumera lo que puede presentar. El grafo de trayectos posibles queda escrito en los tipos y el compilador lo conoce entero.

🔁

La prueba de la recursión

Si una pantalla puede llevar a otra igual que ella, necesitas pila. Un enum de destino no puede contenerse a sí mismo sin indirección.

🪢

Se combinan

Lo normal es una pila para el trayecto y, dentro de cada pantalla de esa pila, un árbol para sus hojas, alertas y diálogos modales.

El equivalente en SwiftUI y el criterio de elección

Cada modelo tiene su costura exacta en la capa de vista, y no son intercambiables. El árbol se consume con los modificadores que aceptan un elemento opcional; la pila, con un NavigationStack enlazado al camino.

// Árbol: el hueco opcional alimenta un modificador de presentación
.sheet(item: $store.scope(state: \.destino?.anadir, action: \.destino.anadir)) { store in
  FormularioItemView(store: store)
}

// Pila: la colección alimenta el NavigationStack
NavigationStack(path: $store.scope(state: \.camino, action: \.camino)) {
  InicioView(store: store.scope(state: \.inicio, action: \.inicio))
} destination: { store in
  switch store.case {
  case let .perfil(store): PerfilView(store: store)
  case let .publicacion(store): PublicacionView(store: store)
  }
}
Criterio Árbol con @Presents Pila con StackState
Forma del dato hueco opcional de un enum cerrado colección ordenada y homogénea
Profundidad fijada por los tipos al escribirlos libre en tiempo de ejecución
Recursión del recorrido no expresable sin indirección natural, son hermanos en la colección
Coste de composición un nivel de anidamiento por escalón dos envoltorios sea cual sea la profundidad
Modificador de SwiftUI .sheet | .popover | .alert | .navigationDestination NavigationStack con path
Cómo se retrocede poner el hueco a nulo quitar el último elemento del camino

El criterio se reduce a dos preguntas encadenadas. Primera: ¿el usuario puede volver a llegar a una pantalla del mismo tipo sin haber salido antes? Si la respuesta es sí, es pila y no hay discusión. Segunda, si la respuesta fue no: ¿la presentación es modal, es decir, interrumpe el trayecto en lugar de continuarlo? Si es modal, es árbol. Lo que queda en medio —un detalle no recursivo que continúa el trayecto— admite ambas, y ahí conviene decidir por coste de composición, que casi siempre favorece a la pila en cuanto hay más de dos escalones.

flowchart TD
A1[Inventario] --> A2[Hueco de destino opcional]
A2 --> A3[Detalle]
A2 --> A4[Formulario]
P0[Raiz] --> P1[StackState de Camino]
P1 --> P2[Perfil]
P1 --> P3[Publicacion]
P1 --> P4[Perfil otra vez]
style A2 fill:#89b4fa,color:#11111b
style P1 fill:#a6e3a1,color:#11111b
⚠️
No confundas modal con profundo

Una hoja que se abre encima de una pila no pertenece a la pila. Si la modelas como un elemento más del camino, el botón atrás del sistema la tratará como una pantalla empujada, la animación será la equivocada y el usuario podrá deslizar para volver desde algo que debía exigir una decisión explícita. La regla es de dominio, no de estética: lo que interrumpe el trayecto va en un hueco de presentación de la pantalla que lo interrumpe, y lo que continúa el trayecto va en el camino.

El árbol acota lo posible por adelantado; la pila acota lo homogéneo y deja libre lo demás

Los dos modelos son, en el fondo, dos respuestas a un problema de teoría de tipos: cómo representar un conjunto de trayectos que en principio es infinito con un tipo que necesariamente es finito. El árbol lo resuelve por enumeración anticipada. Escribe todos los destinos posibles de cada pantalla, y como cada destino puede a su vez declarar los suyos, el tipo que resulta describe un grafo finito de trayectos que el compilador conoce en su totalidad. El precio es exactamente la contrapartida de esa virtud: lo que no está enumerado no es expresable, y en particular no lo es la recursión, porque un tipo no puede contenerse a sí mismo sin una capa de indirección. La pila resuelve el mismo problema por el camino opuesto: renuncia a saber cuántas pantallas habrá y a cambio impone que todas pertenezcan al mismo tipo suma, de modo que lo infinito queda contenido no en la variedad sino en la longitud. Una colección de un tipo cerrado puede tener cualquier tamaño sin dejar de ser un valor perfectamente finito y comparable. Entender esta simetría cambia cómo se lee el resto del nivel, porque revela que la elección entre árbol y pila no es una preferencia sobre animaciones ni sobre qué modificador de SwiftUI usar, sino una decisión sobre dónde poner el límite: en la variedad de destinos o en la profundidad del recorrido. Y de ahí sale también la razón por la que casi ninguna app real usa uno solo. La vida de una interfaz tiene dos ritmos distintos: el trayecto, que avanza y puede volver sobre sus pasos indefinidamente, y la interrupción, que abre un paréntesis con un conjunto conocido de salidas y lo cierra. La pila es la forma natural del primero y el árbol la del segundo, así que la arquitectura sana no es escoger sino repartir: una pila para el hilo del recorrido y, dentro de cada pantalla de esa pila, su propio hueco de destino para las alertas, los formularios y las hojas que la interrumpen. Cuando alguien fuerza toda la navegación a un solo modelo, siempre paga lo mismo: si todo es árbol, acaba con tipos anidados que no soportan un recorrido libre; si todo es pila, acaba con alertas que el usuario puede deslizar para cerrar y con un camino que ya no describe un trayecto sino un revoltijo.

⚔️ Clasifica cada transición de tu app
  1. Enumera todas las transiciones de navegación de una app tuya, una línea por transición, con origen y destino.
  2. Marca cada una con la primera pregunta del criterio: ¿puede el usuario volver a llegar a una pantalla del mismo tipo sin salir antes? Las que den que sí son pila, sin excepción.
  3. Marca las restantes con la segunda pregunta: ¿interrumpe o continúa? Las que interrumpen son huecos de presentación en la pantalla que las lanza.
  4. Escribe el StackState de tu trayecto principal con su enum Camino y comprueba cuántos envoltorios atraviesa ahora una acción nacida en la pantalla más profunda.
  5. Busca en tu código actual alguna presentación modal que hoy viva en una pila. Sácala al hueco de destino de su pantalla y verifica que el gesto de volver atrás ya no la afecta.