En la vista: `NavigationStack` con el store y el `NavigationLink` de TCA
El estado ya describe la pila entera; falta que SwiftUI la refleje sin dejar de ser un reflejo. Esta lección monta el lado de la vista: el `NavigationStack` alimentado por un binding derivado de `$store.scope`, el cierre de destino que despacha cada caso del enum con `store.case`, la variante de `NavigationLink` que TCA ofrece para empujar estado en vez de valores inertes, y el criterio para decidir cuándo un enlace declarativo es correcto y cuándo hay que bajar a un botón que envía una acción.
Todo lo que has modelado hasta aquí vive en el estado, y el estado por sí solo no dibuja nada. El puente entre StackState y NavigationStack es el punto donde la tesis de la navegación como datos se pone a prueba de verdad, porque SwiftUI también guarda su propia idea de qué hay en pantalla y las dos versiones tienen que coincidir siempre, en las dos direcciones y sin excepciones. Si el reducer apila, la pantalla debe aparecer; si el usuario arrastra desde el borde, el estado debe encogerse. TCA resuelve esa biyección con una única pieza —un binding derivado del store mediante scope— y a partir de ahí la vista deja de decidir nada sobre navegación: se limita a mostrar lo que la pila dice que hay. Esta lección monta ese lado visual sin sacrificar un gramo de la propiedad que lo hace valioso.
- Alimentar un
NavigationStackcon el binding que produce$store.scopesobre la pila y el caso de acción correspondiente. - Escribir el cierre de destino despachando con
store.casesobre el enum de rutas y obtener el store hijo de cada pantalla. - Usar la variante de
NavigationLinkque empuja estado en lugar de un valor inerte, y conocer su requisito de contexto. - Decidir con criterio entre enlace declarativo y botón que envía una acción según haya o no trabajo previo al empuje.
El binding que ata la pila a la vista
La vista raíz declara el store como enlazable y deriva de él, con scope, un binding que el NavigationStack entiende. Esa llamada no copia la pila: produce una vista de escritura sobre el campo camino del estado, de modo que cualquier cambio que SwiftUI intente hacer sobre el camino se traduce en una acción que entra por el canal de siempre.
struct TiendaView: View {
@Bindable var store: StoreOf<Tienda>
var body: some View {
NavigationStack(
path: $store.scope(state: \.camino, action: \.camino)
) {
List(store.destacados) { producto in
NavigationLink(state: Tienda.Camino.State.producto(Producto.State(id: producto.id))) {
Text(producto.nombre)
}
}
.navigationTitle("Destacados")
} destination: { store in
switch store.case {
case let .producto(store): ProductoView(store: store)
case let .resena(store): ResenaView(store: store)
case let .perfil(store): PerfilView(store: store)
}
}
}
}
Léelo despacio porque cada pieza justifica su sitio. El primer argumento es el binding a la pila. El bloque principal es la raíz, la pantalla que siempre está debajo de todo y que no vive dentro de StackState. Y el cierre destination recibe, por cada elemento apilado, un store ya reducido al dominio de ese elemento; store.case es la propiedad que la macro @Reducer sintetiza en el enum de rutas para poder hacer switch obteniendo en cada rama un store del tipo exacto de esa feature.
Cada pantalla apilada obtiene su propio store derivado, cuya identidad queda atada al StackElementID de ese elemento. Empujar una pantalla nueva no reconstruye las que ya estaban debajo, y desapilar no obliga a recalcular las supervivientes. Es la misma economía de identidad que hacía barata una lista de mil filas en el Nivel 15, aplicada a la profundidad: el trabajo de la vista es proporcional a lo que cambia, no a lo que hay.
NavigationLink(state:): empujar un estado, no un valor
SwiftUI ofrece de serie un enlace que empuja un valor identificable y delega en un modificador de destino la tarea de traducir ese valor a una vista. TCA aporta una variante que empuja directamente el State del destino, y la diferencia no es cosmética: significa que la pantalla que se abre nace completamente inicializada, con todos sus campos puestos por quien la empuja, en lugar de tener que reconstruirse a partir de un identificador suelto.
NavigationLink(state: Tienda.Camino.State.resena(Resena.State(producto: producto, autor: autor))) {
Label("Ver reseña", systemImage: "text.bubble")
}
Que el destino nazca completo tiene una consecuencia visible que suele atribuirse al rendimiento y en realidad es de diseño: la pantalla nueva no parpadea. No hay un instante en que exista con los campos vacíos mientras alguien va a buscar el dato, porque el dato ya viajó dentro del estado que la empujó. La carga interna queda reservada para lo que de verdad no se puede saber antes de llegar, y el número de estados de carga que hay que modelar en la aplicación baja de forma notable.
Ese enlace tiene un requisito estricto de contexto: solo funciona dentro de un NavigationStack cuyo camino provenga de un scope sobre un store. Fuera de ese contexto no encuentra la pila a la que dirigirse y TCA emite un aviso en tiempo de ejecución en vez de fallar en silencio. El aviso vale su peso en oro cuando alguien extrae una subvista a un módulo de previsualización y se pregunta por qué el enlace no hace nada.
| Situación | Herramienta | Motivo |
|---|---|---|
| El destino se conoce ya y no hay validación | NavigationLink(state:) |
el empuje es declarativo y el estado se construye en la vista |
| Hay que cargar | validar antes de empujar | Button que envía una acción |
el reducer decide si apila y con qué datos |
| El empuje lo dispara un efecto o un evento remoto | acción del reducer | la vista no participa en absoluto |
La tercera fila es la que revela el orden real de las cosas. El enlace declarativo es una comodidad para el caso trivial; la vía canónica y siempre disponible es que el reducer añada a la pila, porque el estado es la fuente de la verdad y la vista solo lo refleja. En cuanto hay una condición que evaluar, la vista deja de ser competente para decidir y se limita a informar de la intención.
Button("Continuar") {
store.send(.continuarPulsado)
}
.disabled(store.formulario.estaVacio)
Ese botón no sabe adónde lleva ni si llevará a alguna parte: anuncia una pulsación y espera. El reducer, que sí ve el resto del estado, decide si valida, si carga, si apila o si muestra un error. La asimetría es deliberada y es la misma que atraviesa todo TCA desde el Nivel 8: la vista describe lo que ocurrió, nunca lo que debe ocurrir.
Compatibilidad y disciplina del reflejo
Hay un error de montaje que conviene nombrar antes de seguir porque se comete una vez y cuesta media tarde: anidar un NavigationStack dentro de una pantalla de destino. Cada pila que declares crea su propia navegación independiente, de modo que una pila dentro de otra produce dos barras de título, dos botones atrás y un estado que ya no describe lo que hay en pantalla. Las vistas de destino son contenido, no contenedores; el único NavigationStack del flujo vive en la raíz y todas las demás pantallas se limitan a poblarlo. La regla se recuerda fácil: si una vista aparece en el cierre destination, no puede declarar una pila.
En objetivos anteriores a la observación nativa de SwiftUI, la biblioteca de compatibilidad que viste en el Nivel 6 cubre exactamente el mismo código con dos cambios mecánicos: el store enlazable se declara con la variante de la biblioteca de percepción y el cuerpo se envuelve en el contenedor que activa el seguimiento.
struct TiendaView: View {
@Perception.Bindable var store: StoreOf<Tienda>
var body: some View {
WithPerceptionTracking {
NavigationStack(path: $store.scope(state: \.camino, action: \.camino)) {
// raíz
} destination: { store in
WithPerceptionTracking {
switch store.case { /* ... */ }
}
}
}
}
}
Salvo esos dos envoltorios, la estructura es idéntica y la disciplina también: la vista nunca decide navegación, solo la dibuja. Ese es el criterio con el que revisar cualquier pantalla de un flujo apilado, y el más fácil de aplicar en una revisión de código, porque se comprueba de un vistazo. Si en el cuerpo de una vista encuentras una variable de estado local que recuerda si algo está presentado, una bandera que decide qué mostrar o un gesto que descarta por su cuenta, esa vista dejó de ser un reflejo y volvió a ser un depósito, y con ella regresa la posibilidad de que su idea de la navegación difiera de la del reducer.
Un solo binding
$store.scope sobre la pila es todo el pegamento entre StackState y NavigationStack.
Destino por caso
store.case despacha el enum de rutas y entrega un store ya reducido a cada feature.
Empujar estado
El enlace de TCA lleva el State completo del destino, que nace inicializado por quien lo empuja.
Atrás es lectura
El gesto del sistema encoge la pila y el reducer se entera por popFrom, no al revés.
flowchart LR ST[StackState en el estado] -->|scope produce binding| NS[NavigationStack] NS -->|cierre destination| D[Store reducido por elemento] NL[NavigationLink con state] -->|push| ST GB[Gesto atras del sistema] -->|popFrom| ST style ST fill:#89b4fa,color:#11111b style GB fill:#a6e3a1,color:#11111b
Merece la pena nombrar con precisión lo que se ha eliminado aquí, porque es un problema que la industria de las interfaces arrastró durante décadas sin llegar a formularlo bien. En la navegación imperativa clásica existen siempre dos versiones de qué hay en pantalla: la que el sistema de vistas mantiene internamente en su jerarquía de controladores, y la que tu código cree recordar en banderas, referencias y variables sueltas. Nadie las declara como duplicadas y sin embargo lo son, y todo el catálogo de bugs de navegación —la pantalla que se presenta dos veces porque el usuario tocó rápido, el retroceso que deja un estado zombi porque el sistema desmontó la vista sin avisar a nadie, la restauración que reproduce una secuencia de empujes y aterriza en un sitio distinto según el momento— es literalmente el catálogo de las maneras en que dos copias no sincronizadas pueden divergir. Lo que hace el binding de esta lección no es sincronizar mejor esas dos copias, sino suprimir una: la única versión de la verdad es el StackState del reducer, y la jerarquía de SwiftUI se convierte en una función pura de ese valor. El gesto atrás no cambia la navegación, informa de que el usuario quiere cambiarla, y el cambio ocurre donde ocurren todos los demás, dentro del reducer, como acción con nombre. Empujar no es dar una orden a un navegador, es añadir un elemento a un array. La consecuencia es que la pregunta que uno se hace ante un fallo de navegación cambia de naturaleza: ya no es qué orden se ejecutó cuándo y en qué hilo, sino qué valor tiene la pila en este instante, que es una pregunta con respuesta impresa, comparable y afirmable en un test. Cuando la interfaz es un reflejo y no un depósito, deja de haber nada que pueda desincronizarse.
- Monta la vista raíz de tu flujo con
NavigationStackalimentado por$store.scopey un cierre de destino que despache constore.casetodos los casos del enum de rutas. - Añade un enlace con la variante de TCA que empuje un destino con su estado ya inicializado, y verifica que la pantalla nace con los datos puestos sin cargar nada.
- Imprime el número de elementos de la pila cada vez que cambie. Navega hacia adelante y retrocede con el gesto del borde, y comprueba que la cuenta sube y baja sin que tu vista haya ordenado nada.
- Coloca deliberadamente ese mismo enlace fuera del
NavigationStackdel store y observa el aviso en tiempo de ejecución. Anota el texto: reconocerlo te ahorrará una tarde. - Sustituye uno de los enlaces por un botón que envíe una acción y deje al reducer decidir si apila. Argumenta cuál de las dos formas corresponde a cada transición de tu flujo y por qué.