La C de Composable: componer reducers
La palabra del medio en The Composable Architecture no es decorativa: nombra su tesis. Esta lección muestra que en TCA el reducer no es una función suelta sino un valor de primera clase que se compone con otros mediante un puñado de operadores —`Scope` para un hijo que siempre existe, `ifLet` para uno opcional, `forEach` para una colección— y que `body` es, en realidad, la lista declarativa de esas piezas ensambladas por `@ReducerBuilder`. Al final entenderás por qué una feature grande no es un reducer grande sino un ensamblaje de reducers diminutos que jamás se conocen entre sí, y por qué esa ignorancia mutua es exactamente lo que los convierte en piezas Lego.
Cuando Point-Free bautizó su arquitectura, puso el acento en la palabra del medio. Reactividad la tienen todas; la disciplina de mutación unidireccional la heredó de Elm y de Redux, que ya conoces del bloque anterior. Lo que distingue a The Composable Architecture es que la unidad misma de esa disciplina —el reducer— es un valor de primera clase que se compone con otros como se enchufan piezas de Lego. Una feature grande no se escribe como un reducer gigante: se ensambla a partir de reducers diminutos que ni siquiera saben que viven dentro de otro. Esta lección diseca el pegamento que hace posible ese ensamblaje —los operadores Scope, ifLet y forEach— y el body que los lista.
- Entender el reducer como valor de primera clase y
bodycomo una composición declarativa construida por@ReducerBuilder. - Usar
Scopepara incrustar el dominio de un hijo dentro del padre mediante key paths de estado y de acción. - Componer estado opcional con
ifLety colecciones conforEachsin romper el aislamiento de cada feature. - Ver por qué la composición de reducers —y no la herencia ni los singletons— es lo que hace de las features piezas Lego.
El reducer como valor, body como composición
En Redux clásico el reducer es una función desnuda y compones con combineReducers. En TCA el reducer es un protocolo, Reducer, con un State y un Action asociados, y su propiedad body devuelve some ReducerOf<Self>: otro reducer, construido por @ReducerBuilder, un result builder que te deja listar varios reducers para que corran en secuencia sobre el mismo estado y la misma acción. La macro @Reducer genera el andamiaje —conformidad, CasePathable sobre las acciones— y te deja escribir solo lo esencial.
@Reducer
struct Contador {
@ObservableState
struct State: Equatable {
var cuenta = 0
}
enum Action {
case incrementar
case decrementar
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .incrementar:
state.cuenta += 1
return .none
case .decrementar:
state.cuenta -= 1
return .none
}
}
}
}
La sutileza que lo cambia todo: body no es la lógica del reducer, es una composición de reducers. Reduce es la hoja que carga la mutación imperativa —recibe inout State y devuelve un Effect—, pero body puede enumerar varios reducers, y TCA los ejecuta de arriba abajo por cada acción que entra. Esa enumeración es el lugar donde vive la composición: en cuanto tienes más de una hoja en body, estás componiendo.
Scope: incrustar el dominio de un hijo
Cuando una app tiene features, el State del padre incrusta el State del hijo como un campo, y el Action del padre incrusta el Action del hijo como un caso. Scope conecta ambos: toma un key path de escritura hacia el estado del hijo y un key path de caso hacia su acción, y corre el reducer hijo sobre esa rebanada del dominio del padre.
@Reducer
struct AppFeature {
@ObservableState
struct State: Equatable {
var contador = Contador.State()
var perfil = Perfil.State()
}
enum Action {
case contador(Contador.Action)
case perfil(Perfil.Action)
}
var body: some ReducerOf<Self> {
Scope(state: \.contador, action: \.contador) {
Contador()
}
Scope(state: \.perfil, action: \.perfil) {
Perfil()
}
Reduce { state, action in
// coordinación entre hijos, si hiciera falta
return .none
}
}
}
El key path de caso \.contador existe porque @Reducer marca el enum de acciones como @CasePathable, lo que da a cada caso un key path simétrico al de los campos. Fíjate en el orden: los Scope corren primero y despachan cada acción a su hijo; el Reduce final corre después y ve el estado completo de la app, que es el sitio idóneo para la coordinación entre features. Y fíjate en lo que Contador no sabe: ignora por completo que vive dentro de AppFeature. Esa ignorancia deliberada —el hijo jamás alcanza hacia arriba— es la propiedad Lego: un reducer que no conoce su contexto se puede enchufar en cualquier otro.
ifLet y forEach: opcional y colección
Scope sirve para un hijo que siempre existe. Dos operadores más cubren el hijo que puede no existir —opcional— y el que existe muchas veces —colección—. Ambos se aplican como modificadores sobre el reducer padre, no como elementos hermanos.
ifLet gobierna estado opcional, típicamente el de una presentación. Con @Presents var hijo: Hijo.State? y un caso hijo(PresentationAction<Hijo.Action>):
var body: some ReducerOf<Self> {
Reduce { state, action in
// ...
return .none
}
.ifLet(\.$hijo, action: \.hijo) {
Hijo()
}
}
ifLet corre el reducer hijo solo cuando el estado es no nulo y, en el instante en que pasa a nulo, cancela automáticamente los efectos en vuelo de ese hijo. Ese desmontaje automático es una corrección que en Redux tendrías que escribir a mano y olvidarías la mitad de las veces.
forEach gobierna una colección de estados hijos identificados por ID:
@ObservableState
struct State: Equatable {
var filas: IdentifiedArrayOf<Fila.State> = []
}
enum Action {
case filas(IdentifiedActionOf<Fila>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in return .none }
.forEach(\.filas, action: \.filas) {
Fila()
}
}
IdentifiedArrayOf mantiene los elementos direccionables por un ID estable, de modo que una acción enrutada a \.filas viaja acompañada del ID de la fila que le toca; forEach corre el reducer hijo sobre exactamente ese elemento y, como ifLet, cancela los efectos de una fila cuando la fila desaparece.
flowchart TD App[AppFeature body] --> S1[Scope contador] App --> S2[Scope perfil] App --> IL[ifLet hijo opcional] App --> FE[forEach filas] App --> R[Reduce coordinacion] S1 --> C1[Contador reducer] S2 --> C2[Perfil reducer] IL --> D[Hijo reducer solo si existe] FE --> F[Fila reducer por elemento]
La tríada no es arbitraria: cubre las tres cardinalidades del estado hijo. Scope es el hijo con cardinalidad uno —existe siempre—; ifLet es el hijo con cardinalidad cero o uno —existe o no—; forEach es el hijo con cardinalidad cero o muchos —una colección—. Cuando dudes cuál usar, no pienses en la API, pregunta cuántas instancias del hijo pueden coexistir. La respuesta elige el operador sin margen.
La revelación de TCA no es sintáctica sino algebraica: al hacer del reducer un valor con operadores que devuelven otro reducer, la composición deja de ser un patrón de diseño que aplicas con disciplina y pasa a ser una operación cerrada, como sumar números. Scope, ifLet y forEach toman reducers y devuelven reducers, igual que + toma enteros y devuelve un entero; por eso puedes anidarlos sin límite y el resultado sigue siendo, sin más, un reducer que encaja donde encajaría cualquier otro. Compáralo con la vía que casi todo el mundo tomó antes: componer objetos con herencia, protocolos con requisitos, o coordinar view models que se pasan referencias unos a otros. Todas esas vías comparten un vicio —el hijo termina sabiendo algo de su contexto: una superclase, un delegado, un puntero al padre— y ese conocimiento es justo lo que impide moverlo. El reducer de TCA no puede alcanzar hacia arriba porque ni siquiera tiene con qué: solo ve su propio State y su propio Action, y es el padre quien, desde fuera, decide con un key path dónde enchufarlo. Esa asimetría —el padre conoce al hijo, el hijo ignora al padre— es la definición técnica de una pieza Lego, y es la razón por la que la misma feature Contador corre idéntica sola en una preview, dentro de una pestaña, repetida cien veces en una lista o presentada en una hoja modal. No compones porque la arquitectura te lo pida como buena práctica; compones porque la unidad de trabajo es, literalmente, un valor componible, y la aplicación entera no es más que la mayor de esas composiciones.
- Toma una pantalla real con al menos tres zonas independientes —una cabecera, una lista y un panel de detalle, por ejemplo—.
- Para cada zona decide su cardinalidad: ¿existe siempre, existe o no, o existe muchas veces? Anota el operador que le corresponde (
Scope,ifLetoforEach). - Escribe el
Statey elActiondel padre incrustando el estado y las acciones de cada hijo como campo y como caso. - Ensambla el
bodylistando un operador por hijo y unReducefinal vacío para la coordinación futura. Comprueba que ningún hijo referencia al padre. - Verifica la propiedad Lego: intenta arrancar uno de los hijos por sí solo en una preview, sin el padre. Si compila y corre aislado, la composición es correcta.