wandres.dev
ESTADO EN SWIFTUI · la fuente de la verdad

Estado y arquitectura: la frontera con el dominio

Dónde acaba el estado de la interfaz y empieza el del dominio, la prueba que separa a uno del otro, y por qué Observation es reactividad de grano fino: el puente entre SwiftUI y la teoría general del estado.

⏱ 19 min

Las cuatro lecciones anteriores han sido de mecánica: qué wrapper, qué identidad, por qué no reacciona, a qué altura vive. Esta es de fronteras. Una app que crece se rompe casi siempre por el mismo sitio, y no es el rendimiento ni la sintaxis: es haber mezclado en el mismo saco el estado que describe cómo se ve la pantalla ahora mismo con el estado que describe qué es verdad en el negocio. Separar ambos es la decisión arquitectónica que más rendimiento da a largo plazo, y además es la puerta que conecta todo lo aprendido aquí con la teoría general de la reactividad, donde estas mismas ideas aparecen con otros nombres.

🎯 Al terminar esta lección sabrás
  • Distinguir estado efímero de interfaz, estado de dominio y estado del servidor.
  • Aplicar una prueba objetiva para situar cualquier dato a un lado de la frontera.
  • Entender Observation como un sistema de reactividad de grano fino.
  • Traducir los conceptos de SwiftUI a los de la categoría de Reactividad y Estado.

Tres estados que no se parecen en nada

Hablamos de estado como si fuera una sola cosa y son tres, con dueños distintos, ciclos de vida distintos y reglas de prueba distintas.

El estado efímero de interfaz describe la pantalla en este instante: qué fila está expandida, qué campo tiene el foco, si el teclado está visible, cuánto se ha desplazado el scroll. Nace y muere con la vista, no tiene sentido fuera de ella y nadie lo persistiría jamás. Su hogar natural es @State.

El estado de dominio describe qué es cierto en el problema que resuelve la app, con independencia de que exista una interfaz: las tareas del usuario, su carrito, sus permisos, sus ajustes. Sobrevive a cualquier pantalla, se persiste, se sincroniza y se puede testear sin instanciar una sola vista. Su hogar es una clase @Observable que vive por encima del árbol.

La prueba de que son categorías distintas y no grados de la misma es que fallan de maneras distintas. El estado de interfaz mal colocado produce parpadeos, saltos de scroll y campos que se vacían; el de dominio mal colocado produce datos que se pierden, dos pantallas que discrepan y reglas de negocio imposibles de testear; y el del servidor mal tratado produce pantallas que muestran información caducada sin saberlo. Cuando el síntoma es reconocible, la categoría está diciendo dónde mirar.

El estado del servidor no es realmente tuyo: es una caché local de una verdad remota, con sus propias preocupaciones que ninguno de los otros dos tiene, como frescura, revalidación, reintentos, y qué mostrar mientras llega. Tratarlo como estado de dominio normal es el origen de la mitad de los bugs de sincronización de una app conectada.

flowchart TD
V[Vistas] -->|lee y escribe| U[Estado efimero de interfaz]
V -->|lee| D[Estado de dominio]
V -->|intenciones| D
D -->|persiste| P[SwiftData o archivos]
D -->|solicita| S[Cache de estado remoto]
S -->|revalida| API[Servicio remoto]

La prueba de la frontera

Para situar un dato hay una prueba de tres preguntas que no admite empate. Uno: si borro esta pantalla y la rediseño desde cero, ¿este dato sigue teniendo sentido? Dos: ¿tendría que persistirlo o sincronizarlo entre dispositivos? Tres: ¿puedo escribir un test que lo verifique sin instanciar ninguna vista? Si la respuesta es afirmativa a cualquiera de las tres, el dato es de dominio y no pertenece a la vista.

Dato Frontera Dónde vive
Fila expandida Interfaz @State local
Texto del buscador Interfaz @State en la pantalla
Resultados del buscador Dominio Modelo observable
Elemento seleccionado Depende Interfaz si es visual, dominio si dirige la navegación
Sesión del usuario Dominio Modelo en el entorno
Estado de carga Servidor Caché junto al dato remoto

La fila ambigua es la interesante y no es un fallo de la tabla. Una selección que solo pinta un fondo azul es interfaz; la misma selección, si determina qué se muestra en otro panel y debe restaurarse al reabrir la app, es dominio. La prueba no clasifica el nombre del dato, clasifica el papel que juega en tu app concreta.

@Observable @MainActor
final class ListaTareas {
    private(set) var tareas: [Tarea] = []      // dominio: solo el modelo lo muta
    private(set) var cargando = false

    func alternar(_ id: Tarea.ID) { /* intencion, no mutacion directa */ }
    func cargar() async { /* frescura, reintentos, error */ }
}

struct VistaTareas: View {
    @Environment(ListaTareas.self) private var lista   // dominio inyectado
    @State private var busqueda = ""                   // interfaz, local y efimero

    var body: some View {
        List(lista.tareas.filter { busqueda.isEmpty || $0.titulo.contains(busqueda) }) { t in
            Text(t.titulo)
        }
        .searchable(text: $busqueda)
    }
}

Fíjate también en dónde ha quedado el filtrado. La búsqueda se aplica en el body porque es una vista de los datos, no un hecho del dominio: nadie persistiría el resultado de teclear tres letras. Si mañana ese mismo filtrado tuviera que sincronizarse entre dispositivos o alimentar una consulta al servidor, cruzaría la frontera y bajaría al modelo. El código apenas cambiaría; lo que habría cambiado es el papel del dato, que es lo único que la frontera mide.

Observa el private(set): la vista lee el dominio pero no lo muta a mano, expresa intenciones llamando a métodos. Esa asimetría entre lectura libre y escritura canalizada es el flujo unidireccional, y es lo único que separa un modelo compartido de un montón de variables globales.

El modelo de dominio, por definición, vive por encima del árbol de vistas y entra en él por el entorno. Que su creación ocurra en el punto de entrada de la app, y no dentro de una pantalla, es la traducción en código de la frontera: nada de lo que ocurra a las vistas puede destruirlo.

@main
struct MiApp: App {
    @State private var lista = ListaTareas()      // el dominio nace fuera de las pantallas

    var body: some Scene {
        WindowGroup {
            VistaTareas().environment(lista)      // y entra al arbol por el entorno
        }
    }
}
ℹ️
El estado del servidor merece su propio vocabulario

Frescura, revalidación, deduplicación de peticiones, reintentos con espera creciente y datos obsoletos mientras se recarga son conceptos que no existen ni en el estado de interfaz ni en el de dominio. Si acabas escribiendo esa lógica a mano en cada pantalla, la señal no es que falte disciplina: es que falta una capa.

Observation es reactividad de grano fino

Aquí es donde SwiftUI deja de ser un caso particular. Lo que hace la macro @Observable tiene un nombre en la literatura y un linaje de veinte años: reactividad de grano fino, la misma familia a la que pertenecen los signals de Solid, los de Angular, los de Vue y la propuesta de signals para el propio lenguaje JavaScript.

El patrón es idéntico en las cinco implementaciones. Cada propiedad observable es un nodo fuente del grafo. Cada evaluación de un body es un efecto que, mientras corre, registra qué nodos lee. Escribir en un nodo marca como sucios a los efectos que dependen de él y solo a esos. La diferencia con el modelo del DOM virtual, o con el viejo ObservableObject, es que la unidad de invalidación es la dependencia real y no el componente entero ni el objeto entero.

SwiftUI Reactividad general
Propiedad de una clase @Observable Signal o nodo fuente
Evaluación de un body Efecto que se suscribe al leer
Propiedad calculada del modelo Derivación o computed
ObservationRegistrar Grafo de dependencias
Invalidar y reevaluar Propagación sin glitches

La equivalencia se ve mejor con las dos versiones del mismo contador al lado. Cambian las palabras y no cambia ni una idea: una fuente, una derivación que la lee, y un consumidor que se resuscribe solo por haber leído.

@Observable
final class Contador {
    var n = 0                     // signal
    var doble: Int { n * 2 }      // derivacion
}
// La vista que lee `doble` es el efecto: se resuscribe por leer, no por declararlo

Reconocer el linaje tiene una utilidad concreta: todo lo que la teoría general sabe sobre estos sistemas se aplica aquí sin traducción. Que las derivaciones deben ser puras y baratas, que un efecto que escribe en su propia fuente crea un ciclo, que la propagación debe ser consistente antes de dibujar. Los problemas de SwiftUI con el estado no son problemas de SwiftUI: son los problemas conocidos de la reactividad de grano fino, con la sintaxis de Apple.

Todo estado es reactividad más una disciplina de mutación

Esta es la síntesis que justifica el nivel entero. Cualquier sistema de estado, en cualquier plataforma, se descompone en dos mitades independientes que se suelen confundir. La primera es el mecanismo de propagación: cómo se entera quien lee de que algo cambió. Ahí caben el observer clásico, el pub y sub, los signals de grano fino, el diffing de árboles o el streaming de eventos, y SwiftUI eligió signals con descubrimiento automático de dependencias. La segunda mitad es la disciplina de mutación: quién tiene permiso para cambiar qué, y a través de qué camino. Ahí caben la mutación libre, el flujo unidireccional con intenciones, el reductor puro de Elm y Redux, las máquinas de estado explícitas y el modelo de TCA, que en el ecosistema Apple es exactamente la respuesta de la escuela Elm a esta segunda mitad. Y lo decisivo es que las dos mitades son ortogonales: puedes tener propagación de grano fino con mutación anárquica, que es lo que ocurre cuando esparces clases @Observable con propiedades públicas y las mutas desde cualquier vista, y puedes tener propagación tosca con una disciplina impecable. La mecánica de este nivel te ha dado la primera mitad completa; la segunda no la resuelve ningún framework, la eliges tú al diseñar. Cuando llegues a la categoría de Reactividad y Estado y veas signals, stores, máquinas de estado, reductores y CRDTs presentados como alternativas rivales, tendrás la clave para leerlos: casi ninguno compite con los otros, cada uno responde a una de las dos mitades, y la mayoría de las discusiones de arquitectura son gente comparando respuestas a preguntas distintas.

Qué llevarte al resto de la categoría

Tres traslados sobreviven fuera de SwiftUI y merecen quedarse contigo, porque no dependen del framework ni del lenguaje: son propiedades de cualquier sistema donde unos datos cambian y otras cosas tienen que enterarse.

El primero es que la fuente única de verdad es una decisión, no un accidente. Cada dato duplicado es una anomalía de actualización esperando su turno, y eso vale igual para dos @State que para dos stores o dos caches.

El segundo es que la dependencia descubierta vence a la declarada. Un sistema que observa qué lees de verdad es más preciso que uno donde tú declaras a qué te suscribes, porque las declaraciones envejecen mal y las lecturas no pueden mentir. Ese es el argumento de fondo de toda la ola de signals.

Hay un corolario práctico del segundo que conviene no perder: la precisión del sistema depende de dónde leas. Como la dependencia nace de la lectura, mover una lectura de sitio cambia el grafo aunque el resultado en pantalla sea idéntico. Extraer una subvista que lea solo la propiedad que necesita no es cosmética, es reescribir el grafo para que la invalidación llegue más abajo.

El tercero es que la identidad precede al estado. Antes de preguntar qué valor tiene algo hay que saber de qué algo hablamos, y ese principio, que aquí se manifestó como el modificador id y como las claves de un ForEach, reaparece idéntico en las claves de una caché de servidor, en los identificadores de un CRDT y en los estados de una máquina.

💡
Un test que revela la frontera

Si para testear una regla de negocio necesitas instanciar una vista, la regla está en el sitio equivocado. Muévela al modelo hasta que el test compile sin importar SwiftUI: ese punto exacto es la frontera que llevabas toda la lección buscando. Es además la única definición operativa de arquitectura que no depende de siglas ni de modas, porque se comprueba con el compilador y no con una discusión.

⚔️ Traza la frontera de tu app
  1. Lista quince datos de una app tuya y pásale a cada uno la prueba de las tres preguntas.
  2. Encuentra un dato ambiguo y argumenta por qué en tu app cae de un lado y no del otro.
  3. Convierte una mutación directa desde la vista en una intención con private(set) y un método del modelo.
  4. Traduce tres piezas de tu código al vocabulario general: qué es signal, qué es efecto y qué es derivación.
  5. Escribe en un párrafo cuál es tu disciplina de mutación actual, y si es una elección o una casualidad.