wandres.dev
@SHARED · estado compartido

Shared: una referencia que sigue comportándose como estado

La respuesta de TCA al pliego de condiciones de la lección anterior es un property wrapper que parece contradecirse: `@Shared` introduce una referencia genuina —una caja que varias features apuntan a la vez— dentro de un `State` que sigue siendo una estructura con semántica de valor. Esta lección desarma esa aparente contradicción capa por capa: cómo se declara y se entrega un `Shared` desde el padre al hijo mediante el valor proyectado, qué ocurre exactamente cuando se copia un `State` que contiene uno, por qué la igualdad y la observación siguen funcionando, y cuál es el mecanismo —la instantánea de referencia— que permite que el `TestStore` siga afirmando cambios de forma exhaustiva sobre algo que, por definición, puede cambiar desde fuera.

⏱ 20 min

Un ingeniero que llega a @Shared desde la ortodoxia funcional siente de inmediato que algo huele mal: acaban de meter una referencia mutable en el corazón de una arquitectura cuyo argumento de venta era que no había referencias mutables. La sospecha es sana y merece una respuesta precisa, porque de ella depende que uses la herramienta con criterio o con miedo. La respuesta corta es que @Shared no es una referencia cualquiera, sino una indirección instrumentada: mantiene la unicidad del dato, conserva las propiedades observables del estado y —esto es lo que la separa de un singleton— guarda memoria de su propio pasado para que un test pueda seguir afirmando, línea a línea, qué cambió y quién lo cambió. Compartir sin esconder es una posición defendible; esta lección explica cómo se sostiene técnicamente.

🎯 Al terminar esta lección sabrás
  • Declarar estado compartido con @Shared y entregarlo de un padre a un hijo mediante el valor proyectado.
  • Explicar qué se copia y qué no cuando se copia un State que contiene un campo compartido.
  • Justificar por qué Equatable, la observación y la composición siguen funcionando sobre un campo Shared.
  • Describir el mecanismo de instantánea que permite al TestStore afirmar cambios de estado compartido de forma exhaustiva.

Declarar y entregar

La declaración es un property wrapper sobre un campo normal del estado, y la entrega es el paso del valor proyectado en el inicializador del hijo. Nada más.

@Reducer
struct Perfil {
  @ObservableState
  struct State: Equatable {
    @Shared var usuario: Usuario     // la caja, no una copia
    var editando = false
  }
}

@Reducer
struct Ajustes {
  @ObservableState
  struct State: Equatable {
    @Shared var usuario: Usuario     // la misma caja
    var notificacionesActivas = true
  }
}

@Reducer
struct App {
  @ObservableState
  struct State: Equatable {
    @Shared var usuario: Usuario
    var perfil: Perfil.State
    var ajustes: Ajustes.State

    init(usuario: Usuario) {
      let compartido = Shared(value: usuario)
      self._usuario = compartido
      self.perfil = Perfil.State(usuario: compartido)
      self.ajustes = Ajustes.State(usuario: compartido)
    }
  }
}

Hay tres formas de nombrar lo mismo y conviene fijarlas desde el principio, porque toda la ergonomía del tipo depende de distinguirlas. usuario es el valor envuelto: se lee como cualquier campo y devuelve el Usuario actual. $usuario es el valor proyectado: es el Shared en sí, la caja, y es lo que se pasa a otra feature para que comparta. Y _usuario es el almacenamiento del wrapper, que solo aparece cuando escribes un inicializador a mano. Un hijo que recibe $usuario no recibe un Usuario: recibe el derecho a mirar y modificar exactamente la misma celda que mira su padre.

El valor proyectado admite además acceso por miembro dinámico, lo que permite compartir una parte en lugar del todo. Si una feature solo necesita el nombre, $usuario.nombre produce un Shared de esa propiedad, apoyado sobre la misma caja de origen, y esa feature no llega a ver el resto del perfil. Es la contrapartida de scope para el estado compartido: rebanar sin duplicar.

Qué se copia cuando se copia

Aquí está el nudo conceptual. Perfil.State sigue siendo una estructura y sigue teniendo semántica de valor: copiarla produce una estructura independiente cuyos campos normales —editando— son suyos y de nadie más. Lo que ocurre con el campo compartido es que lo que se copia es la referencia a la caja, no su contenido. Dos copias del State tienen dos booleanos distintos y un único Usuario, y esa asimetría es intencionada, porque es exactamente el modelo del dominio: lo local es local y lo único es único.

var a = Perfil.State(usuario: $usuario)
var b = a                       // copia de la estructura

b.editando = true               // solo afecta a b
b.$usuario.withLock { $0.nombre = "Ada" }
a.usuario.nombre                // "Ada": la caja es la misma

De ahí se sigue todo lo demás. La conformidad a Equatable se sintetiza sin problema porque Shared es comparable cuando lo es su contenido, y compara valores envueltos: dos estados que apuntan a la misma caja son iguales en ese campo por construcción, y dos que apuntan a cajas distintas con el mismo contenido también lo son. La observación funciona porque el acceso al valor envuelto se registra igual que el de cualquier propiedad observada, de modo que una vista que lee store.usuario.nombre se invalida cuando ese nombre cambia, venga el cambio de su propio reducer o del de otra feature al otro extremo del árbol. Y la composición no se entera de nada: Scope, ifLet y forEach siguen operando sobre estructuras, sin saber ni necesitar saber que uno de los campos es una caja.

Merece la pena nombrar lo que aquí se sacrifica, porque no es cero. La afirmación el estado de esta feature solo cambia cuando esta feature procesa una acción deja de ser cierta en el momento en que aparece un @Shared. Sigue siendo cierta para todos los demás campos, y la escritura sigue estando restringida a una operación explícita que la lección siguiente estudia en detalle, pero la propiedad global se ha debilitado a cambio de la unicidad. Ese intercambio es el asunto de la quinta lección; por ahora basta con saber que existe y que está localizado en los campos que llevan la marca.

Cómo sigue siendo testeable

La objeción seria contra el estado compartido no es estética sino metodológica: si un dato puede cambiar desde fuera de la feature, ¿cómo puede un TestStore seguir siendo exhaustivo? La respuesta es que Shared no guarda solo su valor actual, sino también una instantánea del valor que tenía en el último punto de comprobación. El TestStore compara contra esa instantánea, no contra un estado anterior que ya no existe, y con eso recupera el diff exacto.

@Test
func editarNombre() async {
  let usuario = Shared(value: Usuario(nombre: "Ada"))
  let store = TestStore(initialState: Perfil.State(usuario: usuario)) {
    Perfil()
  }

  await store.send(.nombreCambiado("Grace")) {
    $0.$usuario.withLock { $0.nombre = "Grace" }
  }
}

La aserción se escribe con la misma forma que la escritura real, y su ausencia se castiga igual que la de cualquier otro campo: si el reducer toca el usuario y el test no lo declara, la prueba falla con un diff que señala exactamente la diferencia. La simetría es total con el estado normal, y esa simetría es la prueba de que la herramienta está bien construida: no hay una segunda gramática de testing para el estado compartido, hay la misma con un acceso más.

Hay además una consecuencia práctica que conviene aprovechar desde el primer día. Como la caja se construye fuera del store y se pasa a la feature, un test puede crearla, entregarla y después inspeccionarla directamente para comprobar el efecto lateral que la feature ha tenido sobre el mundo compartido, sin necesidad de mirar dentro del estado del store:

let sesion = Shared(value: Sesion(usuario: nil))
let store = TestStore(initialState: Autenticacion.State(sesion: sesion)) {
  Autenticacion()
}

await store.send(.entrarPulsado)
await store.receive(\.respuestaLogin.success)
#expect(sesion.wrappedValue.usuario?.nombre == "Ada")

Esa capacidad de sostener la caja desde el test es el reflejo exacto de lo que ocurre en producción, donde quien la sostiene es el padre. Y explica por qué la forma recomendada de crear estado compartido es Shared(value:) y no una clave global: cuando la caja se recibe, el test puede fabricarla; cuando se conjura por una cadena de texto, el test tiene que confiar en que el almacén subyacente esté limpio.

💡
Compartir sin persistir es el caso base, no un caso raro

Shared(value:) crea una caja que existe mientras alguien la sostenga y muere cuando el último interesado la suelta, sin tocar disco ni claves globales. Es la forma que deberías usar por defecto, porque conserva la propiedad más valiosa de todas: el dato tiene un dueño identificable —quien lo creó— y llega a los demás porque alguien se lo entregó explícitamente. Las variantes con estrategia de persistencia, appStorage para preferencias, fileStorage para documentos, inMemory para vida de proceso, resuelven un problema distinto y adicional, y traen consigo un riesgo que la quinta lección examina con detalle. Empezar por ellas porque parecen más cómodas es la manera más rápida de convertir esta herramienta en aquello de lo que quiere protegerte.

📦

Tres nombres, tres cosas

El valor envuelto es el dato, el proyectado es la caja y el subrayado es el almacenamiento. Compartir es pasar el proyectado.

🧩

Copia asimétrica

Copiar el estado duplica los campos propios y comparte la caja. Lo local queda local y lo único sigue siendo único.

👁️

Observación por lectura

Una vista se invalida por haber leído el dato, no por parentesco con quien lo escribió. La distancia en el árbol deja de importar.

🕰️

La instantánea

La caja recuerda su valor en el último punto de comprobación. Sin ese recuerdo no habría diff exhaustivo posible.

flowchart LR
P[App State] -->|pasa el valor proyectado| A[Perfil State]
P -->|pasa el valor proyectado| B[Ajustes State]
A --> C[Caja compartida con Usuario]
B --> C
C --> S[Instantanea para el TestStore]
A --> L1[Campos locales propios]
B --> L2[Campos locales propios]
style C fill:#a6e3a1,color:#11111b
La diferencia entre compartir y esconder es que lo compartido conserva su historia

Vale la pena precisar por qué @Shared no es simplemente un singleton con mejor sintaxis, porque la respuesta explica de paso qué hace realmente que una arquitectura sea testeable, cosa que suele atribuirse a las razones equivocadas. Se dice que TCA es testeable porque el estado es un valor, y eso es media verdad. Un valor es fácil de comparar, sí, pero la comparación necesita dos términos: para afirmar que algo cambió hace falta el después y hace falta el antes. En el estado normal el antes está garantizado por la propia semántica de valor —el TestStore conserva una copia del estado previo porque copiar es barato y las copias no se enteran de nada— y por eso el diff exhaustivo sale gratis. En cuanto introduces una referencia, ese antes desaparece: no existe una copia del pasado, porque solo hay una celda y ya se sobrescribió. Ese es el motivo real por el que un singleton es intestable, y no la vaguedad habitual de que introduce acoplamiento. Es intestable porque destruye evidencia. Lo que hace Shared es reconstruir deliberadamente esa evidencia que la indirección había eliminado: mantiene, junto al valor vivo, una instantánea del valor en el último punto de comprobación, de modo que la pareja antes y después vuelve a existir aunque no haya copia del estado. Con esa pareja restituida, todo el aparato de aserción exhaustiva vuelve a funcionar sin cambiar ni una regla: el test sigue exigiendo que declares cada mutación, sigue fallando cuando omites una, y sigue mostrando un diff que señala el campo exacto. Vista así, la tesis de fondo de esta herramienta es más interesante que la comodidad que ofrece. No dice que las referencias sean aceptables cuando el dominio las pide, dice algo más fuerte: que la propiedad que de verdad importa nunca fue la ausencia de referencias, sino la existencia de historia observable, y que la semántica de valor era hasta ahora la manera más barata de conseguirla, no la única. En cuanto alguien se toma la molestia de instrumentar la referencia, se puede tener identidad compartida y auditoría completa a la vez. Eso es lo que separa a @Shared de la variable global que superficialmente se le parece: la global también comparte, pero no recuerda, y lo que no recuerda no se puede afirmar.

⚔️ Verifica con tus manos qué se copia y qué no
  1. Toma las dos features hermanas del reto anterior y sustituye los dos campos duplicados por un @Shared var usuario: Usuario, creando la caja en el inicializador del padre con Shared(value:) y pasando $usuario a cada hijo.
  2. En un test, copia un State que contenga el campo compartido, muta en la copia un campo local y el campo compartido, y afirma sobre el original: comprueba que lo local no viajó y lo compartido sí.
  3. Rompe la observación a propósito: haz que una vista lea el usuario a través de una variable capturada antes de renderizar en lugar de leerlo del store, y observa qué deja de actualizarse y por qué.
  4. Escribe una aserción de TestStore sobre el campo compartido y después bórrala dejando la mutación en el reducer. Lee con atención el mensaje de fallo: es la instantánea hablando.
  5. Comparte solo una parte con $usuario.nombre en lugar del objeto entero y ajusta el hijo. Justifica en dos frases qué gana el sistema al no dar acceso al perfil completo a quien solo pinta el nombre.