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

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.

⏱ 19 min

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.

🎯 Al terminar esta lección sabrás
  • 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, PresentationAction y ifLet, 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 descarte también es un hecho que llega al reducer

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.

Una orden pertenece al pasado; un valor pertenece al presente, y solo del presente se puede hablar con certeza

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.

⚔️ Convierte una orden de navegación en un dato
  1. 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.
  2. Sustituye ambos campos por un único @Presents var destino: Destino.State? y comprueba que las combinaciones sin sentido ya no son representables.
  3. 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 el scope.
  4. 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.
  5. 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.