La tesis: la navegación es un valor del estado
En casi todas las apps de Apple la navegación es una orden imperativa —empuja esta pantalla, presenta esta hoja— y la consecuencia es que la respuesta a la pregunta dónde estoy no vive en ningún sitio del modelo: hay que reconstruirla interrogando a la pila real del sistema de vistas. TCA invierte la relación: el destino es un campo del `State`, presentar es asignarle un valor y descartar es ponerlo a nulo. Esta lección defiende esa tesis desde su raíz —la interfaz como función pura del estado— y muestra por qué convertir la navegación en un dato la vuelve inspeccionable, serializable, comparable y, sobre todo, afirmable en un test.
Toda arquitectura de interfaz responde, quiera o no, a una pregunta ontológica muy simple: ¿qué clase de cosa es estar en una pantalla? El modelo imperativo responde que es el residuo de una acción pasada, el efecto acumulado de una secuencia de órdenes que alguien dio en algún momento y de la que ya solo queda el rastro dentro del sistema de vistas. TCA responde otra cosa, y esa respuesta es el eje del nivel entero: estar en una pantalla es un hecho presente, un valor que el estado sostiene ahora mismo, tan dato como el nombre de un usuario o el contador de un carrito. La diferencia parece de estilo y es de naturaleza. Un residuo no se puede leer, ni comparar, ni guardar en disco, ni reconstruir a voluntad; un valor sí. Todo lo que este nivel promete —deep linking sin ceremonia, restauración de sesión, tests de flujos completos, desaparición de estados imposibles— es consecuencia mecánica de haber cambiado la respuesta a esa pregunta.
- Distinguir la navegación como orden imperativa de la navegación como valor, y localizar dónde vive la verdad en cada modelo.
- Modelar una presentación con
@Presents,PresentationActionyifLet, entendiendo qué garantiza cada pieza. - Ver la interfaz como una función del estado y comprender por qué esa biyección hace inspeccionable la navegación.
- Reconocer las cuatro propiedades que un dato tiene y una orden no: legible, comparable, serializable y afirmable.
La pregunta que una app imperativa no sabe responder
Detén cualquier app tradicional en el depurador y pregúntale qué tiene encima. No hay ningún sitio donde mirar. La respuesta está repartida entre la pila del controlador de navegación, un puñado de booleanos que gobiernan hojas, el estado interno de las animaciones en curso y, si hay suerte, alguna variable que alguien recordó actualizar. La única forma de saberlo con certeza es preguntarle al sistema de vistas, es decir, a la capa que debería ser el reflejo de la verdad y no su depositaria.
// La verdad vive fuera del modelo: solo queda el rastro de la orden
func filaTocada(_ item: Item) {
let detalle = DetalleVC(item: item)
navigationController?.pushViewController(detalle, animated: true)
}
// La versión SwiftUI cambia la sintaxis, no la ontología
@State private var mostrarDetalle = false
@State private var itemSeleccionado: Item?
Ese par de propiedades independientes es el síntoma exacto del problema. Nada impide que mostrarDetalle sea verdadero mientras itemSeleccionado es nulo, ni al revés, porque son dos hechos que el compilador considera ajenos entre sí aunque en el dominio sean uno solo. La orden imperativa no solo dispersa la verdad: la fragmenta en trozos que pueden contradecirse. Y como el sistema de vistas es quien realmente sabe qué hay en pantalla, cualquier intento de sincronizar el modelo con él es una carrera que se pierde tarde o temprano, cuando el usuario desliza para volver atrás y nadie avisa a las banderas.
Presentar es asignar, descartar es borrar
La propuesta de TCA cabe en una frase: el destino es un campo más del estado. El anotador @Presents marca ese campo como un hueco de presentación, PresentationAction unifica en un solo caso de acción los mensajes que vienen de dentro del hijo y el hecho de que el hijo se descartó, y ifLet ata el ciclo de vida del reducer hijo a la existencia de ese valor.
@Reducer
struct Lista {
@ObservableState
struct State: Equatable {
var items: IdentifiedArrayOf<Item> = []
@Presents var detalle: Detalle.State?
}
enum Action {
case filaTocada(Item.ID)
case detalle(PresentationAction<Detalle.Action>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .filaTocada(id):
guard let item = state.items[id: id] else { return .none }
state.detalle = Detalle.State(item: item) // presentar
return .none
case .detalle(.dismiss):
return .none // el nil ya lo puso TCA
case .detalle:
return .none
}
}
.ifLet(\.$detalle, action: \.detalle) { Detalle() }
}
}
No hay ninguna llamada a navegación en ese reducer, y esa ausencia es el punto entero. Presentar el detalle es escribir un valor; descartarlo es escribir nulo. La vista se limita a observar el hueco y a materializarlo con el modificador que corresponda, de modo que la dirección de la causalidad queda fijada: del estado a la pantalla, nunca al revés.
struct ListaView: View {
@Bindable var store: StoreOf<Lista>
var body: some View {
List(store.items) { item in
Button(item.titulo) { store.send(.filaTocada(item.id)) }
}
.sheet(item: $store.scope(state: \.detalle, action: \.detalle)) { store in
DetalleView(store: store)
}
}
}
El scope sobre el binding es la costura que cierra el círculo: cuando el usuario descarta la hoja con un gesto, SwiftUI escribe nulo a través de ese binding, eso se traduce en la acción dismiss, y el reducer del padre se entera del descarte como se entera de cualquier otro hecho del mundo. No hay ningún camino por el que la pantalla pueda cambiar sin que el estado lo sepa.
Legible
En cualquier instante puedes imprimir state.detalle y saber exactamente qué hay presentado. No hay que interrogar a la jerarquía de vistas.
Comparable
Como es Equatable, dos situaciones de navegación se comparan con el operador de igualdad. Eso es lo que hace posible afirmar sobre ellas.
Serializable
Un valor se escribe en disco y se lee de vuelta. Una orden no: solo se puede repetir, y repetirla depende del tiempo y del azar.
Construible
Puedes fabricar el estado de una pantalla profunda sin pasar por las anteriores. El deep link deja de ser una simulación de toques.
La interfaz como función del estado
Detrás de todo esto hay una tesis más general, heredada de Elm y anterior a TCA: la vista es una función pura del estado. Escrito como ecuación, vista igual a f de estado. Si esa igualdad se cumple de verdad, entonces todo lo que se ve en pantalla —incluida la pantalla misma— tiene que ser deducible del estado, porque una función no puede devolver algo que no esté determinado por su entrada. La navegación imperativa rompe la ecuación por la mitad: introduce una variable oculta, la pila real del sistema, que influye en el resultado sin aparecer en la entrada. Modelar la navegación como dato no es un truco de conveniencia; es lo mínimo necesario para que la ecuación vuelva a ser cierta.
| Pregunta | Navegación como orden | Navegación como valor |
|---|---|---|
| Dónde vive la verdad | en la pila del sistema de vistas | en un campo del State |
| Cómo se presenta una pantalla | llamando a un método | asignando un valor |
| Cómo se sabe qué hay encima | interrogando a la jerarquía de vistas | leyendo el estado |
| Qué ocurre con dos órdenes simultáneas | dos presentaciones o un fallo en tiempo de ejecución | la segunda asignación gana, y es determinista |
| Se puede guardar y restaurar | no, solo se puede repetir la secuencia | sí, es un valor codificable |
| Se puede afirmar en un test | solo con aserciones sobre vistas | con una comparación de igualdad |
flowchart LR S[State con el destino] --> V[Vista refleja el destino] V -->|intencion del usuario| A[Action] A --> R[Reducer asigna o borra el destino] R --> S O[Orden imperativa] -.-> P[Pila real del sistema] P -.-> Q[Verdad dispersa fuera del modelo] style S fill:#89b4fa,color:#11111b style Q fill:#f38ba8,color:#11111b
El error inicial más común es tratar el descarte como algo que ocurre solo en la vista. No lo es: cuando el usuario desliza para cerrar una hoja, TCA envía .destino(.dismiss) y pone el hueco a nulo antes de que tu reducer vea la acción. Puedes ignorarla o reaccionar a ella —guardar un borrador, cancelar una tarea, registrar analítica— pero nunca te llega tarde ni te llega de forma distinta a como llegaría un botón de cerrar. Un solo camino, un solo hecho.
La distinción entre orden y valor es más vieja que el software y explica por qué este cambio se paga tan bien. Una orden es un acontecimiento: ocurre en un instante, produce un efecto y desaparece, dejando tras de sí un mundo modificado del que hay que inferir lo que pasó. Un valor es un estado de cosas: no ocurre, es, y por eso se puede mirar todas las veces que uno quiera sin que la observación lo altere. Cuando programas la navegación con órdenes, el sistema que construyes solo sabe de sí mismo lo que pueda deducir de las huellas que dejó, y esa deducción es siempre parcial: la pila conoce su altura pero no por qué tiene esa altura, la bandera booleana sabe que algo está presentado pero no qué, y ningún componente conoce la situación completa porque la situación completa no está escrita en ninguna parte, es una propiedad emergente del conjunto. Ese es el motivo profundo de que los bugs de navegación sean tan difíciles: no son errores de lógica, son errores de epistemología, momentos en que el programa cree estar donde no está porque nunca tuvo forma de saberlo. Modelar la navegación como dato cierra esa grieta de una manera casi brutal por lo simple: si estar en el detalle es que un campo valga un caso concreto, entonces la pregunta dónde estoy tiene una respuesta escrita, única y consultable, y el sistema de vistas pasa de ser la fuente de verdad a ser lo que siempre debió ser, una proyección. A partir de ahí todo lo que era difícil se vuelve aritmética sobre valores. Abrir la app en una pantalla profunda no es reproducir una coreografía de toques sino construir un valor. Volver donde estabas ayer no es recordar qué hiciste sino deserializar un valor. Probar un flujo de cinco pasos no es manejar un simulador sino comparar seis valores en sucesión. Y la conclusión que hay que llevarse del nivel es que ninguna de esas tres cosas es una funcionalidad que TCA añada: son la misma propiedad vista desde tres sitios, y la propiedad es que dejaste de dar órdenes sobre dónde ir para empezar a declarar dónde estás.
- Toma una pantalla tuya que hoy presente algo con un booleano y un opcional separados. Escribe las cuatro combinaciones posibles de esos dos campos y marca cuáles carecen de sentido en tu dominio.
- Sustituye ambos campos por un único
@Presents var destino: Destino.State?y comprueba que las combinaciones sin sentido ya no son representables. - Reescribe la presentación como una asignación dentro del reducer y el descarte como el caso
dismiss. No debe quedar ninguna llamada de navegación en la capa de vista salvo el modificador que consume elscope. - Añade una línea que imprima el destino en cada cambio y recorre la pantalla a mano: comprueba que el valor impreso siempre coincide con lo que ves, incluido el cierre por gesto.
- Escribe un test que envíe la acción de tocar una fila y afirme únicamente sobre el valor del destino. Si pasa sin montar ni una sola vista, has probado que tu navegación ya es un dato.