`var body: some ReducerOf<Self>`: el reducer como valor compuesto
En TCA la lógica de una feature no habita un método sino un valor: `body` devuelve `some ReducerOf<Self>`, es decir, otro reducer que el result builder `@ReducerBuilder` ensambla a partir de la lista sin comas que escribes dentro. Esta lección desmonta las dos puertas del protocolo `Reducer` —implementar `reduce` para escribir una hoja, implementar `body` para componer—, muestra que `Reduce` es la única primitiva que realmente muta el estado, revela qué tipo genérico esconde la palabra `some`, y explica por qué el `body` se reconstruye en cada acción que entra al sistema.
Has visto ya que un reducer se declara con @Reducer y se rellena con un body. Lo que casi nadie interioriza al principio es la consecuencia exacta de esa firma: var body: some ReducerOf<Self> no promete devolver comportamiento, promete devolver otro reducer. La lógica de tu feature no es lo que hay dentro de body; es el valor que body construye y devuelve. Esa distinción —entre el método que reduce y el valor que se compone— es la bisagra sobre la que gira todo este nivel, porque explica por qué puedes apilar reducers sin comas, por qué los operadores encadenados devuelven siempre algo que vuelve a encajar, y por qué existe una primitiva llamada Reduce cuyo único trabajo es cargar con la mutación que ninguna composición sabe hacer.
- Leer
some ReducerOf<Self>como el azúcar desome Reducer<Self.State, Self.Action>y entender qué oculta el tipo opaco. - Distinguir las dos puertas del protocolo: implementar
reducepara una hoja e implementarbodypara una composición. - Usar
Reducecomo la única primitiva que muta el estado y devuelve un efecto dentro de unbody. - Explicar qué hace TCA en tiempo de ejecución con tu
bodyy qué consecuencias de rendimiento se derivan.
Dos puertas al mismo contrato
El protocolo Reducer declara tres tipos asociados —State, Action y Body— y dos requisitos que puedes satisfacer: el método reduce(into:action:) y la propiedad body. La biblioteca da una implementación por defecto de cada uno en términos del otro, y ahí está la elegancia: si escribes body, el reduce por defecto se limita a delegar en él; si escribes reduce, el Body asociado se resuelve como Never —que en TCA conforma a Reducer con la misma trampa con la que SwiftUI lo hace conformar a View— y nadie llega a pedirte un body.
// Puerta 1: la hoja. Escribes comportamiento.
@Reducer
struct Contador {
@ObservableState struct State: Equatable { var cuenta = 0 }
enum Action { case incrementar, decrementar }
func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action {
case .incrementar: state.cuenta += 1; return .none
case .decrementar: state.cuenta -= 1; return .none
}
}
}
// Puerta 2: la composicion. Escribes un valor.
@Reducer
struct Contador {
@ObservableState struct State: Equatable { var cuenta = 0 }
enum Action { case incrementar, 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
}
}
}
}
Las dos versiones son observacionalmente idénticas para el Store, y sin embargo solo la segunda escala: en el momento en que necesites un BindingReducer, un hijo con Scope o un .ifLet, la primera no tiene dónde ponerlos. Por eso la regla práctica es escribir siempre body, y reservar reduce para las hojas que construyes a mano cuando fabricas tus propios operadores. Nunca implementes las dos: si defines body, TCA lo usa y tu reduce queda muerto sin un solo aviso del compilador.
flowchart TD P[protocolo Reducer] --> A[implementas reduce] P --> B[implementas body] A --> H[hoja que escribe la mutacion] B --> C[valor que lista otros reducers] C --> RB[ReducerBuilder ensambla el tipo] RB --> D[reduce por defecto delega en body] D --> H
Reduce: la única hoja que muta
body compone, pero la composición no puede componer la nada: en algún punto del árbol tiene que haber alguien que escriba en el inout State. Ese alguien es Reduce, un struct diminuto que conforma a Reducer y cuyo único contenido es la closure que le pasas. Conviene fijar la ortografía porque la confusión es epidémica: reduce en minúscula es el método del protocolo; Reduce en mayúscula es un tipo, un valor, algo que puedes guardar en una variable y pasar como argumento.
let logicaBase = Reduce<Contador.State, Contador.Action> { state, action in
switch action {
case .incrementar: state.cuenta += 1; return .none
case .decrementar: state.cuenta -= 1; return .none
}
}
var body: some ReducerOf<Self> {
BindingReducer()
logicaBase
}
Que la lógica sea un valor asignable no es una curiosidad sintáctica: es la condición de posibilidad de todo lo demás. Un valor se puede almacenar, devolver desde una función, pasar a un inicializador, envolver en otro tipo. Un método no. Cuando en la lección 4 de este nivel fabriques tus propios reducers reutilizables, lo que estarás fabricando son tipos que reciben otros reducers como argumento, y eso solo funciona porque Reduce y sus hermanos son valores de primera clase.
Existe también EmptyReducer, la hoja que no hace nada y devuelve siempre .none. Parece inútil hasta que la necesitas: es lo que el @ReducerBuilder produce cuando un bloque queda vacío, y es lo que devuelves desde una rama condicional que no debe aportar comportamiento. Su existencia convierte el conjunto de reducers en un monoide —hay una operación de combinación y hay un elemento neutro—, que es la razón algebraica de que la composición nunca tenga casos especiales.
@ReducerBuilder: el tipo que nadie escribe
Cuando listas tres reducers dentro de body sin comas ni operadores, no estás usando una sintaxis mágica: estás alimentando un result builder, @ReducerBuilder, que el protocolo aplica al requisito body. El builder toma tu lista y la transforma en un único valor cuyo tipo es un árbol genérico anidado —tipos internos cuyos nombres empiezan por guion bajo, precisamente para señalar que no debes escribirlos—. Un if sin else produce un Optional de reducer, porque TCA hace conformar Optional a Reducer; un if con else produce un tipo condicional que unifica ambas ramas.
var body: some ReducerOf<Self> {
BindingReducer()
if depuracion {
Reduce { state, action in
state.trazas.append(String(describing: action))
return .none
}
}
Reduce { state, action in ... }
}
Ese if no es una sentencia que se ejecute cuando llega una acción: es una rama que el builder resuelve al construir el valor, y su resultado es un reducer opcional que en un caso aporta comportamiento y en el otro se comporta como EmptyReducer. La distinción vuelve a ser la misma de siempre —construir el árbol frente a recorrerlo— y explica por qué la condición se evalúa una vez por pasada y no una vez por rama del switch.
Ese tipo resultante es enorme, ilegible y perfectamente irrelevante para ti, y por eso la firma dice some. El tipo opaco es un contrato en dos direcciones: hacia fuera garantiza al Store que recibirá algo que sabe reducir State y Action; hacia dentro te libera de nombrar la estructura exacta de tu composición, de modo que añadir un reducer al body no cambia la firma pública de tu feature. Y ReducerOf<Self> es solo abreviatura: some ReducerOf<Self> significa literalmente some Reducer<Self.State, Self.Action>.
Composición estática
El árbol de tipos se resuelve en compilación. No hay tablas de dispatch dinámico ni búsquedas en diccionarios: la cadena de llamadas es especializable e inlineable por el optimizador.
Errores legibles
Cuando el builder falla, el compilador señala el elemento concreto del body que no encaja, no una firma monstruosa. Anotar el tipo de una hoja suele desatascar cualquier mensaje confuso.
Cierre bajo composición
Todo operador toma reducers y devuelve un reducer. Por eso puedes anidar sin límite y el resultado sigue encajando exactamente donde encajaba el original.
Qué ejecuta TCA cuando llega una acción
Aquí conviene ser literal, porque de este detalle se derivan errores de rendimiento reales. La implementación por defecto del protocolo es, en esencia, que reduce(into:action:) devuelve self.body.reduce(into: &state, action: action). Como body es una propiedad computada, eso significa que el árbol de reducers se vuelve a construir en cada acción que entra al Store. Construirlo es barato —son struct diminutos, casi todos vacíos, y el optimizador aplana la mayor parte—, pero deja de serlo si dentro del body haces trabajo real.
// Mal: se ejecuta en CADA accion despachada.
var body: some ReducerOf<Self> {
let formateador = DateFormatter() // caro, y reconstruido cada vez
Reduce { state, action in ... }
}
// Bien: el coste vive fuera del body.
private let formateador = DateFormatter()
var body: some ReducerOf<Self> {
Reduce { state, action in ... } // captura la propiedad ya construida
}
La misma advertencia vale para cualquier cómputo que pongas entre las declaraciones del body: ordenar un array, leer un fichero, instanciar un cliente. El body no es un lugar donde ocurra la lógica, es un lugar donde se describe la lógica, y describir debe ser gratis.
La forma madura de leer var body: some ReducerOf<Self> en 2026 es dejar de verlo como el cuerpo de una función y empezar a verlo como una denotación: una expresión que no hace nada y cuyo único cometido es nombrar un valor que sabe hacer algo. La distinción parece escolástica hasta que observas todo lo que se sigue de ella. Se sigue que la composición sea cerrada, porque si body devolviera comportamiento no habría nada que componer, mientras que devolviendo un valor puedes tomar dos y fabricar un tercero indefinidamente. Se sigue que los operadores existan como métodos encadenables y no como sentencias imperativas, porque un valor admite transformaciones y una ejecución no. Se sigue que el TestStore pueda instrumentar la feature entera sin tocarla, porque lo que recibe es una estructura de datos inspeccionable y no una pila de llamadas ya lanzada. Y se sigue, en negativo, la trampa de rendimiento de esta sección: si body denota, entonces evaluarlo debe ser tan barato como escribir un literal, y todo lo que metas ahí que no sea denotación se pagará una vez por acción durante toda la vida de la app. Es el mismo contrato que SwiftUI estableció con var body: some View —describe el árbol, no lo pintes— trasladado íntegro al dominio del estado. Quien traslada también el hábito mental, deja de preguntarse qué hace su reducer y empieza a preguntarse de qué reducers está hecho; y esa segunda pregunta es la única que escala hasta una app de cincuenta pantallas.
- Toma un reducer tuyo escrito con
bodyy reescríbelo implementandoreduce(into:action:)a mano. Comprueba que compila y que los tests siguen pasando. - Ahora añade un
BindingReducer()a la versión conreduce. Anota el error exacto que te da el compilador y explica por qué la puerta de la hoja no admite composición. - Extrae la closure de tu
Reducea una propiedadprivate letde tipoReduce<State, Action>y vuelve a listarla en elbody. Confirma que un reducer es, literalmente, un valor almacenable. - Mete un
printjusto antes de la primera declaración delbody—fuera de cualquier closure— y despacha diez acciones. Cuenta las líneas impresas y explica el resultado con la implementación por defecto dereduce. - Escribe en una frase la diferencia entre
Reduceyreduce, y guárdala: es el malentendido que más tiempo hace perder en este nivel.