State y Action: modelar el dominio con tipos fuertes
En TCA el dominio de una feature se declara con dos tipos: el State como struct de valor —Equatable y anotado con ObservableState— que guarda toda la verdad, y la Action como enum que enumera de forma exhaustiva todo lo que puede ocurrir. Esta lección muestra cómo modelar ambos, por qué el estado es un valor y no un objeto, qué aporta el macro ObservableState frente al viejo WithViewStore, y cómo nombrar las acciones por hechos y no por intenciones. Termina con el arte de hacer imposibles los estados imposibles: usar enums dentro del State para que el compilador prohíba combinaciones que no deberían existir.
Un reducer es una función sobre dos tipos, y la calidad de una feature en TCA se decide antes de escribir una sola línea de lógica: se decide al modelar esos dos tipos. El State es un struct de valor que concentra toda la verdad de la feature, y la Action es un enum que enumera, de forma exhaustiva y cerrada, todo lo que puede ocurrirle. Elegir bien esos tipos es la mitad del trabajo, porque un dominio bien modelado convierte al compilador en tu revisor: los estados que no deberían existir dejan de compilar, y las acciones que olvidaste manejar encienden un switch incompleto. Modelar con tipos fuertes no es adorno académico; es trasladar las reglas del dominio desde tu cabeza al sistema de tipos, donde no se olvidan.
- Modelar el
Statede una feature comostructde valorEquatabley observable. - Explicar qué aporta
@ObservableStatefrente al antiguoWithViewStore. - Diseñar la
Actioncomoenumque nombra hechos, no intenciones vagas. - Usar un
enumdentro delStatepara que los estados imposibles no compilen.
El State: un struct de valor que concentra la verdad
El State de una feature es siempre un struct, nunca una clase, y la elección es de fondo. Un struct es un tipo de valor: cuando el reducer recibe inout State y lo muta, trabaja sobre una copia que Swift administra con copy-on-write, no sobre un objeto compartido que otros observadores pudieran cambiar a tus espaldas. Dos valores de State se comparan por contenido, no por identidad, y por eso el State debe ser Equatable: TCA compara el estado anterior con el siguiente para saber qué vistas refrescar y para que el TestStore afirme diferencias exactas.
@Reducer
struct Perfil {
@ObservableState
struct State: Equatable {
var nombre: String = ""
var edad: Int = 0
var favoritos: [Articulo] = []
var cargando: Bool = false
}
// ...
}
El State guarda solo lo esencial: los datos mínimos desde los que todo lo demás se puede derivar. Lo que se calcula a partir de otros campos no se almacena, se expone como propiedad computada, para que no exista la posibilidad de que dos campos se contradigan.
extension Perfil.State {
var esMayorDeEdad: Bool { edad >= 18 }
}
Esta regla —guardar lo esencial y derivar lo demás— es la misma que viste en la lección de derivación: un valor calculado que se almacena puede quedar obsoleto respecto a su fuente, y esa desincronización es un estado imposible más. Un State mínimo no es solo más elegante; tiene literalmente menos superficie donde algo pueda contradecirse.
@ObservableState: el estado que la vista observa sin ceremonia
Durante años, leer el estado en la vista exigía envolverlo en WithViewStore y un ViewStore, con una closure observe: para limitar los refrescos. Era verboso y fácil de equivocar: si observabas de más, la vista se redibujaba por cambios que no le importaban. Desde TCA 1.7 esa maquinaria la reemplaza un macro, @ObservableState, construido sobre el framework Observation de Swift. Anotas el State con él y la vista puede leer store.nombre directamente; Observation rastrea el acceso campo a campo, así que tocar cargando no invalida una vista que solo leía nombre.
@ObservableState
struct State: Equatable {
var nombre = ""
var contador = 0
}
Ese seguimiento de grano fino es lo que antes conseguías a mano con la closure observe: y ahora obtienes gratis. En iOS 17 y posteriores usa Observation nativo; para iOS 16 y anteriores, TCA incluye una reimplementación llamada Perception que da la misma ergonomía con @Perception.Bindable y un WithPerceptionTracking alrededor del cuerpo de la vista.
Que el State sea Equatable no es un capricho de estilo: es lo que permite a TCA comparar estados para el testeo exhaustivo y lo que sostiene la difusión eficiente de cambios. Si un campo no es Equatable —una closure, por ejemplo— el State entero deja de serlo y pierdes esas garantías. Regla práctica: guarda datos, no comportamiento, en el State.
La Action: un enum que nombra hechos, no intenciones
La Action es un enum porque representa una elección: en cada instante ocurre una acción, no varias, y el enum modela exactamente esa exclusividad. Cada case es un suceso, y la convención de Point-Free en 2026 es nombrarlos por el hecho ocurrido, no por la orden imperativa: no cargarDatos, sino alAparecer; no guardar, sino guardarPulsado; no setDatos, sino datosRecibidos. El reducer decide qué hacer; la acción solo relata qué pasó.
enum Action {
case alAparecer
case incrementarPulsado
case datosRecibidos([Articulo])
case falloDeCarga(String)
}
Los valores asociados transportan la carga del suceso: datosRecibidos([Articulo]) lleva la lista que llegó, falloDeCarga(String) el mensaje del error. El macro @Reducer hace la Action conforme a @CasePathable, lo que habilita rutas de caso como \.incrementarPulsado; el Store y la vista las usan para enviar y observar acciones concretas. En features grandes se agrupan los casos en subenums —acciones de vista, de delegado, internas— para que la superficie no crezca sin orden.
State: un struct de valor
Concentra toda la verdad de la feature, se compara por contenido (Equatable) y se muta sobre una copia con copy-on-write. Guarda datos, nunca comportamiento.
Action: un enum cerrado
Enumera el conjunto finito de sucesos posibles; en cada instante ocurre exactamente uno, y el switch exhaustivo obliga a atenderlos todos sin olvidos silenciosos.
La simetría entre ambos tipos no es casual. El struct es un tipo producto —tiene un nombre y una edad y una lista— mientras que el enum es un tipo suma —está en un caso o en otro—. El State usa el producto para agregar todo lo que coexiste, y la Action usa la suma para expresar que los sucesos son mutuamente excluyentes. Elegir producto donde hay coexistencia y suma donde hay exclusión es, en el fondo, casi todo el arte del modelado de dominios.
Esa misma dualidad reaparece dentro del State: cuando un campo puede estar en fases excluyentes se modela con un enum anidado, un tipo suma incrustado en el tipo producto. El modelado maduro alterna productos y sumas hasta que la forma del tipo calca la forma exacta del dominio.
flowchart TB S[State struct de valor] --> R[reducer] A[Action enum cerrado] --> R R --> S2[State siguiente] R --> E[Effect] style S fill:#a6e3a1,color:#11111b style A fill:#fab387,color:#11111b
Estados imposibles que el compilador rechaza
Aquí está el pago real de modelar con tipos fuertes. Es tentador representar una carga de red con tres campos sueltos, pero eso abre la puerta a combinaciones sin sentido: cargando a true mientras error no es nulo, o datos presentes junto a un error presente. Cuatro booleanos permiten dieciséis combinaciones y la mayoría son estados imposibles que, sin embargo, el tipo permite.
// Frágil: campos sueltos permiten combinaciones que no deberían existir
struct State {
var cargando = false
var datos: [Articulo] = []
var error: String? = nil
}
La solución es modelar la carga como un enum: un valor está en exactamente uno de sus casos, y los estados imposibles simplemente no se pueden expresar.
@ObservableState
struct State: Equatable {
var carga: Carga = .inicial
enum Carga: Equatable {
case inicial
case cargando
case cargado([Articulo])
case fallo(String)
}
}
Ahora el reducer hace switch sobre carga y solo contempla los cuatro casos legítimos, y la vista dibuja uno de cuatro estados sin ramas contradictorias. El compilador dejó de ser un notario que registra tus errores y pasó a ser un guardia que los impide: esa es la esencia de make impossible states impossible, y en TCA se vuelve doblemente valiosa porque el TestStore recorrerá cada caso.
El patrón escala con el dominio: un enum con valores asociados puede anidar el estado específico de cada fase —los datos ya cargados solo existen en el caso cargado, el mensaje de error solo en fallo— de modo que acceder a un dato en la fase equivocada ni siquiera se puede escribir. El tipo se convierte así en una demostración, comprobada por el compilador en cada build, de que ciertos errores del dominio son literalmente imposibles de expresar.
La madurez con TCA se nota en cuánto trabajo haces antes de escribir lógica. Un principiante abre el Reduce y empieza a poner if sobre booleanos; un experto pasa el noventa por ciento del tiempo esculpiendo State y Action hasta que la lógica se vuelve casi obvia. La razón es profunda: cada estado imposible que eliminas del tipo es una rama del reducer que no tienes que escribir, un test que no tienes que redactar y un bug que no puede ocurrir. Un State con cinco booleanos independientes esconde treinta y dos configuraciones, y tú solo querías cuatro; las otras veintiocho son deuda pura, superficie donde anidan los fallos que aparecen en producción y no en tu cabeza. Sustituir esos booleanos por un enum de cuatro casos no es refactor cosmético: colapsa el espacio de estados a lo que el dominio realmente admite, y con él colapsa el espacio de bugs. La Action, por su parte, es el diccionario cerrado de tu feature: si un suceso no está en el enum, no puede pasar, y el switch exhaustivo garantiza que nunca ignoras uno en silencio. Por eso la disciplina de nombrar acciones por hechos y de modelar estados con enums no es etiqueta de buen gusto, es ingeniería preventiva. El reducer que escribes después es solo la sombra proyectada por los tipos que elegiste; si los tipos son precisos, la lógica sale recta, y si son laxos, ninguna cantidad de tests te salvará de los estados que dejaste expresables. Modela el dominio con crueldad y el resto de TCA se vuelve fácil.
- Toma una pantalla de carga con
cargando: Bool,datos: [T]yerror: String?. Enumera cuántas combinaciones permite el tipo y cuántas tienen sentido en tu dominio. - Reemplaza los tres campos por un
enum Cargaconinicial,cargando,cargadoyfallo, cada uno con sus datos asociados. Comprueba que los estados absurdos ya ni siquiera compilan. - Diseña la
Actionde esa feature nombrando cada caso por el hecho ocurrido, no por la orden. Justifica por quédatosRecibidos([T])es mejor nombre quecargarDatos. - Añade una propiedad computada al
Statepara un valor derivado (un total, un texto formateado) y explica por qué guardarlo como campo habría introducido un estado imposible. - Marca el
Statecomo@ObservableStateyEquatable, y describe qué garantía concreta pierdes si un campo no fueseEquatable.