wandres.dev
REDUCERS Y EFECTOS · State, Action, Effect

El reducer: la función pura que devuelve un efecto

El reducer es el corazón de TCA: una función pura que recibe el estado actual y una acción, muta una copia del estado y devuelve un Effect que describe el trabajo asíncrono sin ejecutarlo. Esta lección presenta el macro Reducer y su propiedad body, la diferencia entre componer con body e implementar el método reduce, y lee la firma de estado y acción que devuelve un efecto como la ecuación que resume toda la arquitectura. Cierra justificando por qué la pureza del reducer no es un dogma académico, sino la condición técnica del testeo exhaustivo, la composición de features y la reproducibilidad del estado.

⏱ 18 min

En The Composable Architecture todo converge en una sola idea: el Reducer. No es una clase que hace cosas ni un objeto con métodos que mutan el mundo a su antojo; es una función pura que recibe el estado actual y una acción, escribe el estado siguiente sobre una copia mutable, y devuelve un Effect que describe —sin ejecutarlo todavía— cualquier trabajo asíncrono pendiente. Esa firma, (inout State, Action) -> Effect<Action>, es la ecuación entera del patrón: comprime en una línea la herencia de Elm y de Redux traducida al sistema de tipos de Swift. Aprender TCA es, antes que nada, entender por qué esa función tiene exactamente esa forma y ni una responsabilidad de más.

🎯 Al terminar esta lección sabrás
  • Declarar un Reducer con el macro @Reducer y su propiedad body.
  • Distinguir cuándo componer con body y cuándo implementar el método base reduce.
  • Leer la firma (inout State, Action) -> Effect como una ecuación de transición de estado.
  • Justificar la pureza del reducer como condición del testeo, la composición y la reproducibilidad.

Del protocolo ReducerProtocol al macro @Reducer

Cuando Point-Free reescribió TCA en 2022, un reducer era un tipo que conformaba el protocolo ReducerProtocol: declarabas un struct, asociabas State y Action, e implementabas un método a mano con typealias explícitos. Desde 2023 —y ya consolidado en la TCA de 2026— esa ceremonia la absorbe un macro: @Reducer. Anotas el struct de la feature con @Reducer y el macro sintetiza la conformidad, deduce los tipos asociados a partir de tus State y Action anidados, y de paso hace Action conforme a CasePathable para habilitar las rutas de caso que la navegación y el Store necesitarán después.

import ComposableArchitecture

@Reducer
struct Contador {
  @ObservableState
  struct State: Equatable {
    var cuenta = 0
  }

  enum Action {
    case incrementarPulsado
    case decrementarPulsado
  }

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .incrementarPulsado:
        state.cuenta += 1
        return .none
      case .decrementarPulsado:
        state.cuenta -= 1
        return .none
      }
    }
  }
}

Tres piezas merecen nombre. some ReducerOf<Self> es azúcar de some Reducer<State, Action>: el body promete devolver algún reducer cuyo estado y acción son los de esta feature. Reduce { } es el reducer primitivo que envuelve tu closure de lógica; es la hoja del árbol. Y return .none declara, explícitamente, que esa transición no dispara ningún efecto: el estado cambió y no hay nada asíncrono que hacer.

body frente a reduce: dos caras de la misma verdad

Hay dos maneras de dar cuerpo a un reducer, y confundirlas es el tropiezo inicial más común. La primera es implementar la propiedad body, exactamente como en SwiftUI implementas el body de una View: no describes qué hace el reducer paso a paso, sino de qué otros reducers se compone. La segunda es implementar directamente el método reduce(into:action:), la operación primitiva que efectivamente muta el estado y devuelve el efecto.

@Reducer
struct Contador {
  @ObservableState struct State: Equatable { var cuenta = 0 }
  enum Action { case incrementarPulsado, decrementarPulsado }

  func reduce(into state: inout State, action: Action) -> Effect<Action> {
    switch action {
    case .incrementarPulsado:
      state.cuenta += 1
      return .none
    case .decrementarPulsado:
      state.cuenta -= 1
      return .none
    }
  }
}

La regla es simple: implementa reduce cuando escribes la lógica base de una hoja, y usa body cuando quieres orquestar y componer reducers —el propio Reduce, un BindingReducer, hijos con Scope o ifLet—. No implementes ambos: si defines body, TCA lo usa e ignora reduce. El paralelo con la View de SwiftUI no es casual, es deliberado: body es el lenguaje de la composición, y componer features enteras a partir de reducers pequeños es la C de Composable, el asunto del Nivel 28.

Un vistazo adelantado deja clara la diferencia. Cuando body compone, se limita a listar reducers y TCA los ejecuta en orden sobre el mismo estado y la misma acción, sin pegamento manual entre ellos.

var body: some ReducerOf<Self> {
  BindingReducer()
  Reduce { state, action in
    // la lógica propia de esta feature
    return .none
  }
  .onChange(of: \.filtro) { _, _ in
    Reduce { state, _ in
      state.recalcular()
      return .none
    }
  }
}

Aquí conviven tres reducers —BindingReducer, tu Reduce y un reductor disparado por onChange— sin una sola coma que los una, porque el @ReducerBuilder los combina por ti. Nada de esto se puede expresar implementando reduce a mano, y esa es exactamente la frontera entre ambas formas: reduce escribe comportamiento, body ensambla comportamientos.

ℹ️
El result builder que hay bajo body

body no es una closure normal: está gobernado por @ReducerBuilder, un result builder gemelo de @ViewBuilder. Por eso puedes listar varios reducers dentro de body sin comas ni operadores y TCA los combina en orden. Ese detalle, invisible al principio, es lo que hará que BindingReducer() y un Reduce convivan apilados sin pegamento manual cuando lleguen los bindings.

La firma es una ecuación, no una función cualquiera

Detente en func reduce(into state: inout State, action: Action) -> Effect<Action>, porque cada fragmento es una decisión de diseño. El inout State te deja escribir mutaciones aparentes —state.cuenta += 1— sobre un tipo de valor; como State es un struct, esa mutación no toca ningún objeto compartido, sino una copia que Swift gestiona con copy-on-write. Escribes en estilo imperativo, pero el efecto neto es una transformación pura de un valor de entrada en un valor de salida, igual que el update : Msg -> Model -> Model de Elm, solo que con inout por pura ergonomía.

El segundo parámetro, action, es el conjunto cerrado de todo lo que puede ocurrirle a esta feature: cada case del enum es un suceso posible y el switch te obliga a contemplarlos todos. Y el tipo de retorno, Effect<Action>, es lo que distingue a TCA de un reducer de Redux clásico: la función no hace el trabajo asíncrono, devuelve un valor que lo describe y que, al terminar, volverá a alimentar el sistema con nuevas acciones.

flowchart LR
A[accion] --> R[reducer puro]
S0[estado actual] --> R
R --> S1[estado siguiente]
R --> E[Effect devuelto]
E -.produce nuevas acciones.-> A
style R fill:#cba6f7,color:#11111b
style E fill:#f9e2af,color:#11111b

Leída así, la firma es una ecuación de recurrencia: el estado siguiente y una descripción de efectos son función del estado actual y una acción, y esos efectos realimentan acciones que vuelven a entrar en la misma función. Todo el comportamiento de la app es la iteración de esa ecuación.

Por qué la pureza es la condición de todo lo demás

El precepto más estricto de TCA es que el reducer sea puro: nada de llamadas a la red, nada de leer el reloj del sistema, nada de UUID() ni de números aleatorios, nada de I/O dentro del cuerpo. Toda impureza sale del reducer por una única puerta —el Effect que devuelve— y entra por otra única puerta —las dependencias inyectadas, que verás en la lección 4—. Esta disciplina parece purismo académico y es exactamente lo contrario: es lo que compra las propiedades que hacen valioso a TCA.

🧪

Testeo exhaustivo

Si el reducer es puro, un test es trivial: das un estado y una acción, y afirmas el estado resultante. El TestStore del Nivel 28 exige que declares cada mutación y cada efecto, o falla.

Reproducibilidad

Estado inmutable más acciones serializables producen una historia reproducible: la misma secuencia de acciones da siempre el mismo estado, base del time-travel que TCA hereda de Elm y Redux.

🧩

Composición

Una función pura sin efectos ocultos se compone con otra sin sorpresas. Reducers pequeños se ensamblan en features grandes porque ninguno esconde dependencias en su interior.

Un reducer que hiciera try await URLSession.shared.data(from:) en su cuerpo rompería las tres propiedades de golpe: sería no determinista, imposible de reproducir e intestable sin una red real. Por eso TCA no te pide que muevas la impureza al Effect: te lo impone haciendo que el cuerpo del reducer sea síncrono y que la única forma de tocar el mundo sea devolver un efecto. La restricción, pagada por adelantado, es la que habilita todo lo demás.

El reducer es una ecuación, y esa es toda la tesis de TCA

La forma madura de ver el Reducer en 2026 es dejar de leerlo como código que se ejecuta y empezar a leerlo como una ecuación que se cumple. (inout State, Action) -> Effect no describe un procedimiento, describe una relación: dado un estado y una acción, existe un estado siguiente y una descripción de efectos, y nada más. Todo lo que hace especial a TCA —el testeo que agota cada rama, la reproducción de una sesión a partir de su lista de acciones, la composición de features sin fugas— no son funcionalidades que la librería añada por encima, sino consecuencias que emergen solas de que esa ecuación se respete sin excepciones. El día que metes una llamada de red o una lectura de reloj dentro del cuerpo, la ecuación deja de cumplirse: el mismo estado y la misma acción ya no producen el mismo resultado, y con esa grieta se derrumban a la vez la testeabilidad, la reproducibilidad y la composición. Por eso el reducer no puede hacer trabajo asíncrono, solo devolverlo como valor; no puede leer el mundo, solo recibirlo inyectado. La aparente rigidez —un cuerpo síncrono y puro que solo muta un inout y retorna un Effect— no es una limitación que toleras a cambio de las virtudes de TCA: es, literalmente, la misma cosa que esas virtudes, vista desde el otro lado. Entender esto es dejar de pelear con la arquitectura y empezar a apoyarte en ella.

⚔️ Escribe tu primer reducer y demuéstrate su pureza
  1. Crea un @Reducer struct Temporizador con un State que tenga var segundos = 0 y una Action con los casos arrancarPulsado, pararPulsado y tick.
  2. Implementa el cuerpo con Reduce: en tick incrementa segundos y devuelve .none; deja arrancarPulsado y pararPulsado devolviendo .none de momento.
  3. Reescribe la misma lógica implementando func reduce(into:action:) en vez de body. Comprueba que compila igual y explica por escrito cuál preferirías al componer y por qué.
  4. Intenta —solo para verlo fallar mentalmente— poner un try await Task.sleep dentro del case tick. Nombra las tres propiedades que romperías y por qué el cuerpo síncrono del reducer te obliga a sacarlo a un Effect.
  5. Dibuja la firma (inout State, Action) -> Effect y marca sobre ella por dónde entra la impureza (dependencias) y por dónde sale (el efecto devuelto). Guarda ese esquema: es el mapa de las tres lecciones siguientes.