wandres.dev
@OBSERVABLESTATE · observación fina

Observación y composición: cómo se propaga por el árbol de features

Una feature aislada con `@ObservableState` es un caso trivial; el caso interesante es un árbol de features anidadas, donde el estado de un nieto vive dentro del estado de un hijo que vive dentro del estado del padre. Esta lección sigue la cadena completa: cómo el `Store` compone rutas de clave al hacer `scope`, por qué el store hijo es una lente sobre el estado del padre y no una copia, qué ocurre con presentaciones opcionales, colecciones identificadas y pilas de navegación, y cuál es el eslabón que rompe la granularidad —un `State` intermedio sin marcar—. Termina en la distinción que gobierna todo el árbol: mutar un campo conserva la identidad y actualiza; reasignar un estado hijo entero la cambia y sustituye el subárbol.

⏱ 20 min

La observación fina sería una anécdota si solo funcionara dentro de una pantalla. Su valor real aparece cuando la app es lo que TCA propone que sea: un árbol de features donde el estado del padre contiene el de sus hijos, y el de estos el de los suyos, hasta cualquier profundidad. En ese escenario la pregunta deja de ser qué campos lee esta vista y pasa a ser cómo viaja la noticia de que un campo de un nieto ha cambiado hasta la única vista del árbol que lo dibuja, sin despertar a las cuarenta que hay por el camino. La respuesta tiene tres piezas que ya conoces por separado y que ahora hay que ver funcionando juntas: una macro que instrumenta cada nivel del estado, un scope que compone rutas de clave en lugar de copiar datos, y una identidad de valor que distingue una actualización de una sustitución. Cuando las tres están en su sitio, la granularidad atraviesa el árbol entero; cuando falta una, la pierdes en la frontera exacta donde falta, y solo ahí.

🎯 Al terminar esta lección sabrás
  • Seguir la cadena de accesos desde un campo de un nieto hasta la vista que lo lee, a través de los scope intermedios.
  • Explicar por qué un store hijo es una lente sobre el estado del padre y qué implica que TCA cachee esos hijos.
  • Conectar la observación con @Presents, IdentifiedArrayOf y StackState en presentaciones, listas y pilas.
  • Distinguir mutación de reasignación y predecir cuándo un subárbol se actualiza y cuándo se sustituye.

La cadena de accesos, eslabón a eslabón

Empecemos por la estructura mínima que exhibe el problema: un padre con un hijo que siempre existe, una presentación opcional y una colección.

@Reducer
struct Padre {
  @ObservableState
  struct State: Equatable {
    var cabecera = Cabecera.State()
    var filas: IdentifiedArrayOf<Fila.State> = []
    @Presents var destino: Destino.State?
  }
  enum Action {
    case cabecera(Cabecera.Action)
    case filas(IdentifiedActionOf<Fila>)
    case destino(PresentationAction<Destino.Action>)
  }
  @Reducer
  enum Destino {
    case detalle(Detalle)
    case ajustes(Ajustes)
  }
}

Cada State implicado —el del padre, el de Cabecera, el de Fila, el de cada caso de Destino— lleva su propia instrumentación. Cuando @Reducer se aplica a un enum como Destino, el State que genera ya viene marcado como observable, de modo que las presentaciones no rompen la cadena por descuido.

Ahora sigue una lectura concreta. La vista del padre construye un store hijo con store.scope(state: \.cabecera, action: \.cabecera) y se lo pasa a CabeceraView, que lee store.titulo. Lo que ocurre por debajo es una composición de rutas: el store hijo no guarda una copia de Cabecera.State, guarda la ruta que lleva desde el estado del padre hasta él, y al leer titulo la lectura efectiva es la ruta compuesta cabecera.titulo sobre el estado raíz. Ese es el acceso que queda apuntado en la sesión de rastreo abierta por SwiftUI. Cuando el reducer muta state.filas[id: x]?.marcada, la ruta que se anuncia es otra —empieza por filas— y no coincide con ninguna de las apuntadas por CabeceraView, así que la cabecera no se reevalúa. La granularidad, por tanto, no es por feature sino por campo, cualificado por el camino completo desde la raíz.

flowchart TD
RS[Store raiz sobre State del Padre] -->|scope cabecera| SC[Store de Cabecera]
RS -->|scope filas id| SF[Store de una Fila]
RS -->|scope destino detalle| SD[Store de Detalle]
SC -->|lee titulo| K1[ruta cabecera punto titulo]
SF -->|lee marcada| K2[ruta filas id punto marcada]
SD -->|lee texto| K3[ruta destino detalle punto texto]
MUT[reducer muta filas id punto marcada] --> K2
K2 --> INV[solo se invalida la vista de esa fila]
style INV fill:#a6e3a1,color:#11111b

De aquí sale la regla operativa más importante del nivel: la cadena vale lo que vale su eslabón más grueso. Si Fila.State no lleva @ObservableState, la ruta más específica que el sistema sabe anunciar es filas, y cualquier cambio en cualquier fila invalidará a todo el que haya leído algo de la colección. El defecto no produce error de compilación ni aviso: produce una app que va bien con veinte filas y mal con quinientas.

scope es una lente, no una copia

Conviene desterrar una intuición falsa. store.scope(state:action:) no extrae ni duplica un trozo del estado: crea un store que sabe leer a través de una ruta y sabe reincrustar hacia arriba las acciones del hijo. El estado sigue viviendo en un solo sitio, el del padre, que a su vez vive en el store raíz. Por eso no hay sincronización que mantener entre padre e hijo, ni riesgo de que el hijo trabaje sobre una versión rancia: hay una sola copia de la verdad y muchas lentes sobre ella.

De paso, esa mecánica explica por qué la observación fina no exige nada especial de los reducers hijos: un hijo muta su propio State sin saber que está incrustado, y es la ruta compuesta por el scope la que convierte esa mutación local en una notificación cualificada desde la raíz. El hijo sigue ignorando a su padre, tal como exige la propiedad Lego, y aun así el sistema sabe exactamente a quién avisar.

Esto tiene una consecuencia práctica que la gente que viene de versiones antiguas de TCA suele desconocer: llamar a scope dentro de un body es correcto y barato. TCA cachea los stores hijos por la identidad del ámbito, de modo que reevaluar el cuerpo del padre no fabrica un store nuevo cada vez ni reinicia nada del hijo. Lo que sigue siendo un error es crear un Store en el cuerpo de una vista, que es otra cosa: eso sí construiría un runtime nuevo con estado inicial en cada pasada.

struct PadreView: View {
  @Bindable var store: StoreOf<Padre>

  var body: some View {
    List {
      CabeceraView(store: store.scope(state: \.cabecera, action: \.cabecera))
      ForEach(store.scope(state: \.filas, action: \.filas)) { filaStore in
        FilaView(store: filaStore)
      }
    }
    .sheet(item: $store.scope(state: \.destino?.detalle, action: \.destino.detalle)) { detalleStore in
      DetalleView(store: detalleStore)
    }
  }
}

La distinción entre store.scope y $store.scope no es cosmética y conviene fijarla: la primera produce un store de solo lectura y envío de acciones, apta para hijos que siempre existen; la segunda produce una proyección enlazable, que es lo que exigen los modificadores de presentación de SwiftUI porque necesitan tanto leer si hay algo que mostrar como escribir la ausencia cuando el usuario cierra la hoja deslizándola.

Las tres formas de composición aparecen aquí juntas y merecen una lectura atenta. El hijo que siempre existe se scopea con rutas simples. La colección se scopea entera y ForEach reparte un store por elemento, cada uno anclado al identificador de su fila: mutar la fila número siete invalida exactamente una vista. Y la presentación opcional usa la proyección $store.scope con una ruta que atraviesa el opcional y el caso del enum, lo que produce un enlace que SwiftUI entiende como presencia o ausencia de la hoja.

⚠️
En sistemas antiguos, cada closure diferida necesita su ámbito

El ForEach y el sheet del ejemplo evalúan su contenido fuera del cuerpo que los escribe. En iOS 17 y superiores no importa, porque SwiftUI abre el rastreo en cada cuerpo. Si tu objetivo de despliegue baja de ahí, cada una de esas closures necesita su propio WithPerceptionTracking, tal como viste en la lección anterior; sin él, esa rama del árbol queda fuera de todo ámbito y deja de actualizarse pese a que el estado esté perfectamente instrumentado.

Identidad: actualizar frente a sustituir

Queda la pieza que solo aparece en árboles, y es la que explica los comportamientos más desconcertantes. Un valor no tiene identidad propia, así que @ObservableState se la da: _$id, un ObservableStateID que acompaña al estado. Ese identificador se conserva cuando mutas campos del valor y cambia cuando reemplazas el valor entero. SwiftUI hace cosas radicalmente distintas en cada caso.

// Actualiza: se conserva la identidad, la vista se refresca en su sitio.
state.detalle?.titulo = "Nuevo"

// Sustituye: identidad nueva, subarbol desmontado y montado de cero.
state.detalle = Detalle.State(titulo: "Nuevo")

En el primer caso la vista del detalle se actualiza: conserva su @State local, su posición de desplazamiento, el foco del teclado y las animaciones en curso. En el segundo, SwiftUI concluye que ya no es la misma vista, desmonta el subárbol y monta otro: se pierde el estado local, salta el desplazamiento, el teclado se cierra y las animaciones se reinician. Ninguno de los dos es incorrecto en abstracto —a veces quieres justamente un reinicio limpio— pero elegirlo por accidente es una fuente clásica de defectos que se describen como “la pantalla parpadea” o “se me borra lo que estaba escribiendo”.

La heurística es simple: muta campos cuando quieras continuidad y reasigna el valor completo solo cuando quieras un borrón y cuenta nueva. Y cuando dudes de lo que está pasando, la marca @identity de Self._printChanges() te lo dice sin ambigüedad, como viste en la lección 3.

ℹ️
Las pilas de navegación siguen la misma lógica

Con StackState y un @Reducer enum para los destinos, el NavigationStack se enlaza con $store.scope(state: \.ruta, action: \.ruta) y cada elemento de la pila obtiene su propio store scopeado. La identidad de cada elemento la fija su posición en la pila, de modo que empujar y retirar pantallas actualiza solo los elementos afectados en lugar de reconstruir la pila entera. Es la misma cadena de rutas de siempre, con la profundidad como una dimensión más.

Higiene de la observación en un árbol que crece

Con estas piezas en la mano, mantener la granularidad en una app que crece se reduce a cuatro hábitos que conviene enunciar como reglas explícitas, porque ninguna de las cuatro la vigila el compilador.

Marca todos los niveles sin excepción, incluidos los estados que hoy son diminutos: el State de tres campos de esta semana es el de treinta del año que viene, y para entonces nadie recordará por qué esa rama del árbol invalida de más. Empuja las lecturas hacia abajo, hasta la vista más pequeña que de verdad dibuje el dato, en lugar de leer en el contenedor y pasar valores como parámetros. Prefiere mutar campos a reasignar estados hijos completos, y cuando reasignes hazlo a sabiendas de que estás pidiendo un reinicio. Y scopea en la vista tan cerca del consumo como puedas, aprovechando que TCA cachea los stores hijos: un scope en el cuerpo no cuesta nada, mientras que arrastrar el store del padre hasta el fondo del árbol y leer desde allí store.hijo.nieto.campo reintroduce por la puerta de atrás justo la dependencia que la composición pretendía cortar.

// Frágil: la vista del nieto depende de la forma del estado del padre.
NietoView(store: store, valor: store.hijo.nieto.campo)

// Correcto: cada nivel recibe su propia lente y lee solo lo suyo.
NietoView(
  store: store.scope(state: \.hijo, action: \.hijo)
              .scope(state: \.nieto, action: \.nieto)
)

La segunda versión no solo observa mejor: también es la que permite mover NietoView a otro sitio del árbol —o a un módulo aparte, o a una vista previa aislada— sin tocar una línea. Es la propiedad Lego del nivel 3 reapareciendo, ahora con una consecuencia de rendimiento medible además de una consecuencia de diseño.

La observación fina es la última prueba de que el árbol de features era la estructura correcta

Cierra el nivel con esta idea, porque conecta lo que has aprendido aquí con la tesis entera de la arquitectura. TCA te pidió desde el principio un sacrificio que parecía puro formalismo: modelar la aplicación como un único valor de estado, compuesto jerárquicamente, donde el estado de cada feature es literalmente un campo del estado de su padre, sin referencias cruzadas, sin punteros hacia arriba, sin datos compartidos por accidente. La justificación que se dio entonces era la testabilidad y la composición: un árbol puro se puede reducir, comparar y demostrar. Lo que la observación fina revela, años después, es que aquella estructura tenía una segunda virtud que nadie había cobrado todavía. Un árbol de valores con rutas de clave estables es, resulta, la estructura de datos ideal para responder con precisión quirúrgica a la pregunta que todo motor de interfaz necesita responder mil veces por segundo: quién depende de qué. Porque cada dato del sistema tiene una dirección única y canónica desde la raíz —filas seguido del identificador seguido de marcada, y no hay segunda vía de llegar ahí—, la notificación de un cambio puede ser exacta en lugar de aproximada. Compáralo con la alternativa que domina el resto del ecosistema: un grafo de objetos con referencias mutuas, donde el mismo dato es alcanzable por cinco caminos distintos, donde nadie puede enumerar sus dependientes sin recorrerlo todo, y donde por eso mismo la única política segura es notificar de más. La granularidad gruesa de la era anterior no era una limitación de Combine ni una torpeza de Apple: era la consecuencia inevitable de no tener direcciones canónicas para los datos. TCA las tenía desde el primer día, por razones que nada tenían que ver con el renderizado, y cuando el lenguaje por fin ofreció el mecanismo para explotarlas, la adaptación consistió en añadir una macro a una estructura que ya era la correcta. Esa es la señal que distingue una decisión arquitectónica profunda de una moda: no que resuelva el problema que la motivó, sino que siga rindiendo beneficios inesperados en problemas que aún no existían cuando se tomó. Y de ahí se sigue el consejo con el que conviene salir del nivel: cuando la composición te obligue a mantener el árbol limpio —un State por feature, anidado, sin atajos laterales, marcado en todos sus niveles— no lo vivas como una ceremonia que la arquitectura te impone, sino como el mantenimiento del sistema de direcciones del que dependen a la vez tus pruebas, tu navegación y tu rendimiento.

⚔️ Recorre la cadena de un árbol real de tres niveles
  1. Monta un padre con un hijo que siempre exista, una colección identificada y una presentación opcional, y marca todos los niveles del estado como observables.
  2. Coloca un print en el cuerpo de cada nivel y muta un campo de un solo elemento de la colección. Comprueba que únicamente se reevalúa esa fila.
  3. Quita @ObservableState del State del elemento y repite la prueba: identifica en qué eslabón se pierde la granularidad y por qué.
  4. Cambia una mutación de campo por una reasignación completa del estado hijo y documenta qué se pierde en la vista: estado local, desplazamiento, foco, animación.
  5. Añade en la vista del padre una lectura de un campo de un hijo para pintar un título, mide el efecto sobre las reevaluaciones y corrígelo extrayendo una subvista que lea ese campo por su cuenta.