Persistir sin ensuciar el reducer: preferencias con appStorage
Antes de `@Shared` la persistencia era un ritual: una acción para cargar, un cliente inyectado para leer, un efecto para escribir y la esperanza de que nadie olvidara ninguno de los tres. Esta lección explica cómo `@Shared(.appStorage)` convierte ese ritual en una propiedad del propio estado, qué tipos admite realmente `UserDefaults` como sustrato, por qué la escritura pasa por `withLock` en lugar de una asignación directa, y en qué sentido preciso el reducer sigue siendo puro aunque su estado tenga un ancla fuera del proceso.
Hay un momento en toda app en el que alguien pregunta dónde se guarda el tema oscuro. La respuesta tradicional en TCA era un pequeño teatro de tres actos: una dependencia que sabe hablar con UserDefaults, una acción de arranque que la invoca para poblar el estado, y un efecto colgado de cada mutación que devuelve el valor al disco. Funciona, pero paga un precio que no siempre se contabiliza: la lógica de la feature queda salpicada de instrucciones sobre almacenamiento, y aparece una ventana temporal —entre que el estado cambia y el efecto termina— en la que la memoria y el disco discrepan. @Shared(.appStorage) propone otra cosa: que la persistencia no sea algo que el reducer hace, sino algo que el estado es. Esta lección desmonta esa afirmación hasta el nivel de la firma de tipos.
- Reconocer el coste arquitectónico del patrón cliente más acción más efecto para persistir una preferencia.
- Declarar estado persistente con
@Shared(.appStorage)y entender qué papel cumple el valor por defecto. - Conocer el conjunto exacto de tipos que
appStorageadmite y por qué no acepta cualquierCodable. - Mutar estado compartido con
withLocky explicar por qué la escritura exige un gesto explícito.
El reducer no tiene por qué saber que hay un disco
Considera la versión clásica. Se inyecta un cliente, se añade una acción de aparición para leer, y cada mutación arrastra su efecto de escritura.
// El ritual de tres actos, antes de @Shared
@Reducer
struct Ajustes {
@Dependency(\.userDefaults) var userDefaults
@ObservableState
struct State: Equatable { var tema: Tema = .sistema }
enum Action { case aparecio, temaElegido(Tema) }
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .aparecio:
state.tema = self.userDefaults.tema() // acto uno: cargar
return .none
case let .temaElegido(tema):
state.tema = tema // acto dos: mutar
return .run { _ in // acto tres: guardar
await self.userDefaults.setTema(tema)
}
}
}
}
}
Tres piezas para una preferencia, y ninguna de ellas habla del dominio: hablan de infraestructura. Peor aún, la corrección depende de una convención no verificable —recordar el efecto en cada rama que toque tema— que ningún compilador puede imponer. El día que una cuarta acción modifique el tema y nadie añada su return .run, la app funcionará perfectamente hasta el siguiente arranque, que es el peor momento posible para descubrir un bug. Y mientras el efecto está en vuelo existe un intervalo en el que la memoria dice una cosa y el disco otra: una ventana de incoherencia que en la práctica se cierra sola, salvo cuando el sistema mata el proceso justo ahí.
La declaración: el valor por defecto es el fondo, no el dato
La alternativa cabe en una línea, y esa línea vive en el State.
@ObservableState
struct State: Equatable {
@Shared(.appStorage("tema")) var tema: Tema = .sistema
@Shared(.appStorage("vioLaBienvenida")) var vioLaBienvenida = false
}
El valor asignado en la declaración no es el valor inicial en el sentido habitual: es el valor de reposo cuando la clave no existe todavía en UserDefaults. Si hay algo guardado, gana lo guardado; si no lo hay, se usa el defecto y no se escribe nada, de modo que la clave permanece ausente hasta la primera mutación real del usuario. Esa asimetría importa: permite distinguir a posteriori entre alguien que nunca eligió tema y alguien que eligió explícitamente el del sistema, cosa imposible si la app escribe el defecto en el primer arranque.
La clave de texto libre es cómoda y peligrosa a partes iguales: es un espacio de nombres global, sin comprobación de tipos, compartido con cualquier otra parte del binario. La disciplina consiste en no escribirla nunca dos veces, y para eso se extiende el propio tipo de clave.
extension SharedKey where Self == AppStorageKey<Tema>.Default {
static var tema: Self { Self[.appStorage("tema"), default: .sistema] }
}
// En cualquier feature, sin repetir la cadena ni el defecto:
@Shared(.tema) var tema
Ahora la clave es un símbolo: el compilador la comprueba, el autocompletado la ofrece y el defecto vive en un solo sitio. Cualquier feature que declare @Shared(.tema) no obtiene una copia sino la misma referencia compartida, de forma que un cambio en la pantalla de ajustes es visible de inmediato en la de perfil sin ninguna acción que lo comunique.
Carga síncrona
La lectura ocurre al construir el estado, no en un efecto. No existe el instante intermedio en que el valor todavía no llegó.
Una sola referencia
Dos declaraciones con la misma clave apuntan al mismo valor. La sincronización entre features deja de ser un problema a resolver.
Observa lo externo
Un cambio hecho por un widget, una extensión o los ajustes del sistema entra en el estado y redibuja la vista sin intervención.
Aislado en tests
Bajo pruebas el sustrato es un almacén efímero por test, así que ningún caso contamina al siguiente ni toca el disco real.
Qué cabe en appStorage y qué no
El sustrato es UserDefaults, y UserDefaults es una lista de propiedades, no un almacén de objetos arbitrarios. El conjunto admitido es corto y deliberado.
| Tipo del valor | Admitido por appStorage |
Nota |
|---|---|---|
Bool, Int, Double, String |
sí, de forma directa | el caso central de una preferencia |
URL, Date, Data |
sí, de forma directa | Data es la puerta de atrás, y conviene no usarla |
Enumeraciones RawRepresentable |
sí, si el RawValue es Int o String |
la forma idiomática de un ajuste de varias opciones |
| Opcionales de los anteriores | sí | ausencia y valor nulo se distinguen |
Cualquier Codable propio |
no | para eso está .fileStorage, en la lección siguiente |
| Colecciones grandes | técnicamente no, en la práctica jamás | los defaults se cargan enteros en memoria al arrancar |
La última fila es una regla de higiene, no una limitación del framework. UserDefaults se materializa completo en memoria durante el arranque del proceso: meter ahí un historial de mil elementos serializado como Data no falla, simplemente encarece cada lanzamiento de la app para siempre. La frontera correcta es semántica: appStorage guarda decisiones del usuario sobre cómo quiere que la app se comporte, y esas decisiones son pocas, pequeñas y escalares.
Renombrar la cadena de una clave no es una refactorización: es borrarle la preferencia a todos los usuarios que ya la tenían. Las cadenas de appStorage pertenecen a la misma categoría que un esquema de base de datos o el nombre de un campo en una API, y merecen el mismo respeto. Si de verdad hay que cambiarla, hay que migrar leyendo la vieja y escribiendo la nueva, tal como se estudia en la quinta lección de este nivel.
Mutar exige un gesto: withLock
Leer es transparente —state.tema es el valor y punto—, pero escribir no se hace por asignación directa sino a través de la proyección.
case let .temaElegido(tema):
state.$tema.withLock { $0 = tema }
return .none
case .bienvenidaCompletada:
state.$vioLaBienvenida.withLock { $0 = true }
return .none
La incomodidad es intencional. Un valor compartido no es propiedad exclusiva del State que lo declara: es una referencia visible desde varias features y potencialmente desde varios hilos, así que la mutación tiene que ser una sección crítica y no una escritura suelta. withLock marca esa sección, hace atómica cualquier lectura y escritura combinada dentro de ella, y de paso deja en el código una señal tipográfica inconfundible de que ahí se está tocando algo que no es local. El resto del reducer sigue leyendo la propiedad sin ceremonia, que es exactamente donde se quiere la asimetría: lectura barata y ubicua, escritura rara y señalizada.
flowchart LR A[Accion del usuario] --> R[Reducer muta con withLock] R --> S[Referencia compartida] S --> V[Vista redibuja] S --> D[UserDefaults escribe] E[Widget o extension cambia la clave] --> D D --> S style S fill:#89b4fa,color:#11111b style D fill:#a6e3a1,color:#11111b
Lo que cambia con @Shared(.appStorage) no es la cantidad de código sino la categoría gramatical de la persistencia. En el patrón clásico, guardar es un verbo: una acción que alguien ejecuta en un instante, con su duración, su posibilidad de fallo y su obligación de recordarla en cada rama. Un verbo vive en el tiempo, y todo lo que vive en el tiempo puede olvidarse, llegar tarde o quedarse a medias. Con @Shared, la persistencia se convierte en un predicado: no es que el reducer guarde el tema, es que el tema es una cosa persistente, y lo es siempre, en toda rama presente y futura, incluidas las que nadie ha escrito aún. Esa mudanza de verbo a predicado desplaza la responsabilidad desde la disciplina del programador hasta la declaración del tipo, que es el único sitio donde una garantía puede sostenerse sin vigilancia. Y explica también por qué la pureza del reducer no se rompe: la función sigue siendo la misma transformación de estado y acción en estado y efecto; lo que cambió es el modo de existencia de una de sus variables, que ahora tiene un ancla fuera del proceso. El reducer nunca supo si su estado vivía en la pila, en el montón o en una caché de la CPU, y tampoco necesita saber que esta parte vive además en el disco. La ignorancia deliberada sobre el sustrato es precisamente lo que permite que la lógica se mantenga testeable mientras el almacenamiento cambia debajo, y es la idea que sostiene las cuatro lecciones restantes: fileStorage, inMemory y tu propia clave no son tres tecnologías, son tres sustratos para un mismo predicado.
- Busca en tu app una preferencia que hoy viaje por el patrón de tres actos: cliente inyectado, acción de carga y efecto de guardado.
- Sustitúyela por una declaración
@Shared(.appStorage)en elState, con el valor por defecto correcto, y elimina las tres piezas antiguas. Cuenta las líneas que desaparecen. - Extrae la clave a una extensión estática con su defecto, y comprueba que ninguna cadena literal queda repetida en el proyecto.
- Declara la misma clave en una segunda feature no relacionada. Cambia el valor en la primera y verifica que la segunda lo refleja sin que exista ninguna acción de comunicación entre ambas.
- Añade una acción nueva que también modifique la preferencia y observa que, esta vez, no hay nada que recordar: la persistencia ocurre porque el estado la tiene, no porque tú la invoques.