El problema que resuelve: bindings de presentación y estados imposibles
Los bugs de navegación de SwiftUI tienen mala fama de arbitrarios —la hoja que aparece vacía, la alerta que vuelve sola, la segunda presentación que no ocurre, el enlace que se dispara al aparecer— y no lo son: casi todos derivan de una misma decisión de modelado, que es representar un hecho único con varias variables independientes. Esta lección hace la aritmética del espacio de estados, cataloga los fallos clásicos junto al estado imposible que los produce y muestra cómo el hueco único de destino los elimina por construcción, no por disciplina.
Un programador con experiencia en SwiftUI reconoce la lista de memoria: la hoja que se abre mostrando el dato anterior, la que no se abre porque otra se estaba cerrando, la alerta que reaparece una vez más de las que debía, el enlace de navegación que se activa solo al renderizarse una fila fuera de pantalla, el aviso de que se está modificando el estado durante una actualización de vista. La reacción habitual es tratarlos como rarezas del framework y aprender un repertorio de precauciones. Esta lección sostiene lo contrario: que no son rarezas sino consecuencias necesarias, que todas nacen de la misma raíz —modelar un hecho con varias variables que el compilador considera independientes— y que por eso ninguna precaución las cura del todo. Lo que las cura es cambiar el modelo, y el modelo correcto ya lo tienes: un solo hueco que o está vacío o contiene exactamente un destino.
- Calcular el espacio de estados que producen varias banderas de presentación y contar cuántos de esos estados son imposibles.
- Diagnosticar los fallos clásicos de SwiftUI identificando en cada uno el estado imposible que lo genera.
- Sustituir banderas y opcionales sueltos por un único hueco de destino y comprobar qué combinaciones dejan de ser representables.
- Entender por qué el descarte que viene de la vista debe volver al modelo como acción y no como escritura silenciosa.
Un hecho, varias variables: la aritmética del desastre
Empieza por lo más inocente que se pueda escribir: una pantalla que presenta un detalle y un formulario, cada uno con su bandera y su dato.
struct InventarioView: View {
@State private var mostrandoDetalle = false
@State private var itemSeleccionado: Item?
@State private var mostrandoFormulario = false
@State private var borrador: Item?
@State private var mostrandoAlerta = false
}
Cinco variables booleanas u opcionales producen un espacio de treinta y dos combinaciones si contamos solo si cada una está puesta o no. De esas treinta y dos, exactamente cuatro tienen sentido en el dominio: nada presentado, detalle con item, formulario con borrador y alerta. Las otras veintiocho son estados imposibles que el tipo permite igualmente: bandera de detalle en verdadero con item nulo, item presente con bandera en falso, detalle y formulario a la vez, alerta encima de un formulario que ya se cerró. Ninguna de esas veintiocho es un error que alguien vaya a cometer conscientemente; todas son estados por los que el programa pasa durante una fracción de segundo mientras dos escrituras compiten, y basta esa fracción para que la vista se reconstruya y observe una contradicción.
La fórmula general es incómoda: con n presentaciones modeladas por separado hay dos elevado a n combinaciones y solo n más una son legítimas, así que la proporción de estados imposibles crece exponencialmente mientras el número de pantallas crece de una en una. Ninguna cantidad de cuidado gana esa carrera.
El catálogo: cada bug con su estado imposible detrás
| Síntoma que se ve | Estado imposible que lo produce |
|---|---|
| La hoja se abre vacía o con el dato anterior | la bandera pasó a verdadero antes de que el opcional tuviera valor |
| La segunda hoja no aparece | dos banderas en verdadero a la vez y el sistema solo honra una |
| La alerta vuelve a salir sola | el descarte lo hizo la vista y la bandera siguió en verdadero |
| El detalle se abre sin que nadie toque nada | un enlace se activó al construirse una fila que no estaba en pantalla |
| Aviso de mutación durante la actualización de vista | la vista escribe en el modelo mientras se está renderizando |
| Cerrar y abrir enseguida no hace nada | la segunda presentación cayó en la ventana del descarte de la primera |
La cuarta fila merece atención porque parece de otra familia y es de la misma. En una lista larga, SwiftUI construye filas que aún no están visibles; si la condición de presentación vive en la fila, construirla equivale a evaluarla, y una fila que nadie ha tocado puede activar su destino. El estado imposible aquí es más sutil: estar presentado dejó de ser una propiedad del modelo para ser una propiedad de la existencia de una vista, de modo que la navegación pasó a depender de un detalle de rendimiento del renderizador.
Verdadero sin dato
La bandera se pone antes que el opcional y la hoja se dibuja con lo que hubiera. Es el bug más común y el más difícil de reproducir a mano.
Dos puertas abiertas
Dos presentaciones simultáneas. El sistema honra una y descarta la otra en silencio, sin error ni aviso.
Descarte no propagado
El usuario cierra con un gesto, el sistema actualiza su vista y el modelo se queda creyendo que sigue presentado.
Carrera de transición
Presentar durante el descarte de otra pantalla. La orden se pierde porque la ventana de animación no admite dos.
El hueco único: prohibir en vez de vigilar
La corrección no consiste en ordenar mejor las escrituras sino en hacer que las combinaciones inválidas no sean expresables. Un enum de destino en un único campo opcional reduce el espacio de treinta y dos a cuatro: nulo o exactamente uno de los tres casos, cada uno con el dato que necesita adherido a él.
@Reducer
struct Inventario {
@Reducer
enum Destino {
case detalle(DetalleItem)
case formulario(FormularioItem)
case alerta(AlertState<Action.Alerta>)
}
@ObservableState
struct State: Equatable {
var items: IdentifiedArrayOf<Item> = []
@Presents var destino: Destino.State?
}
enum Action {
case filaTocada(Item.ID)
case borrarTocado(Item.ID)
case destino(PresentationAction<Destino.Action>)
enum Alerta: Equatable { case confirmarBorrado(Item.ID) }
}
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.destino = .detalle(DetalleItem.State(item: item))
return .none
case let .borrarTocado(id):
state.destino = .alerta(.confirmarBorrado(de: id))
return .none
case let .destino(.presented(.alerta(.confirmarBorrado(id)))):
state.items.remove(id: id)
return .none
case .destino:
return .none
}
}
.ifLet(\.$destino, action: \.destino)
}
}
Tres propiedades caen juntas y ninguna depende de que nadie se equivoque. La primera: no existe presentado sin dato, porque el dato viaja dentro del caso del enum y no hay forma de construir el caso sin él. La segunda: no existen dos presentaciones simultáneas, porque un valor de enum solo puede estar en un caso, así que presentar la alerta mientras hay un detalle no abre dos puertas, sustituye una por otra de manera determinista. La tercera: el descarte por gesto llega al reducer como .destino(.dismiss) y pone el hueco a nulo antes de que tu código lo vea, de modo que el modelo nunca puede quedarse creyendo que sigue presentado.
flowchart TD A[Cinco banderas independientes] --> B[Treinta y dos combinaciones] B --> C[Cuatro validas] B --> D[Veintiocho imposibles] D --> E[Bugs intermitentes que dependen del orden de escritura] F[Un hueco con enum de destino] --> G[Cuatro combinaciones] G --> H[Todas validas por construccion] style D fill:#f38ba8,color:#11111b style H fill:#a6e3a1,color:#11111b
Es tentador pensar que basta con acordarse de poner siempre el opcional antes que la bandera, o de limpiar el dato al cerrar. Funciona mientras haya dos presentaciones y un solo autor. Con cinco presentaciones son veintiocho combinaciones a vigilar en cada punto donde alguien escriba en el estado, y esa vigilancia no se puede repartir entre varias personas ni se puede recordar seis meses después. Un invariante que hay que sostener a mano es una deuda con intereses; un invariante codificado en el tipo es gratis para siempre.
Merece la pena decir con precisión qué error se está cometiendo, porque enunciarlo bien es lo que impide repetirlo en otros contextos. Cuando escribes cinco variables independientes para cinco presentaciones posibles, estás afirmando que las cinco pueden darse simultáneamente y en cualquier combinación, es decir, estás modelando una conjunción: esto y esto y esto. Pero el dominio no dice eso. El dominio dice que hay como mucho una presentación a la vez, o sea una disyunción exclusiva: esto o esto o esto o nada. Un producto cartesiano donde debía haber una suma. El compilador te cree, porque no tiene forma de saber que no era eso lo que querías, y a partir de ahí trabaja con obediencia perfecta para permitir todos los estados que le dijiste que eran legítimos, incluidos los veintiocho que ni siquiera tienen nombre en tu cabeza. Cada uno de los bugs del catálogo es un habitante de esa zona que nunca quisiste crear, y su intermitencia se explica sola: son estados por los que el programa pasa un instante mientras dos escrituras se ordenan, invisibles en el noventa y nueve por ciento de las ejecuciones y perfectamente reales en la restante. Por eso resisten al depurador, por eso no se reproducen en la máquina de quien los tiene que arreglar y por eso el arreglo casi siempre consiste en añadir una comprobación defensiva que no elimina el estado sino que lo tapa. La lección general va más allá de la navegación y es la misma que atraviesa todo el diseño con tipos algebraicos: la elección entre producto y suma no es una preferencia de estilo, es una afirmación sobre qué es posible en el mundo que estás modelando, y el compilador va a hacerla cumplir con más rigor del que tú tienes. Elegir la suma allí donde el dominio dice o bien no es una técnica avanzada ni una elegancia académica; es la única forma conocida de que una clase entera de bugs deje de existir en lugar de dejar de manifestarse, y la navegación es simplemente el sitio donde esa diferencia se cobra antes y más caro.
- Elige la pantalla de tu app con más presentaciones y cuenta las variables que participan: banderas, opcionales, seleccionados. Calcula dos elevado a ese número.
- Escribe la lista de combinaciones que sí tienen sentido en tu dominio. La diferencia entre ambos números es tu superficie de bugs intermitentes.
- Recorre el catálogo de síntomas de esta lección y marca cuáles has visto en producción. Para cada uno, señala la combinación concreta que lo produce.
- Reescribe la pantalla con un único
@Presents var destinoy un@Reducer enumde destinos, metiendo el dato dentro de cada caso. Recuenta las combinaciones representables. - Fuerza la carrera antigua a propósito en la versión nueva: presenta un destino y en la misma acción presenta otro. Comprueba que el resultado es determinista y que ninguna de las dos pantallas queda a medias.