wandres.dev
@SHARED · estado compartido

SharedReader: consumir un dato sin poder tocarlo

Compartir un dato con cinco features rara vez significa que las cinco deban poder escribirlo. `SharedReader` es la mitad de solo lectura del mecanismo: el mismo dato, la misma unicidad, la misma observación e idéntica testabilidad, pero sin la operación que muta. Esta lección presenta la degradación de una caja escribible a una lectora, defiende que la restricción es una capacidad concedida por el padre y no una convención de buena educación, y muestra por qué concentrar la escritura en una sola feature convierte la pregunta quién cambió esto en algo que se responde leyendo tipos en lugar de buscando por el repositorio. Cierra con las fuentes de solo lectura, donde el lector deja de ser una restricción y pasa a ser la forma natural de un dato que la app no posee.

⏱ 18 min

Cuando una aplicación crece, el problema del estado compartido cambia de naturaleza. Al principio la dificultad era conseguir que el dato llegara a todas partes; una vez resuelto eso, la dificultad pasa a ser que llega demasiado bien. Diez features observan el usuario autenticado y cualquiera de ellas, con una sola línea, puede reescribirlo; nueve no tienen ningún motivo legítimo para hacerlo, pero el tipo que sostienen no distingue entre las nueve y la que sí. La consecuencia práctica aparece meses después, la primera vez que alguien pregunta por qué el plan del usuario aparece degradado y la única forma honesta de contestar es buscar withLock en todo el repositorio. SharedReader existe para que esa búsqueda no haga falta: quien no debe escribir sostiene un tipo que no sabe escribir, y la lista de escritores se lee en las declaraciones.

🎯 Al terminar esta lección sabrás
  • Declarar acceso de solo lectura con @SharedReader y obtenerlo degradando una caja escribible.
  • Decidir en el punto de entrega qué capacidad recibe cada feature, en lugar de confiarla a una convención.
  • Argumentar qué gana el sistema al concentrar la escritura de un dato compartido en una sola feature.
  • Reconocer las fuentes de solo lectura, donde el lector no restringe nada sino que modela un dato ajeno.

Un tipo al que le falta una operación

La declaración es simétrica a la de la lección anterior, y la diferencia está en lo que el tipo no ofrece.

@Reducer
struct Cabecera {
  @ObservableState
  struct State: Equatable {
    @SharedReader var usuario: Usuario     // lee y observa; no muta
    var expandida = false
  }
}

// En el padre, que sí posee la caja escribible:
state.cabecera = Cabecera.State(usuario: SharedReader($usuario))

El lector se lee exactamente igual —state.usuario.nombre devuelve el valor vivo— y se invalida exactamente igual cuando alguien escribe al otro lado. Lo único que ha desaparecido es la operación de mutación: no hay withLock que llamar sobre el valor proyectado de un SharedReader, y el intento de escribir es un error de compilación, no un aviso ni una convención. Tampoco hay una vía indirecta que lo permita: no existe una propiedad que devuelva la caja escribible subyacente, ni un inicializador que la reconstruya a partir del lector, porque cualquiera de las dos cosas convertiría la garantía en una sugerencia.

Que la lectura sea idéntica importa más de lo que parece, porque significa que la decisión de quitar la escritura a una feature no la obliga a cambiar nada más. Sus vistas siguen escritas igual, sus proyecciones calculadas siguen compilando, sus tests de lectura siguen pasando sin tocar una aserción. Lo único que deja de compilar son exactamente las líneas que escribían, que son precisamente las que querías inspeccionar. Una restricción que solo rompe lo que pretende restringir es una restricción barata de adoptar, y por eso conviene adoptarla temprano en lugar de dejarla para una limpieza futura que nunca llega.

La conversión es de un solo sentido y esa direccionalidad es el diseño entero. Un Shared se degrada a SharedReader cuando se entrega; un SharedReader no se promueve a Shared de vuelta, porque si pudiera hacerlo la restricción no restringiría nada. Quien posee la caja escribible decide, en el momento exacto de construir el estado del hijo, qué capacidad le concede, y esa decisión queda escrita en el inicializador y en el tipo del campo. Una feature no puede aumentar sus propios privilegios: solo puede recibirlos.

Capacidad, no cortesía

La distinción importante es que esto no es documentación con formato de tipo, sino una capacidad en el sentido estricto que la palabra tiene en diseño de sistemas: la posibilidad de realizar una operación viaja con el valor recibido, y no depende ni de la identidad de quien la intenta ni del respeto a una norma del equipo. Quien tiene el valor puede; quien no lo tiene no puede, y no hay una tercera situación en la que pudiera si supiera la contraseña adecuada.

Aspecto Shared SharedReader
Lectura del valor vivo
Invalidación de vistas
Mutación con withLock no existe la operación
Conversión se degrada a lector no se promueve
Papel típico dueño del dato | único escritor consumidor | proyección

Comparado con las alternativas de siempre, el contraste es instructivo. Un comentario que diga no escribas esto depende de que alguien lo lea. Una propiedad calculada de solo lectura obliga a duplicar la superficie del tipo y se rompe al añadir un campo. Un protocolo con solo lectores separado del protocolo con escritores funciona, pero exige dos tipos, una conformidad y un borrado de tipo para pasarlo por ahí. SharedReader obtiene el mismo resultado sin ninguna de esas tres cosas, porque la asimetría está en la API del tipo y no en un envoltorio construido encima.

Conviene además notar que la restricción viaja hacia abajo sola. Una feature que recibió un lector solo puede entregar lectores a sus propios hijos, porque no tiene nada más que entregar; no hace falta recordar la regla en cada nivel ni documentarla en la revisión de código, porque no existe la operación que la violaría. Esa propagación automática es lo que hace que la garantía escale: en un subárbol de quince features, basta con haber decidido bien en el punto donde el subárbol se conecta al resto.

Lo que se gana con eso no es sobre todo seguridad, sino capacidad de razonar. En un módulo cualquiera, la pregunta quién puede cambiar el usuario deja de ser una búsqueda por el repositorio y pasa a ser una lectura de declaraciones: los campos marcados como lectores son inocentes por construcción y solo los pocos que sostienen la caja escribible entran en la investigación. La disciplina que esto habilita es la que conviene adoptar por defecto: una feature escribe, todas las demás leen. La que escribe es la dueña del dominio del dato —la sesión escribe la sesión, el carrito escribe el carrito— y expone a las otras las transiciones legítimas como acciones que le llegan, no como permiso de escritura directa. El resultado es que el estado compartido recupera la propiedad que parecía haber perdido: hay un sitio, y solo uno, donde entender cómo evoluciona el dato.

Esa concentración tiene además un efecto colateral que se agradece meses después: la dueña se convierte en el sitio natural donde viven los invariantes. Si el plan solo puede degradarse cuando la suscripción ha caducado de verdad, esa comprobación se escribe una vez, en el reducer que posee la caja, y ninguna otra feature puede saltársela porque ninguna otra puede escribir. Repartida la escritura, el mismo invariante se convierte en una regla que hay que recordar, y una regla que hay que recordar es una regla que en algún punto no se recordó.

La forma concreta de pedir en lugar de hacer es la que ya conoces del nivel catorce, y ahora tiene una segunda justificación. La feature lectora emite un hecho; la dueña decide si escribe, cómo escribe y qué invariantes aplica:

// En la feature consumidora: no puede escribir, así que informa
case .cerrarSesionPulsado:
  return .send(.delegate(.cierreDeSesionSolicitado))

// En la dueña de la sesión: el único sitio donde el dato cambia
case .ajustes(.delegate(.cierreDeSesionSolicitado)):
  state.$sesion.withLock { $0 = .anonima }
  return .run { _ in await self.credenciales.borrar() }

Fíjate en lo que se ha ganado sin escribir una línea de más: el borrado de credenciales, que es la consecuencia inevitable de cerrar sesión, vive junto a la escritura y no puede olvidarse en la tercera pantalla que también ofrece un botón de salir. Cuando la capacidad de escribir está repartida, cada escritor debe recordar la lista completa de consecuencias; cuando está concentrada, la lista se escribe una vez.

Y hay un beneficio adicional en el nombre de la acción. cierreDeSesionSolicitado describe una petición, no una orden, y esa diferencia de registro deja espacio para que la dueña haga algo distinto de lo esperado: pedir confirmación si hay cambios sin guardar, rechazar la salida durante una subida en curso, registrar el intento. Con la escritura repartida, esa flexibilidad no existe, porque la consumidora no pidió nada: ya lo había hecho.

Merece la pena anticipar el error de aplicar la restricción al revés. Repartir capacidad de escritura a varias features y confiar en que se coordinen no produce un sistema flexible, produce un sistema donde ninguna transición del dato está completa en ninguna parte y donde cada nueva regla de negocio hay que replicarla en cinco sitios. La flexibilidad que se busca casi siempre se obtiene mejor con lo contrario: la dueña escribe y acepta acciones, y las demás piden en lugar de hacer.

Lecturas que vienen de fuera

Hasta aquí el lector ha aparecido como una restricción que alguien impone sobre una caja que sí podría escribirse. Hay una segunda familia de usos, tan importante como la primera, en la que el lector no restringe nada porque no hay nada que restringir: datos que la aplicación no posee y por tanto no puede escribir. Una consulta a la base de datos local, un valor de configuración remota, una proyección derivada que se recalcula sola. El dato entra en el estado, se observa como cualquier otro campo y se prueba con las mismas herramientas, pero la app es su consumidora y no su autora; escribirlo no tendría sentido, del mismo modo que no tiene sentido asignar al resultado de una consulta.

@ObservableState
struct State: Equatable {
  @SharedReader var pendientes: [Tarea]     // carga desde su fuente
  var filtro: Filtro = .todas

  var visibles: [Tarea] {                   // derivar sigue siendo trivial
    pendientes.filter(filtro.admite)
  }
}

Para estas fuentes, el valor proyectado suele ofrecer además el estado de la propia carga —si está en curso, si terminó con error— de modo que la vista puede dibujar el cargando y el fallo sin que la feature invente acciones para representar algo que no es un evento suyo. Es el mismo movimiento conceptual de todo el nivel: en lugar de sincronizar a mano un dato que vive fuera, se declara la dependencia sobre él y la infraestructura mantiene la copia local al día. La diferencia con una dependencia clásica es que aquí el resultado es estado observable y comparable, no un valor devuelto por una llamada que hay que orquestar.

La conversión hacia esta forma suele revelar algo sobre el modelo. Si al declarar un listado como lector descubres que la feature necesitaba escribirlo para reflejar una edición local, lo que tienes no es una fuente externa sino dos datos distintos que estaban ocupando el mismo campo: la verdad del servidor y el cambio pendiente de enviar. Separarlos —lector para lo que llega, campo propio para lo que aún no ha salido— resuelve de paso la clase entera de fallos en que un refresco borra lo que el usuario acababa de escribir.

El contraste con lo que habrías escrito sin esta herramienta explica por qué merece la pena. Un listado que se mantiene al día a mano necesita, como mínimo, un campo para el dato, otro para el indicador de carga, otro para el error, una acción de aparición, una acción de respuesta, un efecto de larga vida que escuche cambios y la cancelación correspondiente al desaparecer: siete piezas, todas ellas mecánicas, todas ellas repetidas en cada pantalla que consuma el mismo listado. Declarado como lector, el dato entra con su ciclo de vida resuelto y la feature vuelve a ocuparse solo de lo suyo, que es decidir qué hace con él. La feature no ha perdido control sobre nada que le importara: ha dejado de mantener un mecanismo que no era de su incumbencia.

Y hay una última consecuencia que conviene subrayar porque cierra el círculo con la primera lección del nivel. Si dos pantallas distintas declaran el mismo lector sobre la misma fuente, no hay dos consultas ni dos copias que puedan divergir: hay una, con dos observadores. El problema con el que empezó este nivel —el mismo hecho representado dos veces— se disuelve también aquí, y por la misma razón de siempre: porque el dato tiene un sitio y todos miran ese sitio.

💡
El lector también es la respuesta correcta al hijo presentado

Un hijo que se presenta en una hoja o un diálogo es el candidato ideal para recibir un SharedReader. Suele existir para mostrar algo y devolver una decisión, su ciclo de vida es corto y el estado que deja atrás pertenece al que lo presentó. Darle la caja escribible porque resulta cómodo que se actualice a sí mismo antes de cerrarse es la manera habitual de acabar con dos features que escriben el mismo dato en un orden que depende de con qué velocidad se anima una transición. Lee del lector, emite el hecho por delegate, y deja que el padre escriba.

🔒

Capacidad, no norma

Sin withLock en el tipo no hay escritura posible. La restricción la comprueba el compilador y no la revisión de código.

➡️

Degradación de un solo sentido

La caja escribible se convierte en lectora al entregarse. Ninguna feature puede recuperar por su cuenta lo que no recibió.

🧭

Un escritor por dato

La dueña del dominio escribe y acepta acciones. Las demás leen y piden. La evolución del dato cabe en un archivo.

🌐

Fuentes ajenas

Consultas y configuraciones remotas entran como lectores: la app las observa como estado sin fingir que las posee.

flowchart LR
S[Sesion posee la caja escribible] -->|withLock| C[Dato compartido]
S -->|entrega lector| A[Cabecera]
S -->|entrega lector| B[Ajustes]
S -->|entrega lector| D[Panel de facturacion]
A -->|accion delegate| S
B -->|accion delegate| S
D -->|accion delegate| S
style S fill:#a6e3a1,color:#11111b
style C fill:#89b4fa,color:#11111b
El tipo como enunciado sobre el grafo de escritura de todo el programa

Conviene ver esta lección como lo que realmente es, porque bajo su aparente modestia hay un principio que trasciende a TCA y a Swift. Cuando se introduce estado compartido en un sistema, lo que se introduce no es un dato al que muchos acceden, sino un grafo: el conjunto de aristas que van de cada componente capaz de escribir a cada componente capaz de observar el resultado. La complejidad de razonar sobre ese sistema no crece con el número de features ni con el tamaño del dato, crece con el número de aristas de escritura, porque cada una es una hipótesis más que hay que considerar al preguntarse cómo llegó el estado a ser lo que es. Un dato leído por diez features y escrito por una tiene una única historia posible y se entiende leyendo un archivo. El mismo dato leído por diez y escrito por cuatro tiene un espacio de historias que ya no cabe en la cabeza de nadie, y ninguna cantidad de disciplina personal compensa eso, porque el problema no es que la gente escriba mal sino que el espacio de posibilidades explotó. La contribución de SharedReader es que hace ese grafo visible y verificable sin herramientas externas: cada declaración de un campo compartido es un enunciado comprobado por el compilador sobre si esa feature es o no es un vértice emisor, y la unión de todas las declaraciones del proyecto es el grafo completo, siempre exacto, imposible de desincronizar de la realidad porque es la realidad. Esa es la razón profunda por la que el principio de mínimo privilegio, que uno asocia a permisos de sistemas operativos y a credenciales de servicios, tiene aquí un lugar central: no se trata de defenderse de código malicioso, sino de mantener acotado el número de explicaciones posibles de un valor. Y hay un corolario que conviene llevarse por delante, porque es la bisagra hacia la última lección: si repartir escritura es lo caro, entonces el peligro real del estado compartido nunca fue compartir. Fue compartir la capacidad de escribir sin darse cuenta de que se estaba haciendo.

⚔️ Reduce el grafo de escritura de tu dato compartido
  1. Localiza en tu app un dato compartido y lista todas las features que sostienen la caja escribible. Ese es tu grafo de escritura actual: anota su número de aristas.
  2. Elige la feature que legítimamente posee el dominio del dato y deja solo a ella con la caja. Convierte a todas las demás en SharedReader y compila.
  3. Para cada error de compilación que aparezca, no lo repares devolviendo la escritura: traduce esa mutación a una acción delegate que la dueña reciba y ejecute. Nombra la acción como un hecho del dominio.
  4. Comprueba que las vistas de las features convertidas siguen redibujándose correctamente al escribir desde la dueña, y que sus tests siguen pasando sin cambios en las aserciones de lectura.
  5. Intenta a propósito promover un lector a caja escribible dentro de una feature consumidora. Documenta en dos frases por qué el diseño no te deja y qué habría pasado en el punto tres si te dejara.