La forma canónica de una feature
Toda feature de TCA tiene la misma silueta: un struct anotado con la macro, un State anidado que guarda todo lo que la pantalla necesita saber, un Action que enumera de forma cerrada todo lo que puede ocurrirle, y un body que declara de qué reducers se compone. Esta lección fija ese esqueleto mínimo, justifica el orden en que se declaran las piezas, muestra cómo la vista se acopla mediante un Store y recoge las convenciones de nomenclatura que la comunidad consolidó para que las acciones se lean como un registro de sucesos y no como una lista de órdenes. Es la plantilla que repetirás cientos de veces.
Hay arquitecturas que presumen de flexibilidad y hay arquitecturas que presumen de uniformidad. TCA pertenece con orgullo a las segundas: cada feature de una app, sea el contador de una demo o la pantalla de pago de un banco, tiene exactamente la misma silueta. Un struct anotado, un estado anidado, una enumeración cerrada de sucesos y un cuerpo declarativo. Esa monotonía deliberada es una ventaja subestimada: cuando abres un archivo desconocido de un proyecto ajeno sabes de antemano dónde está cada cosa, y cuando escribes una feature nueva no tomas decisiones de estructura, solo de contenido. Esta lección fija esa plantilla y explica por qué cada pieza está donde está.
- Escribir de memoria el esqueleto mínimo de una feature con
State,Actionybody. - Justificar por qué el estado se anida dentro del reducer en lugar de vivir como tipo independiente.
- Conectar una vista de SwiftUI a la feature mediante un
Storey la macro de observación. - Aplicar las convenciones de nomenclatura que hacen que las acciones se lean como hechos y no como órdenes.
El esqueleto mínimo
Reducido a lo imprescindible, el molde tiene cuatro líneas de estructura y todo lo demás es contenido de tu dominio. Memorízalo: escribirás esta forma más veces que cualquier otra cosa en TCA.
import ComposableArchitecture
@Reducer
struct MiFeature {
@ObservableState
struct State: Equatable {
// todo lo que la pantalla necesita saber
}
enum Action {
// todo lo que puede ocurrirle a la pantalla
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
}
}
}
}
Cuatro decisiones están tomadas por ti en ese molde. El tipo es un struct y no una clase, porque un reducer es un valor sin identidad que se copia libremente. State es un struct conforme a Equatable, porque el TestStore compara estados y porque la observación necesita detectar cambios. Action es un enum sin conformidades obligatorias, aunque Equatable te lo pedirán los tests en cuanto los escribas. Y body devuelve some ReducerOf<Self>, azúcar de some Reducer<State, Action>, que es la promesa de devolver algún reducer del mismo dominio sin comprometerte con un tipo concreto.
Por qué el estado vive dentro
Podrías declarar struct MiFeatureState en el ámbito global y referenciarlo. Compilaría, pero perderías tres cosas. La primera es el espacio de nombres: al anidar, MiFeature.State y Otra.State conviven sin colisión y sin prefijos artificiales. La segunda es la inferencia: la macro localiza los tipos por nombre dentro del tipo, como vimos en la lección anterior, y fuera de él no los encuentra. La tercera es cognitiva y es la más importante: la feature se vuelve una unidad legible de arriba abajo, donde el dominio completo cabe en una pantalla del editor.
@Reducer
struct Perfil {
@ObservableState
struct State: Equatable {
var nombre = ""
var cargando = false
var error: String?
}
enum Action {
case vistaAparecio
case perfilRecibido(Result<String, any Error>)
case reintentarPulsado
}
@Dependency(\.clienteAPI) var clienteAPI
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .vistaAparecio, .reintentarPulsado:
state.cargando = true
state.error = nil
return .run { send in
await send(.perfilRecibido(Result { try await clienteAPI.perfil() }))
}
case let .perfilRecibido(.success(nombre)):
state.nombre = nombre
state.cargando = false
return .none
case let .perfilRecibido(.failure(fallo)):
state.error = fallo.localizedDescription
state.cargando = false
return .none
}
}
}
}
Fíjate en el orden de declaración, que es una convención estable en toda la comunidad: primero State, luego Action, luego las dependencias con @Dependency, y body al final. La razón es de lectura: los dos primeros bloques definen el vocabulario del dominio, las dependencias declaran de qué mundo exterior depende, y body es la única parte con lógica. Quien abre el archivo lee la interfaz antes que la implementación.
La vista al otro lado del Store
La feature es solo la mitad. La otra mitad es una vista que sostiene un StoreOf y lee estado directamente de él, sin capa intermedia, gracias a la observación que instala @ObservableState.
struct PerfilView: View {
let store: StoreOf<Perfil>
var body: some View {
Form {
if store.cargando {
ProgressView()
} else {
Text(store.nombre)
}
if let error = store.error {
Text(error).foregroundStyle(.red)
Button("Reintentar") { store.send(.reintentarPulsado) }
}
}
.onAppear { store.send(.vistaAparecio) }
}
}
Dos gestos y ningún tercero: se lee estado con acceso directo por punto, store.cargando, y se envían acciones con store.send. No hay observadores manuales, no hay WithViewStore como en versiones antiguas, no hay funciones intermedias. La vista es una proyección del estado y un emisor de sucesos, exactamente eso y nada más.
flowchart LR V[Vista SwiftUI] -->|send accion| S[Store] S -->|entrega accion| R[Reducer body] R -->|muta| E[State] R -->|devuelve| F[Effect] E -->|observacion| V F -.nuevas acciones.-> S
Nombrar acciones como hechos
La convención más rentable de TCA no está en la sintaxis sino en el vocabulario. Un caso de Action no debe nombrar una orden sino un hecho ocurrido. La diferencia parece cosmética y es estructural.
| Nombre desaconsejado | Nombre canónico | Por qué |
|---|---|---|
cargarPerfil |
vistaAparecio |
Describe el suceso de la interfaz, no la reacción que decides darle |
mostrarError |
perfilRecibido |
El error es una consecuencia que decide el reducer, no un mandato de la vista |
setNombre |
campoNombreCambiado |
La vista informa de un cambio; escribir el estado es competencia del reducer |
La razón profunda es que si la acción nombra la orden, la decisión ya se tomó fuera del reducer y la vista pasó a ser lógica de negocio disfrazada. Si la acción nombra el hecho, todas las decisiones quedan concentradas en un único switch auditable. Cuando una lista de acciones se lee como un registro cronológico de lo que pasó, puedes reproducir una sesión entera leyéndola; cuando se lee como una lista de órdenes, has repartido la lógica.
En features grandes, un enum plano de veinte casos mezcla pulsaciones del usuario, respuestas de red y avisos de hijos. La convención madura es anidar enums por origen: un caso vista con las interacciones, un caso interna con las respuestas de efectos y un caso delegado con lo que esta feature comunica hacia arriba. Esa partición hace explícito qué acciones puede enviar una vista y cuáles son estrictamente internas.
Cuesta apreciar el valor de que todas las features tengan la misma forma hasta que trabajas en un proyecto donde no la tienen. En una base de código MVVM madura, cada view model es una negociación irrepetible: uno expone propiedades publicadas y otro un sujeto de Combine, uno inyecta dependencias por inicializador y otro tira de un singleton, uno guarda estado en propiedades sueltas y otro en una estructura anidada. Ninguna de esas decisiones es incorrecta por separado y el conjunto es, sin embargo, ingobernable, porque leer una pantalla nueva exige reconstruir desde cero las convenciones privadas de quien la escribió. La forma canónica de TCA elimina esa negociación al hacerla imposible: el estado va donde va, las acciones van donde van, la lógica está en un único switch y las dependencias se declaran en el mismo sitio siempre. Lo que ganas no es elegancia sino algo mucho más prosaico y mucho más caro de obtener por otras vías: un límite superior al coste de entender código ajeno, que deja de crecer con el tamaño del equipo. Por eso una app TCA de cincuenta features no es cincuenta veces más difícil que una de una; es una feature entendida cincuenta veces, con contenido distinto y estructura idéntica. Y por eso la plantilla de esta lección no es un consejo de estilo que puedas adaptar a tu gusto: adaptarla es exactamente perder la propiedad que la hace valiosa.
- Sin mirar la lección, escribe de memoria el esqueleto completo de un
@Reducer struct AjustesconState,Actionybody. Compáralo después con el molde y anota qué olvidaste. - Modela una pantalla de inicio de sesión:
Statecon correo, contraseña, indicador de envío y error opcional;Actioncon los cambios de campo, la pulsación de envío y la respuesta. - Revisa tus nombres de acciones y reescribe todos los que suenen a orden hasta que cada caso nombre un hecho ocurrido.
- Escribe la vista de SwiftUI correspondiente leyendo el estado por acceso directo y enviando acciones con
store.send, sin ninguna propiedad de estado local. - Divide el enum de acciones en
vista,internaydelegadoy observa qué casos quedan en cada grupo. Si alguno no encaja en ninguno, pregúntate si pertenece a esta feature.