wandres.dev
MODELAR EL STATE · el dominio como valor

Estado derivado: computar en vez de duplicar

Todo dato que se puede calcular a partir de otro y aun así se almacena es una copia que puede quedar obsoleta, y la desincronización entre una fuente y su copia es una de las familias de bug más persistentes que existen. Esta lección establece la disciplina del State normalizado: guardar únicamente el conjunto mínimo de datos independientes y exponer todo lo demás como propiedad computada, de modo que la coherencia deje de ser una obligación del reducer y pase a ser una propiedad del tipo. Se estudian los casos límite —cálculos caros, cachés legítimas con `@ObservationStateIgnored`, validación derivada— y se explica por qué una propiedad computada no participa en `Equatable` ni en el seguimiento de observación, que es justamente lo que la hace segura.

⏱ 17 min

Hay una clase de bug que no se arregla nunca del todo: el del dato que se quedó viejo. El total no cuadra con las líneas, el contador del badge dice tres y la lista tiene dos, el botón sigue deshabilitado aunque el formulario ya es válido. Cada uno de esos fallos tiene la misma causa estructural y no es un descuido puntual: alguien guardó como campo algo que era una función de otros campos, y a partir de ahí la coherencia dejó de estar garantizada por el tipo y pasó a depender de que todas las mutaciones futuras se acuerden de actualizar las dos cosas. La disciplina que corrige esto es de una sencillez casi ofensiva y por eso se subestima: en el State se guarda lo que es independiente y se computa lo que se deduce. No es una optimización de memoria ni un gusto por la elegancia funcional; es la decisión de que una verdad tenga un solo sitio donde vivir, para que no pueda contradecirse consigo misma.

🎯 Al terminar esta lección sabrás
  • Distinguir el dato independiente, que se almacena, del dato derivado, que se computa.
  • Escribir propiedades computadas en el State y entender por qué no rompen Equatable ni la observación.
  • Reconocer la desincronización como consecuencia inevitable de duplicar una verdad en dos campos.
  • Decidir con criterio los casos límite: cálculos caros, cachés explícitas y validación derivada.

La forma normal de un State

La pregunta para cada campo es única: ¿existe alguna otra combinación de campos a partir de la cual este valor se pueda deducir? Si la respuesta es sí, el campo sobra. Un State en el que ningún campo es deducible de los demás está en algo parecido a una forma normal, y esa propiedad tiene la consecuencia práctica de que cualquier asignación a cualquier campo deja el valor entero coherente.

// Frágil: dos verdades para el mismo hecho
@ObservableState
struct State: Equatable {
  var lineas: IdentifiedArrayOf<Linea> = []
  var total: Decimal = 0
  var hayLineas: Bool = false
}

Aquí total y hayLineas son funciones de lineas, pero el tipo no lo sabe. Cada vez que alguien inserte, borre o edite una línea tendrá que recordar recalcular dos campos más, y el día que aparezca una ruta de mutación nueva —un borrado en lote, una sincronización desde red, una restauración— la probabilidad de que alguien olvide uno de los dos es alta. Nada fallará ruidosamente: simplemente el total mostrará una cifra que no corresponde a lo que el usuario ve.

@ObservableState
struct State: Equatable {
  var lineas: IdentifiedArrayOf<Linea> = []

  var total: Decimal { lineas.reduce(into: 0) { $0 += $1.importe } }
  var hayLineas: Bool { !lineas.isEmpty }
}

Ahora hay una sola verdad. total no puede quedar obsoleto porque no existe hasta que alguien lo pide, y cuando lo pide se calcula sobre las líneas actuales. El reducer se simplifica de forma notable: donde antes había tres asignaciones por mutación ahora hay una, y donde antes había un invariante implícito ahora no hay ninguno que mantener. La coherencia dejó de ser una tarea y pasó a ser una imposibilidad de fallar.

Conviene subrayar de dónde viene el ahorro, porque no es donde parece. No se ahorra memoria —un Decimal no ocupa nada relevante— ni líneas de código, que salen parecidas. Se ahorra superficie de obligación: el número de sitios del programa que tienen que hacer algo correcto para que el modelo siga siendo coherente. Con el campo almacenado, esa superficie es todo el código de mutación presente y futuro; con la propiedad computada, es cero. Y las superficies de obligación son la magnitud que de verdad predice cuántos bugs tendrá un sistema a los dos años, porque crecen con cada persona que entra en el equipo sin haber leído el comentario que explicaba el invariante.

Hay un test mental útil para detectar la duplicación cuando no salta a la vista: escribe una función que, dado el State, devuelva verdadero si es coherente. Si esa función tiene algo que comprobar, tienes datos duplicados. Un State bien normalizado hace esa función trivialmente verdadera, porque no existe ninguna combinación de sus campos que se contradiga.

Por qué computar es seguro en TCA

Una propiedad computada no es un campo almacenado, y esa diferencia técnica es exactamente la que la hace inofensiva dentro de la maquinaria de TCA. La conformidad sintetizada de Equatable compara únicamente las propiedades almacenadas, así que añadir diez propiedades computadas no encarece ni una sola comparación de estado. El seguimiento de Observation funciona igual: registra el acceso al almacenamiento subyacente, de modo que una vista que lee store.total queda suscrita en realidad a lineas, que es justo la dependencia correcta. Y el TestStore no exige afirmar nada sobre valores derivados, porque al afirmar la mutación de la fuente el derivado queda implicado.

await store.send(.lineaAnadida(linea)) {
  $0.lineas.append(linea)
}

Esa única aserción cubre también el total y el indicador de lista no vacía. Con el modelo duplicado habrían hecho falta tres, y —lo que es peor— un test que solo afirmase dos de las tres seguiría pasando mientras la tercera se quedaba obsoleta. El estado derivado no solo elimina bugs: elimina la posibilidad de escribir un test incompleto sobre ellos, porque no hay nada incompleto que afirmar.

🧬

Almacenado: lo independiente

Entra en Equatable, entra en la observación, entra en la serialización y hay que mutarlo explícitamente en el reducer. Resérvalo para lo que no se deduce de nada.

🔎

Computado: lo deducido

No ocupa espacio en el valor, no participa en la comparación y no puede desincronizarse. Es una vista de lectura sobre la fuente, recalculada cada vez que se consulta.

Hay un límite que conviene conocer: una propiedad computada no puede leerse desde una vinculación de escritura sin más, porque no tiene dónde escribir. Cuando un control necesita un valor derivado y escribible —un campo de texto que edita una parte de un dato compuesto— el derivado deja de ser una propiedad computada de solo lectura y pasa a necesitar también un set que traduzca la escritura hacia la fuente. Ese set es legítimo y sigue sin duplicar nada, porque no almacena: transforma.

Conviene señalar dónde vive el estado derivado. Ponerlo en el State como propiedad computada, y no en la vista como cálculo local, tiene una ventaja concreta: el reducer también puede consultarlo. Una regla de negocio como no se puede confirmar un pedido vacío se escribe una sola vez, se lee igual desde la vista para deshabilitar el botón y desde el reducer para ignorar la acción, y no puede divergir entre ambas capas. La propiedad computada es, en ese sentido, el sitio natural del vocabulario del dominio.

extension Pedido.State {
  var puedeConfirmarse: Bool { !lineas.isEmpty && direccion != nil }
}
case .confirmarPulsado:
  guard state.puedeConfirmarse else { return .none }
  state.envio = .enviando
  return .run { [pedido = state.lineas] send in
    await send(.respuesta(Result { try await api.confirmar(pedido) }))
  }

El guard y el modificador disabled de la vista consultan la misma propiedad, así que no pueden discrepar. Y observa que la comprobación en el reducer no sobra aunque la vista deshabilite el botón: la acción puede llegar por otras vías —un enlace profundo, un atajo, un test— y el reducer es el único lugar donde la regla se aplica de verdad. La vista solo la refleja.

💡
Escribe las reglas del dominio como propiedades computadas, no como condiciones dispersas

En cuanto una condición aparece en dos sitios —la vista y el reducer, o dos ramas del reducer— conviértela en propiedad computada con un nombre del dominio. El beneficio inmediato es que deja de poder divergir; el beneficio duradero es que el State acaba exponiendo el idioma del negocio, y leer la extensión del State se convierte en la forma más rápida de entender qué hace la feature.

Los casos límite: cuándo sí conviene almacenar

La regla no es absoluta y fingir lo contrario sería deshonesto. Una propiedad computada se recalcula en cada acceso, y una vista de SwiftUI puede acceder muchas veces durante un mismo ciclo de renderizado. Si el cálculo es lineal sobre unas decenas de elementos, el coste es despreciable frente al del propio renderizado. Si es una ordenación, un filtrado o una agregación sobre miles de elementos que además se consulta en cada fila de una lista, deja de serlo.

Cuando midas —y la palabra clave es midas, no supongas— que un derivado es caro, la salida correcta no es esparcir el recálculo por el reducer, sino almacenar una caché explícita, mantenerla en un único punto de mutación y dejar claro en el nombre que es una caché.

@ObservableState
struct State: Equatable {
  var articulos: IdentifiedArrayOf<Articulo> = []
  var filtro: Filtro = .todos

  @ObservationStateIgnored
  private var visiblesCache: IdentifiedArrayOf<Articulo>?

  var visibles: IdentifiedArrayOf<Articulo> {
    visiblesCache ?? articulos.filter(filtro.admite)
  }

  mutating func recalcularVisibles() {
    visiblesCache = articulos.filter(filtro.admite)
  }
}

El patrón tiene un precio que hay que aceptar con los ojos abiertos: reintroduce el invariante que habíamos eliminado, ahora concentrado en un único método mutating al que el reducer debe llamar tras tocar articulos o filtro. La diferencia con el modelo frágil del principio es de grado, pero es decisiva: el invariante está localizado, nombrado y auditable, en vez de repartido por todas las ramas. Y @ObservationStateIgnored mantiene la caché fuera del seguimiento de observación para que su relleno no dispare redibujados espurios.

Antes de llegar ahí hay dos salidas que casi siempre son mejores y que la gente se salta con demasiada prisa. La primera es derivar más arriba: si el derivado caro se consulta una vez por pantalla y no una vez por fila, calcúlalo una sola vez en el cuerpo de la vista y pásalo hacia abajo, en lugar de que cada subvista lo vuelva a pedir. La segunda es cambiar la estructura de datos en vez de cachear su resultado: si lo caro era buscar linealmente por un atributo, guarda la colección indexada por ese atributo y la búsqueda deja de ser cara sin necesidad de caché ni de invariante.

⚠️
Una caché de derivado es un campo duplicado con mejor reputación

No te engañes sobre lo que estás haciendo: al almacenar visiblesCache has vuelto a tener dos verdades sobre lo mismo. Todo lo que esta lección dice sobre desincronización sigue aplicándose, solo que ahora el riesgo está acotado a un método. Si añades una segunda caché, o si el método hay que llamarlo desde más de tres ramas del reducer, has cruzado la línea en la que el remedio empieza a costar más que la enfermedad.

flowchart TB
F[Fuente unica en el State] --> C1[Derivado computado]
F --> C2[Regla de dominio computada]
C1 --> V[Vista]
C2 --> V
C2 --> R[Reducer decide]
F -.->|solo si es caro y medido| K[Cache explicita con invariante local]
style F fill:#a6e3a1,color:#11111b
style K fill:#fab387,color:#11111b
Un dato duplicado es una mentira esperando su turno

La razón profunda por la que esta lección importa más de lo que su sencillez sugiere es que la duplicación de estado no falla el día que se introduce. Cuando alguien añade el campo total junto a lineas, el código funciona: el reducer que existe en ese momento actualiza los dos y todos los tests pasan. El fallo llega meses después, por una vía que nadie previó, cuando un camino de mutación nuevo —una migración, un caso de restauración, una respuesta de red que llega tarde, una rama de error que hace un rollback parcial— toca la fuente y no la copia. Para entonces nadie recuerda que existía el invariante, el síntoma aparece lejos de la causa y la investigación termina en el sitio equivocado. Esta es la firma de los bugs caros: no son los que rompen ruidosamente, sino los que instalan una contradicción silenciosa que el sistema arrastra hasta que se manifiesta como un número raro en una pantalla. La disciplina del estado derivado ataca el problema en el único punto donde es barato atacarlo, que es antes de que exista: si el derivado nunca se almacena, no hay camino de mutación futuro capaz de desincronizarlo, porque no hay nada que sincronizar. Fíjate en la asimetría de esfuerzo. Mantener coherentes dos campos exige vigilancia en cada línea que se escriba de aquí en adelante, para siempre, por parte de gente que aún no ha entrado en el equipo. Computar el derivado exige una decisión, una vez. Esa asimetría —trabajo perpetuo distribuido frente a decisión única concentrada— es la misma que atraviesa todo el modelado de estado en TCA, y es la razón por la que un State mínimo no es solo más limpio: es estructuralmente más barato de mantener durante toda la vida del producto.

⚔️ Normaliza el State de una feature
  1. Recorre el State de una feature real y marca cada campo que se pueda deducir de otros. Anota cuántas rutas de mutación distintas tendrían que mantenerlo al día.
  2. Convierte cada uno en propiedad computada dentro de una extensión del State. Comprueba que el reducer se acorta y que ningún test deja de pasar.
  3. Busca condiciones repetidas entre la vista y el reducer. Extráelas como propiedades computadas con nombre del dominio y elimina las copias.
  4. Mide de verdad el coste de tu derivado más pesado con una colección realista. Solo si el número te preocupa, introduce una caché explícita con @ObservationStateIgnored y un único método mutating que la rellene.
  5. Escribe en el encabezado de esa caché el invariante exacto que hay que respetar. Si no puedes enunciarlo en una línea, la caché es demasiado ambiciosa y conviene volver a computar.