wandres.dev
PERSISTENCIA · @Shared y almacenamiento

inMemory: lo compartido que no sobrevive al proceso

Entre el estado local que muere con su feature y el estado persistido que sobrevive al cierre hay un tercer modo de existencia que suele quedar sin nombre: lo que varias features comparten durante una ejecución y debe desaparecer con ella. Esta lección define ese modo con `@Shared(.inMemory)`, lo contrasta con la alternativa de compartir por referencia explícita, delimita cuándo la ambientalidad de una clave con nombre es una virtud y cuándo es una variable global disfrazada, y muestra por qué tener el mismo API para los tres sustratos convierte la persistencia en una decisión reversible.

⏱ 20 min

La conversación sobre persistencia tiende a plantearse como un interruptor: o el dato se guarda o no se guarda. Pero entre el estado local de una feature —que nace con ella, se copia por valor y muere cuando su pantalla se cierra— y el estado escrito en disco —que sobrevive al proceso, al reinicio y a veces al propio dispositivo— hay una región intermedia perfectamente real y sistemáticamente mal atendida: lo que varias partes de la app necesitan ver a la vez durante una ejecución, y que sería un error conservar más allá de ella. Un token descifrado, la sesión en curso, un carrito que aún no es un pedido. @Shared(.inMemory) nombra esa región, y al nombrarla obliga a decidir explícitamente algo que casi siempre se decide por descuido.

🎯 Al terminar esta lección sabrás
  • Situar inMemory como el tercer modo de existencia del estado, entre lo local y lo persistido.
  • Declarar y usar una clave en memoria, y entender que su ciclo de vida es el del contexto de dependencias.
  • Contrastar compartir por nombre con compartir por referencia explícita, y elegir con criterio entre ambos.
  • Reconocer cuándo inMemory está sustituyendo indebidamente a la composición padre e hijo.

Tres modos de tener un valor

Conviene ordenar el espacio antes de recorrerlo, porque la elección equivocada casi nunca se manifiesta como un error de compilación sino como una arquitectura que se resiste a los cambios.

Modo Quién lo ve Cuánto vive Coste de equivocarse
Propiedad normal del State solo su feature y quien la compone lo que dure el estado que la contiene duplicar el dato en varias features y desincronizarlo
@Shared(.inMemory) cualquiera que nombre la clave lo que dure el proceso acoplamiento invisible entre features lejanas
@Shared(.appStorage) o .fileStorage cualquiera que nombre la clave más que el proceso conservar secretos o basura que debieron morir al cerrar

La tercera columna es la que decide, y la pregunta que la resuelve no es técnica sino de dominio: ¿este valor tiene sentido mañana? Un token de sesión descifrado no lo tiene, y guardarlo en un fichero es una vulnerabilidad, no una comodidad. Un borrador a medio escribir sí lo tiene, y perderlo al cerrar es un bug aunque nadie lo haya reportado. inMemory existe para que la primera respuesta tenga dónde vivir sin caer por inercia en la segunda.

Compartir por nombre

La declaración es la de siempre, con la clave cambiada.

@ObservableState
struct State: Equatable {
  @Shared(.inMemory("sesion")) var sesion: Sesion? = nil
}

No hay requisito de Codable porque no hay serialización: el valor vive en un almacén en memoria que el sistema de dependencias mantiene durante toda la ejecución. Al arrancar, la clave no tiene nada y se usa el valor por defecto de la declaración; a partir de ahí, cualquier feature que nombre la misma clave obtiene la misma referencia, con la misma semántica de lectura directa y mutación bajo withLock que ya conoces.

case let .sesionIniciada(sesion):
  state.$sesion.withLock { $0 = sesion }
  return .none

case .cerrarSesionTocado:
  state.$sesion.withLock { $0 = nil }
  return .none

La cadena vuelve a ser un espacio de nombres global y sin comprobación de tipos, y aquí el riesgo es mayor que en las claves persistidas porque no hay ningún fichero que inspeccionar cuando algo va mal. Dos declaraciones con la misma cadena y tipos distintos son un error que ningún compilador detecta y que se manifiesta en ejecución, lejos de su causa. La profilaxis es la misma de las lecciones anteriores y no admite excepciones.

extension SharedKey where Self == InMemoryKey<Sesion?>.Default {
  static var sesion: Self { Self[.inMemory("sesion"), default: nil] }
}

@Shared(.sesion) var sesion

Que el almacén pertenezca al contexto de dependencias tiene una consecuencia excelente y poco anunciada: cada test arranca con el almacén vacío, y cada previsualización también. No hay estado residual entre casos, no hay que acordarse de limpiar nada, y la clase entera de fallos donde un test pasa en solitario pero falla en la suite completa simplemente no se puede producir por esta vía.

🔐

Secretos vivos

Credenciales descifradas, claves de sesión, datos biométricos derivados. Deben ser visibles para varias features y deben morir al cerrar.

🧭

Contexto de ejecución

El usuario autenticado, el espacio de trabajo activo, el modo de conexión. Lo que toda la app consulta y nadie posee.

Cachés de sesión

Resultados caros de calcular que conviene reutilizar dentro de la ejecución pero que estarían obsoletos mañana.

🔁

Sustrato reversible

Mismo API que las claves persistidas: promover un valor a disco o degradarlo a memoria no toca una sola línea del reducer.

Por nombre o por referencia: dos formas de compartir

inMemory no es la única manera de que dos features vean el mismo valor. Existe también la compartición explícita, en la que se construye una referencia compartida y se entrega a quien deba tenerla.

// Compartir por referencia: el vínculo es visible en el código que lo crea
let carrito = Shared(value: Carrito())
let tienda = Tienda.State(carrito: carrito)
let checkout = Checkout.State(carrito: carrito)

La diferencia entre ambas no es de potencia sino de visibilidad, y es exactamente la vieja tensión entre acoplamiento explícito e implícito. Con la referencia explícita, alguien —normalmente el padre— tuvo que decidir que estas dos features comparten esto, y esa decisión queda escrita en el punto de construcción: se puede leer, se puede buscar y se puede cambiar en un solo sitio. Con la clave con nombre, el vínculo no aparece en ninguna parte; existe porque dos ficheros, quizá escritos por dos personas distintas en dos meses distintos, coinciden en un símbolo.

Criterio Por referencia explícita Por clave con nombre
Dónde se ve el vínculo en el código que construye los estados en ningún sitio: hay que buscar la clave
Quién decide el alcance el padre que compone nadie: es la app entera
Coste de tener varias instancias trivial, se crean varias referencias imposible sin inventar claves distintas
Adecuado para dominios acotados, listas de elementos, pruebas dirigidas contexto ambiental verdaderamente único

La regla práctica se sigue de la última fila. Si el valor es genuinamente uno solo para toda la ejecución —hay una sesión, hay un usuario autenticado, hay un modo de conexión— la clave con nombre dice la verdad sobre el dominio y su ambientalidad es una descripción, no un atajo. Si en cambio puede haber dos —dos documentos abiertos, dos conversaciones, dos carritos— la clave con nombre miente, y la mentira se cobra el día que el producto pide justamente eso.

⚠️
No sustituyas la composición por una clave compartida

El síntoma es reconocible: un hijo escribe en una clave en memoria para que el padre se entere de algo. Eso no es estado compartido, es una acción delegate con peor trazabilidad. La comunicación entre features es un flujo de acontecimientos y pertenece al canal de las acciones, donde queda ordenada, testeada y visible en el árbol. @Shared es para datos que varias partes leen a la vez, no para avisos que una parte manda a otra.

flowchart TD
L[Propiedad local del State] --> P1[Vive lo que su feature]
I[Shared inMemory] --> P2[Vive lo que el proceso]
D[Shared appStorage o fileStorage] --> P3[Vive mas que el proceso]
P2 --> C[Contexto de dependencias]
C --> T[Cada test arranca vacio]
C --> V[Cada preview arranca vacio]
style I fill:#f9e2af,color:#11111b
style C fill:#89b4fa,color:#11111b

El sustrato como variable de diseño

Lo más útil de inMemory quizá no sea su comportamiento sino su forma. Al compartir API con las claves persistidas, convierte el sustrato en un parámetro que se puede cambiar tarde y con seguridad. Un valor puede nacer en memoria mientras se decide si merece disco, ascender a fileStorage el día que el producto pide que sobreviva al cierre, o descender a memoria cuando una revisión de seguridad determina que no debía estar escrito. Ninguno de esos movimientos toca el reducer, ni las acciones, ni los tests de lógica: cambia una línea en la extensión donde vive la clave.

// Ayer, mientras se decidía
extension SharedKey where Self == InMemoryKey<[Borrador]>.Default {
  static var borradores: Self { Self[.inMemory("borradores"), default: []] }
}

// Hoy, sin tocar ninguna feature
extension SharedKey where Self == FileStorageKey<[Borrador]>.Default {
  static var borradores: Self {
    Self[.fileStorage(.documentsDirectory.appending(component: "borradores.json")), default: []]
  }
}
La volatilidad es una propiedad del dominio, no una limitación de la técnica

Hay una asimetría moral en cómo tratamos la memoria y el disco. Lo persistido se considera el estado serio, lo logrado, lo que de verdad cuenta; lo volátil se percibe como una versión inacabada de lo mismo, un dato al que todavía no le hemos dado su lugar definitivo. Esa jerarquía es falsa, y inMemory existe para desmentirla. Que un valor no deba sobrevivir al proceso no es una carencia que la ingeniería aún no ha resuelto: es una afirmación positiva sobre lo que ese valor significa. Una sesión descifrada que sobrevive al cierre deja de ser una sesión y se convierte en una vulnerabilidad; un carrito que sobrevive tres meses deja de ser una intención de compra y se convierte en un recuerdo incómodo; una caché que no caduca deja de ser una caché y se convierte en una mentira sobre el mundo. La duración forma parte de la definición de la cosa, igual que su tipo, y por eso merece declararse con la misma explicitud con que se declara el tipo. Lo que este nivel enseña, visto desde aquí, no es cómo guardar cosas sino cómo escribir en el código cuánto debe durar cada una: appStorage dice para siempre y es pequeño, fileStorage dice para siempre y es grande, inMemory dice mientras estemos aquí. Tres frases sobre el tiempo, no tres tecnologías de almacenamiento. Y como son frases sobre el dominio, cambian cuando el dominio cambia, que es precisamente por qué era tan importante que el reducer no las escuchara.

⚔️ Clasifica tu estado por duración
  1. Inventaría el estado global de tu app: todo lo que hoy vive en un singleton, en una variable estática o en un objeto de entorno de SwiftUI.
  2. Asigna a cada entrada uno de los tres modos respondiendo únicamente a la pregunta de dominio: ¿tiene sentido mañana? Anota los casos donde la respuesta actual y la implementación actual no coinciden.
  3. Migra los verdaderamente volátiles a @Shared(.inMemory) con claves estáticas tipadas, y comprueba que ningún singleton sobrevive a la operación.
  4. Busca en tu código un sitio donde una clave compartida se use para avisar de algo en vez de para contener algo, y reescríbelo como acción delegate.
  5. Toma un valor que hoy esté en memoria y promuévelo a fileStorage cambiando solo la extensión de la clave. Verifica que ni un solo test de lógica necesita modificarse. Después revierte el cambio.