wandres.dev
LA MACRO @REDUCER · el boilerplate desaparece

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.

⏱ 17 min

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á.

🎯 Al terminar esta lección sabrás
  • Escribir de memoria el esqueleto mínimo de una feature con State, Action y body.
  • 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 Store y 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.

💡
Divide las acciones por origen cuando la feature crece

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.

La uniformidad es la propiedad que permite escalar sin arquitecto

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.

⚔️ Escribe la plantilla hasta que salga sin pensar
  1. Sin mirar la lección, escribe de memoria el esqueleto completo de un @Reducer struct Ajustes con State, Action y body. Compáralo después con el molde y anota qué olvidaste.
  2. Modela una pantalla de inicio de sesión: State con correo, contraseña, indicador de envío y error opcional; Action con los cambios de campo, la pulsación de envío y la respuesta.
  3. Revisa tus nombres de acciones y reescribe todos los que suenen a orden hasta que cada caso nombre un hecho ocurrido.
  4. Escribe la vista de SwiftUI correspondiente leyendo el estado por acceso directo y enviando acciones con store.send, sin ninguna propiedad de estado local.
  5. Divide el enum de acciones en vista, interna y delegado y observa qué casos quedan en cada grupo. Si alguno no encaja en ninguno, pregúntate si pertenece a esta feature.