wandres.dev
MODELAR EL STATE · el dominio como valor

El State como retrato del dominio: qué entra y qué no

El State de una feature no es un cajón donde acumular las variables que fueron haciendo falta: es el retrato completo y mínimo del dominio en un instante, la única fuente de verdad desde la que la vista se dibuja y el reducer decide. Esta lección fija el criterio de admisión —entra todo lo que la interfaz necesita para renderizarse y toda la información que la lógica necesita para decidir— y el criterio de exclusión, bastante más severo: fuera dependencias, closures, referencias a objetos vivos y cualquier cosa que rompa la semántica de valor. Termina en la frontera práctica de las colecciones, donde `IdentifiedArrayOf` sustituye a los índices frágiles, y explica por qué esa disciplina no es purismo sino la condición que sostiene la comparación, la serialización y el testing exhaustivo.

⏱ 18 min

Cuando un pintor hace un retrato no copia al modelo: elige. Decide qué rasgos constituyen a la persona y cuáles son accidentes del momento, y esa elección —no la técnica— es la que hace que el cuadro se parezca. Modelar el State de una feature en TCA es exactamente ese trabajo, y es el trabajo que decide la calidad de todo lo demás. El State no es un almacén donde van cayendo las variables que hicieron falta, sino una afirmación deliberada sobre qué constituye el dominio en un instante: todo lo que la vista necesita para dibujarse y todo lo que el reducer necesita para decidir, ni un campo más. Lo que dejas fuera pesa tanto como lo que metes, porque cada cosa que entra sin derecho se convierte en un invariante que alguien tendrá que mantener a mano, y cada cosa que falta obliga a la vista a inventarse la verdad por su cuenta, que es la forma exacta en que una arquitectura unidireccional se vuelve bidireccional sin que nadie lo anuncie.

🎯 Al terminar esta lección sabrás
  • Fijar el criterio de admisión del State: lo que la vista renderiza y lo que la lógica necesita para decidir.
  • Expulsar del State todo lo que rompe la semántica de valor: dependencias, closures y referencias vivas.
  • Distinguir estado de dominio, estado de interfaz y estado efímero, y ubicar cada uno donde corresponde.
  • Modelar colecciones con identidad estable usando IdentifiedArrayOf en lugar de índices frágiles.

El criterio de admisión: lo que la vista dibuja y la lógica decide

La prueba para admitir un campo en el State tiene dos ramas y basta con que una se cumpla. La primera es de renderizado: si la vista, para dibujarse correctamente en este instante, necesita leer ese dato, entra. La segunda es de decisión: si el reducer, para responder a alguna acción, necesita consultar ese dato, entra también aunque la vista nunca lo muestre —un identificador de sesión, una página consultada, la marca de la última consulta— porque forma parte de la memoria de la feature.

@Reducer
struct Busqueda {
  @ObservableState
  struct State: Equatable {
    var consulta: String = ""
    var resultados: IdentifiedArrayOf<Articulo> = []
    var pagina: Int = 1
    var haySiguientePagina: Bool = false
  }
}

Los cuatro campos pasan la prueba. consulta y resultados los pinta la vista; pagina no se muestra en ninguna parte pero el reducer la necesita para pedir la siguiente tanda; haySiguientePagina alimenta a la vez a la vista, que decide si muestra el botón, y al reducer, que decide si ignora la acción de paginar. Nada de esto es opcional ni accesorio: quitarlo obligaría a que alguien —la vista, un objeto auxiliar, una variable estática— lo recordase fuera del valor, y en ese momento el State dejaría de ser un retrato completo para ser un retrato parcial cuya diferencia con la realidad nadie audita.

La prueba también funciona en negativo, y ahí es donde suele encontrarse la basura acumulada. Un campo que ninguna vista lee y que ningún case del reducer consulta no está sirviendo a nadie: es residuo de una versión anterior de la feature que sobrevivió a un refactor. Merece la pena buscarlo activamente, porque su coste no es cero. Cada campo muerto participa en la comparación de igualdad, aparece en los diferenciales de los tests, hay que inicializarlo en cada constructor de prueba y confunde a quien lea el tipo para entender el dominio. El State es la documentación más leída de una feature, y un campo que ya no significa nada es una frase falsa en esa documentación.

El corolario incómodo es que también entra el estado que solemos considerar de interfaz. Si la hoja está presentada, si el campo de texto tiene el foco, si la fila está seleccionada: todo eso es estado del dominio de la feature en el sentido que importa, porque determina qué se ve y qué acciones son legítimas. TCA no reconoce la distinción cómoda entre estado de negocio y estado de UI; reconoce una sola distinción, entre lo que la feature es y lo que la feature usa.

Hay una prueba mental que resuelve casi todas las dudas, y es la prueba de la restauración: imagina que la aplicación muere y vuelve a arrancar con el State que tenías guardado. Si algo que el usuario reconocería como parte de dónde estaba no se restaura, ese algo faltaba en el State. Si algo se restaura y produce una situación absurda —un foco en un campo que ya no existe, un temporizador reanudado sin contexto—, ese algo entró sin derecho o entró mal modelado. El State correcto es exactamente el que hace de esa reconstrucción una operación sin sorpresas.

Lo que no entra: dependencias, closures y referencias vivas

La regla de exclusión es más corta de enunciar y más difícil de obedecer: el State guarda datos, nunca comportamiento ni identidad compartida. Un cliente de red, una closure de callback, un temporizador, un NSManagedObjectContext, cualquier objeto de clase con ciclo de vida propio: todo eso queda fuera. No por dogma, sino porque ninguna de esas cosas es un valor, y el State de TCA solo funciona si es un valor.

// Incorrecto: el State deja de ser un valor
struct State {
  var articulos: [Articulo] = []
  var api: APIClient
  var alCompletar: () -> Void
}

Ese State no puede ser Equatable —una closure no se compara—, no puede serializarse, no puede duplicarse sin ambigüedad y no puede reconstruirse en un test sin fabricar antes media aplicación. Peor todavía: la referencia a api significa que el State guarda un canal por el que alguien puede provocar efectos sin pasar por una acción, que es precisamente el agujero que la arquitectura unidireccional existe para tapar. Las dependencias se declaran en el reducer con @Dependency, no en el estado; las continuaciones se expresan como acciones futuras, no como closures almacenadas.

El caso de las referencias a objetos de clase merece un párrafo aparte porque es el más sutil. Un objeto dentro de una struct no rompe la sintaxis de valor pero sí su semántica: al copiar el State se copia el puntero, de modo que dos copias que deberían ser independientes comparten el mismo objeto y mutar una muta la otra. Todo el razonamiento que sostiene TCA —que el estado anterior y el siguiente son dos valores distintos que se pueden comparar— se desmorona en silencio, sin error de compilación y sin síntoma inmediato. Es el fallo más caro de diagnosticar porque el tipo sigue diciendo struct mientras se comporta como una clase.

Entra: datos inertes

Valores comparables y copiables: cadenas, números, fechas, enums, structs y colecciones de todo lo anterior. Cosas que se pueden imprimir, serializar y comparar sin ejecutar nada.

🚫

No entra: comportamiento

Clientes de red, closures, delegados, cancelables, objetos de clase mutables. Todo lo que hace algo vive en el reducer como dependencia o como efecto, jamás dentro del valor.

La versión correcta separa el dato del comportamiento y deja cada cosa donde su naturaleza la pone.

@Reducer
struct Listado {
  @ObservableState
  struct State: Equatable {
    var articulos: IdentifiedArrayOf<Articulo> = []
  }
  enum Action {
    case alAparecer
    case articulosRecibidos([Articulo])
  }
  @Dependency(\.apiClient) var apiClient
}

El cliente de red pasó a ser una propiedad del reducer resuelta por el contenedor de dependencias, y la continuación que antes era una closure guardada pasó a ser un caso de la Action. El State recuperó su condición de valor puro: comparable, copiable, imprimible y construible en un test con una sola línea. Esa última propiedad es la que más se nota en la práctica, porque un State que se construye sin fabricar el mundo alrededor es un State sobre el que se puede razonar.

Tres capas de estado, y solo una vive en el State

Conviene tener explícita una clasificación que casi todo el mundo maneja de forma tácita y por eso aplica de forma inconsistente. Hay estado de dominio —lo que la feature sabe y decide—, estado de interfaz —qué está presentado, qué tiene el foco, qué está seleccionado— y estado efímero del sistema de vistas —el desplazamiento de un ScrollView, la animación en curso, el gesto a medio hacer—.

Las dos primeras capas suben al State sin excepciones, y esa es la parte que sorprende a quien viene de arquitecturas donde el estado de UI se considera responsabilidad de la vista. En TCA sube porque el reducer lo consulta: la acción de confirmar un formulario debe poder rechazarse si el campo con foco no está validado, y la acción de tocar una fila debe poder decidir si presenta o no según lo que ya haya presentado. En el momento en que la lógica necesita saberlo, es dominio, se llame como se llame.

La tercera capa no sube, y la frontera es más nítida de lo que parece: si perderlo al recrear la vista no cambia nada que el usuario pueda nombrar, es ruido del sistema de vistas y se queda allí con @State. Un desplazamiento que hay que preservar entre sesiones sí sube, porque el usuario lo nombraría; un desplazamiento que se pierde al rotar el dispositivo y a nadie le importa, no.

El foco es el ejemplo de laboratorio de esta clasificación, y por eso TCA lo trata de forma explícita: se modela como un campo del State de tipo opcional sobre un enum de campos enfocables, precisamente porque es información que el reducer quiere decidir.

@ObservableState
struct State: Equatable {
  var email: String = ""
  var clave: String = ""
  var campoEnfocado: Campo? = nil

  enum Campo: Hashable { case email, clave }
}

Con el foco en el State, la regla al enviar un formulario inválido, enfoca el primer campo con error se escribe en el reducer como una asignación, se testea sin abrir el simulador y se comporta igual en todas las plataformas. Con el foco en la vista, esa misma regla exige un canal de vuelta desde el reducer hacia la interfaz, y ese canal es el principio del desorden que la arquitectura unidireccional quería evitar.

💡
Si dudas, pregúntate quién más necesita saberlo

La duda entre subir un dato al State o dejarlo en la vista se resuelve casi siempre con una pregunta: ¿alguien fuera de esta vista —el reducer, un test, un enlace profundo, otra feature— necesita conocer ese valor? Si la respuesta es sí, sube. Si es no y además su pérdida es invisible, se queda. Lo que nunca funciona es dejarlo en la vista y mantener una copia arriba: eso es duplicar la verdad con más pasos.

La frontera de la identidad: IdentifiedArrayOf

Las colecciones son donde el criterio se pone concreto. Un Array corriente identifica sus elementos por posición, y la posición es un dato inestable: si el reducer recibe una acción del elemento tres y mientras tanto llegó una respuesta de red que reordenó la lista, el índice tres ya apunta a otro. TCA resuelve esto con IdentifiedArrayOf, una colección ordenada cuyo acceso primario es el identificador estable del elemento.

@ObservableState
struct State: Equatable {
  var articulos: IdentifiedArrayOf<Articulo> = []
  var seleccionado: Articulo.ID? = nil
}

Fíjate en seleccionado: guarda un ID, no una copia del artículo ni un índice. Guardar la copia crearía dos verdades que pueden divergir —el artículo de la lista se edita y el seleccionado se queda antiguo— y guardar el índice crearía una referencia que caduca. El identificador es la única forma de apuntar a un elemento que sigue siendo correcta después de insertar, borrar y reordenar. Es la versión modesta y cotidiana de un principio grande: dentro de un valor, la única referencia legítima a otra parte del valor es una clave, nunca un puntero ni una posición.

El mismo razonamiento se extiende a cualquier relación entre partes del State. Un filtro que se refiere a una categoría guarda el identificador de la categoría, no la categoría; un borrador de edición que corresponde a un elemento guarda su identificador junto a los campos editados, no una copia del elemento entero que habría que reconciliar al guardar. Cada vez que un valor aparece en dos sitios del mismo State sin una razón fuerte, has creado un invariante que alguien tendrá que sostener, y la lección siguiente sobre estado derivado es en buena medida la elaboración de esta misma idea.

flowchart LR
D[Dominio real] -->|modelado deliberado| S[State como valor]
S --> V[Vista se dibuja]
S --> R[Reducer decide]
DEP[Dependencias] -.->|fuera del State| R
EF[Efimero de la vista] -.->|se queda en la vista| V
style S fill:#a6e3a1,color:#11111b
style DEP fill:#f38ba8,color:#11111b
El State es una tesis sobre tu dominio, y toda la app es su corolario

Hay una asimetría que conviene aceptar pronto: en TCA el reducer es casi mecánico y el State es casi todo. Un reducer se escribe siguiendo el tipo, se corrige con el compilador y se prueba con el TestStore; un State mal modelado, en cambio, no da error en ninguna parte y se cobra su precio lentamente, en forma de invariantes que hay que recordar, de sincronizaciones manuales entre campos, de vistas que consultan tres cosas para decidir una y de bugs que solo aparecen en la combinación catorce de treinta y dos. Por eso la pregunta correcta al empezar una feature no es qué acciones va a tener, sino qué es esta feature cuando está quieta: qué información, exhaustivamente enumerada, distingue un instante de otro. Si respondes bien, el resto se deduce. Las acciones salen de preguntar qué transiciones existen entre esos instantes; los efectos, de preguntar qué información falta y hay que ir a buscar fuera; la vista, de preguntar cómo se pinta cada instante. Si respondes mal —si metes una dependencia, si duplicas un dato, si dejas fuera algo que la vista necesitaba— el error no se queda en el State: se propaga a las tres capas, porque las tres se derivan de él. El State es la única parte de una feature de TCA que no puedes arreglar después sin reescribir todo lo demás, y es exactamente por eso que merece la parte desproporcionada de tu atención. Retrata el dominio con precisión y el código se vuelve una consecuencia; retrátalo con pereza y estarás programando contra tu propio modelo durante toda la vida de la feature.

⚔️ Somete un State existente al criterio de admisión
  1. Toma el State de una feature real y anota, campo por campo, cuál de las dos ramas del criterio cumple: la vista lo dibuja o el reducer lo consulta. Marca los que no cumplen ninguna.
  2. Busca comportamiento infiltrado: clientes, closures, cancelables, objetos de clase. Muévelos al reducer como @Dependency o exprésalos como acciones, y comprueba que el State recupera la conformidad con Equatable.
  3. Localiza el estado efímero de la vista que subió sin necesidad. Pregúntate si perderlo cambiaría algo que el usuario sepa nombrar; si no, devuélvelo a la vista.
  4. Convierte cualquier Array cuyos elementos tengan identidad en un IdentifiedArrayOf, y sustituye índices y copias almacenadas por identificadores.
  5. Escribe en una sola frase qué es tu feature cuando está quieta. Si necesitas más de una frase o aparece la palabra y tres veces, probablemente tengas dos features juntas.