wandres.dev
@OBSERVABLESTATE · observación fina

@ObservableState: observación fina para un estado de valor

TCA no podía adoptar `Observation` tal cual: su State es una struct y el protocolo `Observable` exige una clase. La respuesta de Point-Free fue `@ObservableState`, una macro que embebe un registrador dentro del valor, dota a cada State de una identidad estable —`_$id`— y convierte al Store, el único tipo de referencia de la arquitectura, en el puente que SwiftUI observa. Esta lección desmonta lo que la macro inyecta, muestra la vista que resulta cuando desaparecen `WithViewStore` y las proyecciones de ViewState, y fija las reglas de aplicación que separan una observación verdaderamente fina de una que se degrada a la granularidad de siempre sin avisar.

⏱ 19 min

La lección anterior terminó con un callejón sin salida aparente: Observation reparte notificaciones por campo, pero solo sabe hacerlo sobre clases, porque necesita un registrador con identidad compartida entre lectores y escritores. El State de TCA es una struct, y no por descuido: la copiabilidad, la comparación por valor y la ausencia de mutación a distancia son la base de que un reducer sea una función pura y de que el TestStore pueda demostrar cosas. Renunciar a eso para conseguir observación fina habría sido cambiar la joya por el envoltorio. La salida de Point-Free fue construir el equivalente exacto para valores: @ObservableState, una macro que instrumenta la struct sin quitarle la semántica de valor, más un Store de referencia que hace de único punto observable frente a SwiftUI. El resultado es que la vista lee store.campo como quien lee un campo cualquiera, y el sistema sabe, campo a campo, quién depende de qué.

🎯 Al terminar esta lección sabrás
  • Explicar por qué @Observable no sirve para el State de TCA y qué inyecta @ObservableState en su lugar.
  • Reconocer el papel de _$id y de ObservableStateID como identidad del valor observado.
  • Escribir una vista moderna sin WithViewStore, leyendo con store.campo y enlazando con @Bindable var store.
  • Aplicar las reglas que preservan la granularidad fina y detectar las trampas que la degradan en silencio.

El puente: un registrador dentro del valor y un Store observable

La macro se aplica a la struct del estado, dentro del @Reducer, y no se aplica sola: @Reducer genera el andamiaje del reducer y de las rutas de caso, pero es tarea tuya marcar el State.

@Reducer
struct Perfil {
  @ObservableState
  struct State: Equatable {
    var nombre = ""
    var noLeidos = 0
    @ObservationStateIgnored var cacheDeAvatar: Data?
  }
  enum Action { case nombreCambiado(String) }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case let .nombreCambiado(nuevo):
        state.nombre = nuevo
        return .none
      }
    }
  }
}

Lo que la macro hace se parece mucho a @Observable, con dos diferencias decisivas. Conforma la struct al protocolo ObservableState, reescribe cada propiedad almacenada para que su getter registre un acceso y su setter envuelva la escritura en una mutación anunciada, y añade dos miembros sintetizados: un registrador —ObservationStateRegistrar, una struct que guarda por dentro una referencia, de modo que el aviso sobreviva a las copias del valor— y un identificador, _$id, de tipo ObservableStateID. @ObservationStateIgnored es el equivalente de @ObservationIgnored: saca a un campo del mecanismo, algo que conviene hacer con cachés y datos pesados que la interfaz nunca dibuja.

Reducida a su esqueleto, la expansión se parece a esto. Compárala con la de @Observable de la lección anterior y verás que la diferencia no está en el mecanismo, sino en dónde vive el registrador y en la presencia del identificador:

struct State: ObservableState, Equatable {
  var _$observationRegistrar = ObservationStateRegistrar()   // struct con una referencia dentro
  var _$id: ObservableStateID { _$observationRegistrar.id }

  private var _nombre = ""
  var nombre: String {
    get {
      _$observationRegistrar.access(self, keyPath: \.nombre)
      return _nombre
    }
    set {
      _$observationRegistrar.mutate(&self, keyPath: \.nombre, &_nombre, newValue, isIdentityEqual: ==)
    }
  }
}

El detalle que hace posible todo el invento está en la primera línea: el registrador es una struct que guarda por dentro una referencia compartida. Gracias a ello, copiar el State —algo que ocurre continuamente, porque el reducer recibe un inout y el store guarda su propia copia— no fabrica canales de notificación distintos y desconectados. La semántica pública sigue siendo la de un valor; la identidad necesaria para notificar viaja escondida, en un miembro que tú nunca escribes ni lees.

El identificador es la pieza que no tiene análogo en el mundo de las clases, y existe porque un valor no tiene identidad propia. Una clase es la misma clase aunque cambien todos sus campos; una struct reasignada entera es, para todos los efectos, otro valor. ObservableStateID le da a TCA un criterio para distinguir dos situaciones que SwiftUI trata de forma muy distinta: mutar un campo del estado —el identificador se conserva, la vista se actualiza— frente a reemplazar el estado completo por uno nuevo —el identificador cambia, la vista se considera otra—. La lección 5 explota esa distinción al hablar de árboles anidados; por ahora basta con saber que está ahí y que es lo que permite que la observación fina atraviese fronteras de features.

El segundo actor es el Store. Es el único tipo de referencia de TCA y, con @ObservableState en juego, es también el objeto que SwiftUI observa. Gracias a la búsqueda dinámica de miembros, store.nombre no devuelve simplemente un campo: enruta la lectura hacia el estado subyacente y hace que el acceso quede apuntado en la sesión de rastreo que SwiftUI abrió alrededor del body. La vista, por tanto, no habla nunca con el registrador; habla con el store, y el store traduce.

flowchart LR
V[body de la vista] -->|lee store punto nombre| ST[Store referencia y Observable]
ST -->|ruta de clave nombre| REG[registrador dentro del State]
REG --> TR[sesion de rastreo de SwiftUI]
RD[reducer muta state punto nombre] --> REG
REG -->|solo a quien leyo nombre| INV[invalidacion]
INV --> V
style ST fill:#89b4fa,color:#11111b
style REG fill:#cba6f7,color:#11111b

La vista después de la macro

El efecto práctico es una amputación de ceremonia. Desaparecen WithViewStore, la closure observe:, la struct ViewState que había que mantener en paralelo y viewStore como intermediario. Queda una vista que parece SwiftUI corriente.

struct PerfilView: View {
  let store: StoreOf<Perfil>

  var body: some View {
    VStack {
      Text(store.nombre)
      if store.noLeidos > 0 {
        Badge(cuenta: store.noLeidos)
      }
      Button("Saludar") { store.send(.saludoPulsado) }
    }
  }
}

Fíjate en el if: la observación es condicional, tal como vimos en la lección 1. Mientras noLeidos valga cero, esta vista ni siquiera queda suscrita a los cambios de noLeidos… salvo que la condición misma lo lee, y esa lectura sí registra el campo. Es un buen recordatorio de que lo que cuenta no es lo que se dibuja, sino lo que se lee, condiciones incluidas.

Para los enlaces de dos vías, @Bindable var store produce $store.campo, que envía por dentro una acción .binding aplicada por BindingReducer(), tal como estudiaste en el nivel 2. La diferencia con la era antigua es que ya no hay que declarar el campo en ningún sitio adicional.

struct FormularioView: View {
  @Bindable var store: StoreOf<Formulario>

  var body: some View {
    Form {
      TextField("Nombre", text: $store.nombre)
      Toggle("Avisos", isOn: $store.avisos)
    }
  }
}
⚠️
Leer el estado entero anula la ganancia

store.state devuelve el valor completo, y leerlo dentro de un body registra la raíz: a partir de ahí, cualquier mutación en cualquier campo invalida esa vista. Es la forma más rápida de volver, sin darte cuenta, a la granularidad de ObservableObject. Reserva store.state —o el acceso puntual con store.withState— para contextos fuera de la observación, y dentro de la vista lee siempre campo a campo. La misma advertencia vale para pasar el State completo a una subvista como parámetro de valor: quien lo construye tuvo que leerlo entero.

Reglas de aplicación

🧬

Marca todos los niveles

Si el State de un padre contiene el State de un hijo y este no lleva @ObservableState, la cadena de rastreo se corta en la frontera: el padre observa el campo del hijo como un bloque y cualquier cambio interno lo invalida entero. La granularidad de un árbol es la del eslabón más grueso.

⚖️

Conserva Equatable

Equatable sigue siendo infraestructura, no adorno: lo exige el TestStore para diferenciar estados y lo aprovecha el runtime para descartar trabajo. La macro no lo sustituye ni lo hace innecesario.

🚫

Ignora lo que no se dibuja

@ObservationStateIgnored saca del mecanismo cachés, buffers y datos derivados pesados. Menos campos instrumentados es menos ruido y menos ocasiones de invalidar por accidente.

🧮

Cuidado con las computadas glotonas

Una propiedad computada del State registra todo lo que su getter toca. Si un resumen recorre diez campos, quien lo lea quedará suscrito a los diez. A veces es justo lo que quieres; conviene que sea una decisión y no un descubrimiento.

Queda una regla que no cabe en una tarjeta porque es más bien un hábito: empujar las lecturas hacia abajo. Cuando una vista padre lee un campo del hijo para pintar un detalle propio —un título en la barra, una insignia—, esa lectura ata al padre al ritmo de cambio del hijo, y el padre suele ser el nodo más caro de reevaluar. Extraer una subvista minúscula que lea ese campo por su cuenta convierte una invalidación grande en una diminuta. La observación fina te da el instrumento; dónde colocar la lectura sigue siendo una decisión de diseño tuya.

La macro es la prueba de que valor y observación no eran incompatibles, solo estaban mal casados

Hay una lectura superficial de @ObservableState —una comodidad que borra WithViewStore del código— y una lectura profunda que es la que importa. Durante años, el ecosistema de Apple presentó una disyuntiva que parecía natural: o modelas con valores, y entonces renuncias a que el sistema sepa qué cambió y quién lo mira, o modelas con objetos observables, y entonces renuncias a la pureza, a la copiabilidad y a la comparabilidad que hacen razonable un modelo. Casi todas las arquitecturas de la última década se colocaron en un punto u otro de ese eje falso, y muchas de sus complicaciones —los view models que se pasan referencias, los didSet que sincronizan a mano, las copias defensivas— son cicatrices de esa elección. Lo que @ObservableState demuestra es que la disyuntiva era un accidente de implementación y no una verdad: la observación no exige identidad de referencia en el dato, exige un canal de notificación con identidad en algún sitio. Point-Free descubrió que ese sitio podía ser un registrador embebido en el valor, compartido entre las copias por una referencia interna que la semántica pública jamás expone, con una identidad explícita —_$id— que suple lo único que a un valor le falta de veras. Con esa pieza, la struct sigue siendo una struct para todos los usos que te importan: se copia, se compara, se muta en un reducer puro, se construye en un test sin escenario, se serializa. Y a la vez notifica con precisión de campo. La consecuencia que conviene interiorizar es de segundo orden: cuando dejas de pagar el impuesto de observar de más, dejas de tener incentivos para partir tu estado en trozos por razones de rendimiento. Antes, un State grande se pagaba en redibujados, y por eso la gente lo fragmentaba en objetos pequeños aunque el dominio pidiera lo contrario. Ahora puedes modelar el dominio como el dominio es —un valor grande, coherente, con todos los estados imposibles eliminados por tipos— y confiar en que la granularidad de la observación no depende del tamaño del valor sino de lo que cada vista lee. La arquitectura deja de deformarse por presiones del renderizador. Esa es la libertad que la macro compra, y es mucho más valiosa que los milisegundos.

⚔️ Convierte una feature y verifica que la granularidad es real
  1. Toma una feature con WithViewStore(store, observe:) y una ViewState proyectada. Anota qué campos declara y compáralos con los que la vista lee de verdad; casi siempre sobra o falta alguno.
  2. Marca el State con @ObservableState, borra la envoltura y la ViewState, y reescribe la vista leyendo store.campo.
  3. Añade un print en el body y comprueba que mutar un campo que la vista no lee ya no la reevalúa.
  4. Introduce deliberadamente un let s = store.state al principio del body, repite la prueba y explica con precisión por qué vuelve a invalidarse siempre.
  5. Marca con @ObservationStateIgnored un campo pesado que no se dibuje y razona qué garantía pierdes y cuál conservas al hacerlo.