wandres.dev
COMPOSICIÓN Y TEST · dependencies, TestStore

Navegación y árbol de estado: la navegación como datos

En la mayoría de los frameworks la navegación es una secuencia de comandos imperativos —empuja esta pantalla, presenta esta hoja— que dejan la verdad de qué está en pantalla dispersa por el sistema de vistas. TCA invierte esto: modela la navegación como datos dentro del estado, con `@Presents` para lo opcional y `StackState` para las pilas, de modo que qué se presenta es una consecuencia legible del estado y no una orden que se dio en algún sitio. Esta lección muestra los enums de destino, el `PresentationAction`, la navegación en árbol frente a la de pila, y por qué tratar la navegación como estado convierte el deep linking, la restauración y el testing de flujos en operaciones sobre un valor.

⏱ 19 min

Pregúntale a una app corriente qué pantalla tiene encima y no sabrá responderte: la respuesta está repartida entre banderas booleanas, delegados de navegación y el estado interno del propio sistema de vistas, y solo se reconstruye ejecutando la secuencia de comandos que llevó hasta ahí. TCA se niega a esa dispersión. Su tesis sobre navegación es la misma que sobre todo lo demás, llevada al extremo: la navegación es estado, y como tal debe ser un dato explícito, modelable, que vive en el State y del que la interfaz es un mero reflejo. Presentar una pantalla no es dar una orden; es asignar un valor. Y como es un valor, se puede guardar, restaurar, enlazar por URL y —lo que más importa en este nivel— afirmar en un test.

🎯 Al terminar esta lección sabrás
  • Modelar la navegación opcional con @Presents y PresentationAction, y entender qué garantiza cada uno.
  • Agrupar los destinos posibles de una pantalla en un enum de destino con la macro @Reducer.
  • Distinguir la navegación en árbol —presentaciones opcionales— de la navegación en pila con StackState.
  • Ver por qué la navegación como datos hace del deep linking, la restauración y el testing operaciones sobre un valor.

@Presents: la presentación opcional como estado

La forma más simple de navegación es opcional: hay una pantalla presentada o no la hay. TCA la modela con un estado hijo opcional marcado con @Presents, y con un caso de acción envuelto en PresentationAction, que unifica dos mensajes: los que vienen de dentro del hijo presentado y el que señala que el hijo se descartó.

@Reducer
struct Lista {
  @ObservableState
  struct State: Equatable {
    var items: IdentifiedArrayOf<Item> = []
    @Presents var detalle: Detalle.State?
  }
  enum Action {
    case itemTocado(Item.ID)
    case detalle(PresentationAction<Detalle.Action>)
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case let .itemTocado(id):
        state.detalle = Detalle.State(item: state.items[id: id])
        return .none
      case .detalle:
        return .none
      }
    }
    .ifLet(\.$detalle, action: \.detalle) {
      Detalle()
    }
  }
}

Presentar el detalle es una sola línea: state.detalle = Detalle.State(...). Descartarlo es state.detalle = nil. No hay comando de navegación en ningún lado; hay una asignación al estado, y la vista, que observa detalle, presenta la hoja cuando el valor deja de ser nulo. El ifLet que ya conoces corre el reducer del detalle solo mientras exista y cancela sus efectos al descartarse: la presentación y su ciclo de vida son una sola cosa.

El enum de destino: varios destinos, un solo hueco

Cuando una pantalla puede presentar más de un destino —un detalle, una hoja de edición, una alerta— no quieres un opcional por cada uno, porque eso permite el estado imposible de dos presentaciones a la vez. Lo correcto es un único hueco que sea uno de varios: un enum de destino, que con @Reducer se convierte a la vez en la suma de los estados posibles y en la composición de sus reducers.

@Reducer
struct Lista {
  @Reducer
  enum Destino {
    case detalle(Detalle)
    case editar(Editor)
    case alerta(AlertState<Never>)
  }
  @ObservableState
  struct State: Equatable {
    @Presents var destino: Destino.State?
  }
  enum Action {
    case detalleTocado
    case editarTocado
    case destino(PresentationAction<Destino.Action>)
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .detalleTocado:
        state.destino = .detalle(Detalle.State())
        return .none
      case .editarTocado:
        state.destino = .editar(Editor.State())
        return .none
      case .destino:
        return .none
      }
    }
    .ifLet(\.$destino, action: \.destino)
  }
}

Un solo campo destino, de tipo enum: o es .detalle, o es .editar, o es .alerta, o es nulo. Los estados imposibles del bloque de Niveles 9 a 16 reaparecen aquí resueltos por construcción: es aritméticamente imposible tener el detalle y el editor presentados a la vez, porque un enum solo puede estar en un caso. La navegación heredó la lección de las máquinas de estado.

💡
El ifLet del enum de destino no lleva closure

Cuando el destino es un @Reducer enum, el modificador se escribe .ifLet(\.$destino, action: \.destino) sin closure final: la macro sintetiza el reducer del enum —que despacha cada caso a su feature— y ifLet lo usa solo. En cambio, para un hijo opcional único que no es enum, sí pasas el reducer: .ifLet(\.$hoja, action: \.hoja) { Hoja() }. Distinguir los dos casos evita el error más común al empezar con presentaciones.

Árbol contra pila: StackState para lo profundo

La presentación opcional modela navegación en árbol: cada pantalla presenta a lo sumo un hijo, y la profundidad la fija el anidamiento de features. Para la navegación en pila —empujar pantallas indefinidamente, como un NavigationStack— TCA ofrece StackState, una colección ordenada de estados de destino, con su acción hermana StackAction.

@Reducer
struct App {
  @Reducer
  enum Ruta {
    case perfil(Perfil)
    case ajustes(Ajustes)
  }
  @ObservableState
  struct State: Equatable {
    var camino = StackState<Ruta.State>()
  }
  enum Action {
    case camino(StackActionOf<Ruta>)
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in return .none }
    .forEach(\.camino, action: \.camino)
  }
}

Empujar una pantalla es state.camino.append(.perfil(Perfil.State())); retroceder es quitar el último; volver a la raíz es vaciar la colección. Toda la pila de navegación es un array en el estado, y forEach corre el reducer de cada pantalla apilada, cancelando sus efectos cuando se desapila. En la vista, un NavigationStack se enlaza a ese camino mediante $store.scope, de modo que empujar en el estado empuja en pantalla y el botón atrás del sistema quita del estado. Estado y UI quedan atados por una biyección, no por una secuencia de órdenes.

flowchart LR
E[State con navegacion] --> V[Vista renderiza el reflejo]
V -->|intencion del usuario| A[Action]
A --> R[Reduce asigna destino o apila]
R --> E
DL[Deep link o restauracion] -->|asigna estado inicial| E
style E fill:#89b4fa,color:#11111b
style DL fill:#a6e3a1,color:#11111b
Si la navegación es un dato, todo lo difícil de la navegación se vuelve trivial

La navegación como datos parece, al principio, más ceremonia para hacer lo que un push imperativo hacía en una línea. El pago llega cuando aparecen las tareas que en el modelo imperativo son pesadillas y aquí son casi gratis, porque todas se reducen a leer o escribir un valor. Deep linking: abrir la app en una pantalla profunda no es reproducir la secuencia de toques que llevaría hasta ella —frágil, dependiente del timing—, sino construir directamente el State con el camino ya poblado y dejar que la vista lo refleje; el enlace se traduce a un valor inicial y ya está. Restauración de sesión: guardar dónde estaba el usuario es serializar ese estado de navegación, y devolverlo es deserializarlo, sin coreografía. Testing de flujos —el asunto de este nivel—: afirmar que tocar un item presenta el detalle es afirmar que, tras enviar la acción, state.destino es .detalle; no hay que inspeccionar jerarquías de vistas ni esperar animaciones, solo comparar dos valores. Incluso los estados imposibles desaparecen, porque un enum de destino no puede estar en dos casos a la vez y un StackState es una sola secuencia bien ordenada. La inversión es la misma que atraviesa todo TCA y todo este track: en vez de que la navegación sea un efecto que ocurre y deja su rastro repartido por el sistema de vistas, la navegación es un hecho que declaras en el estado y del que la interfaz es consecuencia obediente. La pregunta deja de ser cómo llego a esta pantalla y pasa a ser qué valor de estado significa estar en esta pantalla; y una vez respondes eso, llegar, volver, enlazar, restaurar y testear son todas la misma operación —mover un dato— vista desde ángulos distintos.

⚔️ Reescribe un flujo imperativo como estado de navegación
  1. Toma un flujo real con tres o cuatro pantallas encadenadas que hoy navegues con comandos imperativos.
  2. Pregúntate para cada transición si es árbol —presento un hijo opcional— o pila —empujo sobre lo anterior—. Anota cuáles son @Presents y cuáles StackState.
  3. Modela los destinos posibles de cada pantalla como un enum de destino con @Reducer. Comprueba que ningún par de destinos puede coexistir, y si alguno podía, celebra que el enum te lo acaba de prohibir.
  4. Reescribe cada navegación como una asignación al estado: presentar es asignar el caso, retroceder es asignar nil o desapilar.
  5. Escribe un deep link que abra la app en la pantalla más profunda construyendo el State directamente, sin simular toques. Si funciona, has probado que tu navegación es de verdad un dato.