wandres.dev
@SHARED · estado compartido

El problema: dos features y un mismo usuario

Todo lo construido hasta aquí supone que el estado es un árbol: cada dato tiene un dueño, un camino y un único sitio donde vive. Esa suposición se rompe la primera vez que dos ramas lejanas necesitan el mismo hecho —el usuario autenticado, la sesión, el carrito— y la solución instintiva, duplicar el campo en ambos `State`, produce un sistema que se desincroniza en cuanto alguien escribe. Esta lección diagnostica el problema con precisión, examina las tres reparaciones clásicas —subir al ancestro común, sincronizar con acciones `delegate`, esconderlo tras una dependencia— y muestra qué paga cada una, para terminar enunciando con exactitud qué propiedades debería tener una solución que no obligue a elegir entre consistencia y testabilidad.

⏱ 18 min

La semántica de valor ha sido, durante veintitrés niveles, la garantía silenciosa de que nada cambia a tus espaldas: copiar un State produce un objeto independiente, comparar dos copias es comparar sus campos, y el único modo de que algo se mueva es que un reducer procese una acción. Esa disciplina es lo que hace que una aplicación TCA sea razonable a las tres de la madrugada. Y sin embargo hay una categoría entera de datos para la que esa misma disciplina es exactamente lo contrario de lo que necesitas. El usuario autenticado no es un valor que dos pantallas casualmente tengan iguales: es una identidad única del dominio de la que solo existe una, y de la que solo debería existir una, en toda la aplicación. Duplicarla no es una optimización discutible: es una afirmación falsa sobre el mundo, y el sistema se encargará de demostrártelo.

🎯 Al terminar esta lección sabrás
  • Reconocer cuándo un dato tiene identidad de dominio y no admite copias independientes sin degradar la corrección.
  • Reproducir el fallo de desincronización que produce duplicar un mismo hecho en dos State distintos.
  • Evaluar las tres reparaciones clásicas y nombrar con precisión qué coste estructural paga cada una.
  • Enunciar las cuatro propiedades que una solución al estado compartido debe conservar para seguir siendo TCA.

Un mismo hecho, dos copias

El planteamiento aparece siempre igual. Hay una pantalla de perfil que muestra y edita al usuario, y hay una pantalla de ajustes que también necesita saber quién es para mostrar su nombre, su plan y su avatar. Cada una declara lo que necesita:

@Reducer
struct Perfil {
  @ObservableState
  struct State: Equatable {
    var usuario: Usuario        // copia 1
    var editando = false
  }
}

@Reducer
struct Ajustes {
  @ObservableState
  struct State: Equatable {
    var usuario: Usuario        // copia 2
    var notificacionesActivas = true
  }
}

Compila, funciona en la demo y falla en producción. El usuario cambia su avatar desde el perfil, vuelve a la pestaña de ajustes y sigue viendo la imagen antigua; o edita su nombre en ajustes, entra al perfil y encuentra el anterior. No hay una línea olvidada que reparar: el fallo es estructural. Hay dos representaciones de un hecho que en el dominio es uno solo, y ningún mecanismo que las obligue a coincidir. La independencia que la copia regala —la propiedad que has estado celebrando desde el nivel cuatro— es aquí precisamente el defecto.

Conviene separar este caso de otro que se le parece y no lo es. Si dos features muestran el resultado de dos consultas distintas que hoy devuelven lo mismo, no hay nada que compartir: son dos valores que coinciden. La pregunta que discrimina es si el dato tiene identidad: si la respuesta a cuántos usuarios autenticados hay es exactamente uno, siempre, entonces cualquier modelado que admita dos copias divergentes está representando estados imposibles, que es el pecado que el nivel siete enseñó a evitar.

Las tres reparaciones clásicas

La primera reparación es subir el dato al ancestro común y rebanarlo hacia abajo. Es la respuesta ortodoxa y, cuando las dos features son hermanas y el padre está cerca, es también la correcta: el padre posee el Usuario, cada hijo lo recibe por scope y no existe segunda copia.

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

El problema aparece cuando el ancestro común no está cerca. Si el perfil vive tres niveles dentro de una pestaña y los ajustes cuatro dentro de otra, el único ancestro común es la raíz, y el dato tiene que atravesar siete features que no lo usan pero que deben declararlo, propagarlo y probarlo. Ese arrastre no es solo tedioso: es acoplamiento, porque cada nivel intermedio pasa a conocer un dato que no le concierne, y con él una razón más para tener que recompilarse, retestearse y revisarse cada vez que el dato cambie de forma.

Y hay un fallo más sutil que el tedio, que aparece justo donde el nivel tres había puesto una victoria. Cuando un hijo se construye en el momento de presentarse, lo que recibe es una fotografía:

case .editarPulsado:
  state.destino = .editor(Editor.State(usuario: state.usuario))   // copia de ahora
  return .none

Ese state.usuario se evalúa una sola vez, al abrir la hoja. Si mientras el editor está en pantalla llega del servidor una renovación de la sesión y el padre actualiza su campo, el editor sigue mostrando el usuario de hace treinta segundos, y al guardar escribirá encima con datos caducados. La navegación como datos, que resolvió con elegancia el problema de las transiciones, aquí introduce silenciosamente la segunda copia por la puerta de atrás: cada presentación es un punto donde el árbol se bifurca en dos verdades.

La segunda reparación es sincronizar con acciones. El hijo que escribe emite un hecho, el padre lo recoge y lo reparte:

case let .perfil(.delegate(.usuarioActualizado(u))):
  state.usuario = u
  state.ajustes.usuario = u        // y a cada uno de los demás interesados
  return .none

Es explícito, es rastreable en el registro de acciones y es la herramienta correcta para eventos. Pero para un hecho persistente degenera con rapidez. Cada escritor debe notificar a cada lector, de modo que el trabajo crece con el número de parejas y no con el de features; el padre se convierte en un enrutador cuya única función es copiar campos; y entre la escritura y la propagación existe una ventana en la que las copias discrepan, lo que convierte la consistencia en eventual dentro de un sistema que era síncrono. Los tests heredan el problema entero: probar que ajustes ve el nombre nuevo obliga a montar la cadena completa.

La tercera reparación es sacar el dato del estado y esconderlo tras una dependencia con almacenamiento propio.

@DependencyClient
struct SesionCliente {
  var usuarioActual: @Sendable () -> Usuario?
  var actualizar: @Sendable (Usuario) async -> Void
}

Recupera la fuente única, y a un precio que conviene mirar de frente: el usuario deja de ser estado. Ya no se compara con Equatable, ya no lo observa SwiftUI sin maquinaria adicional, ya no aparece en el diff exhaustivo del TestStore ni en el registro de acciones. Has comprado consistencia entregando justo las dos propiedades por las que elegiste esta arquitectura. Para sistemas externos —red, disco, reloj— la dependencia es la respuesta correcta; para un dato que la interfaz dibuja y el usuario edita, es una fuga.

El requisito, enunciado con precisión

Las tres reparaciones fallan por razones distintas, y de esas razones se deduce el pliego de condiciones. Una solución aceptable debe garantizar, a la vez, cuatro cosas: unicidad, una sola representación del hecho sin importar cuántos la miren; acceso sin parentesco, que dos features lejanas puedan compartirlo sin obligar a los siete intermediarios a saber de él; naturaleza de estado, que siga siendo comparable, observable y diffable como cualquier otro campo del State; y escrituras auditables, que mutar el dato compartido siga siendo un acto explícito, ordenado y afirmable en un test.

Vale la pena notar que ninguna de esas cuatro exigencias pide que el dato deje de estar en el estado, y que ninguna pide tampoco que la aplicación abandone la semántica de valor en general. Lo que piden, en conjunto, es algo más quirúrgico: que exista una forma de marcar un campo concreto como este es el único que hay sin que el resto del sistema note la diferencia. El campo debe seguir declarándose dentro del State, seguir pasando por el inicializador del hijo, seguir participando en la igualdad sintetizada y seguir apareciendo en el diff del TestStore; lo único que cambia es que copiarlo no lo duplica.

Esa es exactamente la especificación de @Shared, y en la lección siguiente vas a comprobar que la cumple punto por punto. Pero antes conviene sostener un momento la incomodidad, porque el pliego contiene una tensión que no desaparece por escribir una anotación: la unicidad y la auditabilidad tiran en direcciones opuestas. Un dato único es, por construcción, alcanzable desde sitios que no se coordinan entre sí, y todo lo que sea alcanzable sin coordinación es difícil de auditar. Que la herramienta consiga las dos cosas a la vez no es gratis ni automático; depende de un mecanismo concreto —la instantánea— y de una disciplina de uso que la quinta lección detalla. Guardar esa tensión en mente es lo que separa usar @Shared con criterio de usarlo como el atajo que hace desaparecer un tipo de trabajo incómodo.

🧬

Identidad, no coincidencia

Si la pregunta cuantos hay tiene como respuesta exactamente uno, dos copias representan un estado imposible.

🪜

El coste del arrastre

Subir al ancestro común es correcto cerca y venenoso lejos: cada nivel intermedio se acopla a un dato que no usa.

📮

Mensajes para eventos

Las acciones delegate transmiten hechos que ocurren una vez; usarlas como pegamento de sincronización crece por parejas.

🕳️

La fuga de la dependencia

Esconder el dato tras un cliente devuelve la unicidad y se lleva la comparación, la observación y el diff del test.

flowchart TD
H[Hecho unico del dominio] --> C1[Copia en Perfil]
H --> C2[Copia en Ajustes]
C1 --> W[Perfil escribe]
W --> D[Las copias divergen]
C2 --> D
D --> R1[Subir al ancestro comun]
D --> R2[Sincronizar con delegate]
D --> R3[Esconder tras dependencia]
R1 --> P1[Arrastre por siete niveles]
R2 --> P2[Consistencia eventual y enrutador]
R3 --> P3[Deja de ser estado]
style D fill:#f38ba8,color:#11111b
⚠️
El síntoma que delata el problema antes de que lo veas el usuario

Antes de que aparezca el avatar antiguo hay una señal más temprana y más fiable: la aparición de acciones cuyo único propósito es copiar un campo de un sitio a otro. usuarioActualizado, sincronizarSesion, refrescarDesdeElPadre. Ninguna describe un hecho del dominio; todas describen una tarea de mantenimiento que existe porque el modelado obliga a mantener dos verdades alineadas. Cuando en tu enum Action aparece una de esas, el fallo ya está en el código: el usuario todavía no lo ha visto.

La semántica de valor es la respuesta correcta a la pregunta equivocada cuando el dato tiene identidad

Merece la pena entender por qué este problema es inevitable y no un descuido del diseño de TCA, porque la conclusión ilumina la solución de las cuatro lecciones siguientes. Una arquitectura de estado en árbol con semántica de valor codifica una tesis ontológica muy concreta: que el mundo de la aplicación se descompone en partes disjuntas, que cada parte pertenece a un dueño, y que la relación entre dos partes es siempre la de contención o la de ninguna. Bajo esa tesis todo funciona con una limpieza notable —la composición es local, la igualdad es estructural, el diff es exacto, la reproducción de un fallo es replicar una lista de acciones— y esa limpieza no es gratuita: se paga con el supuesto de que las partes son disjuntas. La sesión del usuario viola ese supuesto de raíz. No es una parte que el perfil contenga ni una parte que los ajustes contengan: es una entidad de la que ambas hablan, con existencia previa a las dos y ajena a la topología de la interfaz. Ninguna reorganización del árbol arregla eso, porque el problema no está en dónde colocaste las ramas sino en que hay un objeto del dominio que no es rama de nada. Las tres reparaciones clásicas son, vistas así, tres maneras de fingir lo contrario: la primera finge que el dato pertenece al ancestro y paga el arrastre; la segunda finge que basta con avisarse y paga la consistencia eventual; la tercera finge que el dato no es estado y paga la observabilidad entera. Lo que hace falta no es una cuarta manera más ingeniosa de fingir, sino admitir explícitamente que el grafo de estado tiene una arista que el árbol no puede expresar, y darle a esa arista un tipo, un dueño, una disciplina de escritura y una representación en los tests. Eso es exactamente lo que es @Shared: no un permiso para tener variables globales, sino la decisión de que si vas a tener una referencia compartida —y en un dominio real vas a tenerla— es mejor que esté declarada en el estado, marcada en el tipo y sujeta a las mismas reglas de auditoría que todo lo demás, que escondida en un objeto que nadie mira.

⚔️ Provoca la desincronización y mide lo que cuesta arreglarla a mano
  1. Crea dos features hermanas que declaren cada una su propio campo usuario: Usuario y móntalas bajo un padre con dos pestañas. Edita el nombre en una y comprueba en la otra que sigue el anterior.
  2. Repara el fallo con la primera vía: sube el Usuario al padre y rebana hacia los hijos con scope. Anota cuántas líneas has tenido que tocar y en cuántos archivos.
  3. Añade dos niveles de features intermedias que no usen el Usuario para nada y vuelve a hacer llegar el dato. Anota otra vez el número de archivos: esa curva es el coste del arrastre.
  4. Deshaz el cambio y repáralo ahora con acciones delegate que sincronicen las copias. Escribe el test que demuestra que la segunda pestaña acaba viendo el nombre nuevo y cuenta cuántas acciones necesita.
  5. Con las dos versiones delante, escribe en tres líneas cuál de las cuatro propiedades del pliego —unicidad, acceso sin parentesco, naturaleza de estado, escrituras auditables— incumple cada una. Guárdalo: es la lista con la que juzgarás @Shared en la lección siguiente.