Llega el momento en toda app en que dos pantallas necesitan el mismo dato. Los favoritos que se marcan en el detalle y se ven en la lista. El usuario autenticado que consultan ocho features. Los ajustes que cambian el comportamiento de todo.
Las salidas fáciles son conocidas y las dos son malas. Duplicar el dato en cada feature lleva a que se desincronicen: marcas un favorito en el detalle, vuelves a la lista y sigue sin estrella. Pasarlo por parámetro a través de cinco niveles funciona, pero cada feature intermedia carga con algo que no le importa, y agregar un dato nuevo obliga a tocar cinco archivos.
La tercera salida, el singleton, resuelve la ergonomía y rompe todo lo demás: estado global mutable que ninguna feature declara, que ningún test puede aislar y que nadie sabe quién modificó.
TCA tiene una respuesta para esto: @Shared.
Lo mínimo
Declaras la propiedad compartida en el estado de cada feature que la necesita:
@Reducer
struct FavoritosFeature {
@ObservableState
struct State: Equatable {
@Shared(.appStorage("favoritos")) var favoritos: Set<Int> = []
}
}
Y ya está. Cualquier otra feature que declare @Shared(.appStorage("favoritos")) con el mismo tipo ve exactamente el mismo valor. Cuando una lo cambia, la otra se entera y su vista se actualiza.
Hay tres estrategias:
.inMemory("clave")comparte durante la sesión y se pierde al reiniciar. Para cosas como el estado de una descarga en curso..appStorage("clave")persiste enUserDefaults, solo con tipos simples:Bool,Int,String,Data..fileStorage(url)persiste en disco como JSON. RequiereCodable, y es lo que usas para colecciones y modelos.
Lo importante aquí es que la persistencia es un detalle de la declaración, no del código que la usa. Cambiar de .inMemory a .fileStorage no obliga a tocar un solo reducer.
Compartir sin persistir
También puedes compartir un valor que vive solo en memoria y que el padre inyecta. Ahí la propiedad se declara sin estrategia:
@ObservableState
struct State: Equatable {
@Shared var usuario: Usuario
}
Y el padre la construye pasando su propia referencia compartida:
state.destino = .detalle(
DetalleFeature.State(usuario: state.$usuario, repo: repo)
)
Fíjate en el $: no pasas el valor, pasas la referencia compartida. El hijo no recibe una copia que se quedará vieja, recibe una ventana al mismo dato.
Esta es la diferencia con un singleton: la dependencia sigue siendo explícita. Si abres el State de una feature, ves exactamente qué comparte. No hay acceso invisible desde cualquier parte del código.
Mutar: withLock y por qué existe
Aquí viene el detalle que a mí me tomó por sorpresa. No mutas un @Shared directamente:
// No compila
state.favoritos.insert(repo.id)
// Así sí
state.$favoritos.withLock { $0.insert(repo.id) }
Parece ceremonia gratuita, pero no lo es. Un valor compartido puede leerse y escribirse desde varias features y desde efectos concurrentes. Sin withLock, un favoritos.insert() que por dentro es leer-modificar-escribir puede intercalarse con otro y perder una escritura.
withLock bloquea la operación completa, no solo la escritura final. Es exactamente el bug que en un singleton con un var normal aparece una vez cada mil ejecuciones y nunca logras reproducir.
Un ejemplo completo:
case let .favoritoTocado(repo):
state.$favoritos.withLock { favoritos in
if favoritos.contains(repo.id) {
favoritos.remove(repo.id)
} else {
favoritos.insert(repo.id)
}
}
return .none
Todo el “si contiene, quita; si no, agrega” ocurre bajo un solo candado. No hay ventana entre la lectura y la escritura.
Claves reutilizables
Repetir .appStorage("favoritos") en seis archivos es pedir una errata. La clave se escribe mal en un sitio y esa feature deja de compartir, sin ningún error de compilación.
La librería permite extender SharedReaderKey para tener claves con nombre y tipo:
extension SharedKey where Self == AppStorageKey<Set<Int>>.Default {
static var favoritos: Self {
Self[.appStorage("favoritos"), default: []]
}
}
Y en cada feature:
@Shared(.favoritos) var favoritos
El valor por defecto vive en un solo lugar, el tipo lo garantiza el compilador y la errata deja de ser posible. En un proyecto con más de dos o tres valores compartidos, esto deja de ser opcional.
Solo lectura
No toda feature que consulta un dato compartido debería poder cambiarlo. Una pantalla de estadísticas lee los favoritos, no los edita.
Para eso está @SharedReader:
@ObservableState
struct State: Equatable {
@SharedReader(.favoritos) var favoritos
}
Mismo dato, misma sincronización, pero sin withLock disponible. Es la clase de restricción que documenta la intención mejor que un comentario y que además el compilador hace cumplir.
En los tests
El riesgo obvio del estado compartido es que un test contamine al siguiente. TCA lo cubre: las estrategias de persistencia usan almacenamiento simulado en los tests. Nada toca los UserDefaults reales ni el disco, y cada test arranca limpio.
Puedes fijar el valor inicial en la declaración del estado de prueba, y verificar los cambios como cualquier otro:
@Test
func marcarFavorito() async {
let store = await TestStore(
initialState: DetalleFeature.State(repo: repo)
) {
DetalleFeature()
}
await store.send(.favoritoTocado(repo)) {
$0.$favoritos.withLock { $0 = [1] }
}
}
Cuando el cambio ocurre dentro de un efecto y no directamente en el reducer, se verifica con store.assert { ... } después de que el efecto termina.
Lo que no resuelve
@Shared es bueno, pero no es magia:
- No es
Hashable. Por semántica de referencia, un estado que contenga un@Sharedno puede ser clave de diccionario ni entrar en unSet. Codableno sale gratis. Si necesitas serializar un estado con valores compartidos, tienes que escribirencode/decodea mano.- Sigue siendo estado global. Con mejores propiedades que un singleton, pero abusar de él vuelve a acoplar features que deberían ser independientes. La pregunta antes de compartir algo debería ser siempre la misma: ¿de verdad son dos vistas del mismo dato, o son dos datos que casualmente coinciden hoy?
Ese último punto es el que más cuesta en la práctica, y lo digo por experiencia propia: @Shared es tan cómodo que invita a compartir cosas que no lo necesitan. Cuando te descuidas, el estado global crece igual que en cualquier otra arquitectura, solo que con mejor sintaxis.
Qué sigue
Con esto tenemos las cuatro herramientas base: features, efectos, composición y estado compartido. Suficiente para construir una app real completa.
En la parte 4, que cierra la serie, vamos a fondo con el testing (exhaustivo vs no exhaustivo, relojes controlados, testear efectos de larga duración) y hacemos la comparativa honesta contra MVVM y las demás arquitecturas: cuándo TCA vale la pena, cuándo es exceso, y qué duele en producción.
Y después de la serie, la parte práctica: reconstruir la Pokédex de la serie anterior entera en TCA, para comparar las dos aproximaciones sobre el mismo problema.
Fuentes: Sharing state (docs)