El efecto como valor devuelto: describir el trabajo en vez de hacerlo
La firma del reducer es la tesis entera de TCA: recibe el estado como inout, recibe una acción y devuelve un Effect. No devuelve nada hecho, devuelve un plan. Esta lección lee esa firma con lupa, separa con precisión la descripción de la ejecución, sitúa al Store como el único runtime autorizado a tocar el mundo, y enumera lo que esa pureza compra —determinismo, tests que son demostraciones, cancelación estructurada y dependencias sustituibles—, además de nombrar los síntomas de haber roto el contrato.
Hay una sola línea en TCA que contiene toda su filosofía, y es la firma del reducer: recibe el estado como inout, recibe una acción y devuelve un Effect<Action>. No devuelve Void, no devuelve una promesa a la que engancharse, no devuelve el resultado de la red: devuelve una descripción del trabajo pendiente. Esa elección de tipo es la que separa a TCA de casi todo lo que has escrito antes, porque convierte lo que en otras arquitecturas es una llamada —lanza la petición, arranca el temporizador, escribe en disco— en un valor que existe, que se puede guardar, combinar, cancelar y sustituir entero, y que todavía no ha hecho absolutamente nada. El reducer no actúa sobre el mundo: redacta un encargo y lo firma.
- Leer la firma del reducer y explicar por qué devolver un
Effectno es lo mismo que ejecutar trabajo. - Distinguir la descripción de un efecto de su ejecución, y saber quién ejecuta cada cosa.
- Enumerar lo que la pureza del reducer compra: determinismo, testabilidad, cancelación y sustitución.
- Reconocer los síntomas de haber roto el contrato y devolver el trabajo al lugar que le corresponde.
La firma es la tesis
Quítale las macros al reducer y queda el contrato desnudo, que cabe en una línea. Esa línea dice tres cosas: que el estado se muta en el sitio a través de un parámetro inout, que la acción es la única entrada de información del exterior, y que la salida no es un resultado sino un Effect parametrizado por el mismo tipo de acción que entró.
func reduce(into state: inout State, action: Action) -> Effect<Action>
Que el Effect esté parametrizado por Action y no por un tipo de resultado cualquiera es la pieza que cierra el círculo: lo único que un efecto puede devolverle al sistema son acciones, es decir, hechos ya nombrados en el dominio. No hay canal lateral, no hay retorno directo, no hay callback que escriba en el estado a escondidas. Si algo del mundo exterior quiere influir en tu aplicación, tiene que hacerlo pronunciando una de las palabras que tú declaraste en el enum Action.
@Reducer
struct Perfil {
@ObservableState
struct State: Equatable {
var nombre = ""
var cargando = false
}
enum Action {
case aparecer
case perfilRecibido(Usuario)
}
@Dependency(\.api) var api
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .aparecer:
state.cargando = true
return .run { send in
await send(.perfilRecibido(try await api.perfil()))
}
case let .perfilRecibido(usuario):
state.cargando = false
state.nombre = usuario.nombre
return .none
}
}
}
}
Observa la coreografía del primer caso. La mutación —state.cargando = true— es síncrona y ocurre ya. La red no ocurre: se describe. En la línea del return no viaja ni un byte; se construye un valor que envuelve una closure que algún día alguien ejecutará. Cuando el reducer termina, el trabajo asíncrono sigue sin haber empezado, y el estado ya refleja que está a punto de empezar. Esa distancia de tiempo entre declarar que voy a hacerlo y hacerlo es exactamente el espacio donde vive la testabilidad de TCA.
La mutación con inout puede parecer una traición a la pureza, y no lo es: mutar un parámetro inout de un tipo de valor es indistinguible, desde fuera, de recibir un estado y devolver otro nuevo; Swift solo te ahorra la copia. La pureza que importa aquí es la otra: el reducer no observa ni altera nada fuera de sus argumentos. No lee el reloj, no toca la red, no consulta una variable global. Por eso, dados el mismo estado y la misma acción, produce siempre el mismo estado y el mismo efecto.
Describir no es ejecutar
El Effect que devuelves es un valor inerte. Por dentro no es más que una envoltura sobre una operación que puede ser vacía, una closure asíncrona o un publicador de Combine; por fuera es algo que puedes pasar por parámetro, guardar en una variable, combinar con otros y devolver desde una función. Quien lo despierta es el Store: recibe la acción, corre el reducer, se queda con el efecto devuelto, lo envuelve en una Task que él administra y canaliza de vuelta hacia el propio reducer cada acción que ese efecto emita.
flowchart LR A[Action] --> R[reducer puro muta el State] R -->|devuelve un Effect inerte| S[Store el unico runtime] S -->|arranca una Task fuera del reducer| M[Mundo red disco reloj] M -->|nuevas acciones| A style R fill:#cba6f7,color:#11111b style S fill:#89b4fa,color:#11111b
De esa asimetría se derivan consecuencias muy concretas. El store sabe cuántos efectos hay vivos, porque los arrancó él; puede cancelarlos por identidad, porque guarda la Task; puede esperarlos en un test, porque conoce su ciclo de vida; y puede, si el estado de una feature hija se anula, matar de golpe todo lo que esa hija había lanzado. Nada de eso sería posible si el reducer hubiera ejecutado el trabajo por su cuenta, porque entonces el trabajo no sería de nadie.
Que el efecto sea un valor no es una metáfora: se comporta como tal en todos los sentidos que importan. Puedes construirlo en un método aparte y devolverlo, guardarlo en una propiedad, meterlo en un array o elegir entre dos con un condicional, y nada de eso ejecuta absolutamente nada.
private func cargarPerfil() -> Effect<Action> {
.run { send in
await send(.perfilRecibido(try await api.perfil()))
}
}
private var pulsoDeReloj: Effect<Action> {
.run { send in
for await _ in clock.timer(interval: .seconds(60)) {
await send(.minutoCumplido)
}
}
}
Al leer esas dos definiciones no ocurre ninguna petición y no arranca ningún reloj; solo se han fabricado dos descripciones que esperan a que alguien las devuelva desde un reducer. Esta es también la razón de que la reutilización en TCA se haga extrayendo métodos que devuelven efectos y no reenviando acciones: un método es composición de descripciones, y reenviar una acción es dar una vuelta entera por el runtime.
Lo que compra la pureza
La disciplina de devolver en vez de hacer no es un ejercicio de estética funcional: es una compra, y conviene saber qué te llevas por ese precio.
Determinismo
El mismo par de estado y acción produce siempre el mismo estado y el mismo efecto. La aleatoriedad, el reloj y la red viven fuera, tras la frontera del efecto, y por tanto son sustituibles.
Tests que demuestran
El TestStore compara estados enteros y consume efectos uno a uno. Si el reducer hiciera el trabajo, no habría nada que consumir ni forma de saber si quedó algo pendiente.
Ciclo de vida
Un efecto que el store arrancó es un efecto que el store puede cancelar. Un Task suelto dentro del reducer es una tarea huérfana que sobrevive a la pantalla que la creó.
Sustitución total
Como el trabajo se describe con dependencias inyectadas, cambiar la implementación entera del mundo exterior en un test es reasignar unos valores, no reescribir la feature.
Hay además una ganancia menos evidente y quizá más profunda: la reproducibilidad. Si toda influencia del exterior entra por el enum Action, entonces la secuencia de acciones que un usuario provocó es un registro completo de su sesión. Reproducir un fallo deja de ser adivinar qué pasó y pasa a ser volver a alimentar el reducer con la misma lista de acciones desde el mismo estado inicial. Eso solo funciona mientras nadie mute el estado por un camino que no sea una acción.
Esa propiedad tiene una manifestación cotidiana que conviene usar desde el primer día. El operador de depuración imprime cada acción recibida junto al diferencial exacto del estado que provocó, y leerlo es leer la historia de tu aplicación contada por ella misma.
var body: some ReducerOf<Self> {
Reduce { state, action in
// ...
}
._printChanges()
}
Si esa traza contiene todo lo que pasó, es porque nada pasó fuera de ella. En una arquitectura donde el trabajo se hace dentro de la vista o del modelo de vista, la traza equivalente estaría llena de agujeros: cambios de estado sin acción que los explique, provocados por una closure que se resolvió en algún hilo. Aquí no hay agujeros por construcción, y esa ausencia de agujeros es lo que hace del registro una herramienta de diagnóstico y no una curiosidad.
Primero: aparece un Task suelto dentro del reducer y el estado se muta desde dentro de él; el trabajo escapa al store, no se puede cancelar y el TestStore no lo ve. Segundo: el reducer lee algo que no son sus argumentos —Date(), UUID(), UserDefaults—; el determinismo muere ahí, y con él los tests. Tercero: alguien guarda una referencia al estado o al store dentro de una closure para escribir en él más tarde; eso reintroduce por la puerta de atrás la mutación compartida que la arquitectura entera existía para prohibir. Los tres se arreglan igual: lo asíncrono sale por el efecto devuelto, y lo no determinista entra por @Dependency.
El Effect parece una invención de TCA y es en realidad el redescubrimiento de una idea vieja y muy general: una función pura no puede actuar sobre el mundo, pero sí puede describir la acción y dejar que otro la ejecute. Haskell lo llamó IO, Elm lo llamó Cmd, Redux lo aproximó con middleware, TCA lo llama Effect; son cuatro nombres para el mismo hallazgo. Lo que hace potente al hallazgo no es la pureza en sí, que es un medio, sino la frontera que dibuja: a un lado queda el decidir, en un mundo de valores donde todo es comparable, copiable, serializable y reproducible; al otro queda el hacer, en el mundo real, sucio y una sola vez, concentrado en un único componente que sabe arrancar tareas y matarlas. Toda la dificultad de una aplicación —la red que falla a medias, el reloj que avanza mientras esperas, la pantalla que desaparece con tres peticiones en vuelo— queda confinada tras esa frontera, y confinar la dificultad en un punto es precisamente lo que la vuelve tratable, porque un problema que aparece en un sitio se resuelve una vez, mientras que uno que aparece en cincuenta se resuelve cincuenta veces mal. De ahí que el Effect no sea asíncrono ejecutándose sino asíncrono descrito: un valor que puedes almacenar, fusionar, encadenar, cancelar por identidad y reemplazar entero en un test sin que el reducer se entere de nada. Cuando interiorizas que devolver trabajo es categóricamente distinto de hacerlo, TCA deja de parecer una librería con reglas caprichosas y empieza a parecer lo que es: la consecuencia inevitable de querer, a la vez, funciones puras y una aplicación que habla con el mundo. No se puede tener lo uno sin describir lo otro.
- Escribe la firma del reducer sin macros y explica, término a término, qué garantiza cada parte: el
inout, la acción de entrada y elEffect<Action>de salida. - Toma una función de tu código que hoy llame a la red y mute una propiedad al volver. Sepárala en dos: una mutación síncrona y un efecto devuelto.
- Justifica por qué el
Effectestá parametrizado porActiony no por un tipo de resultado. ¿Qué se rompería si pudiera devolver unUsuariodirectamente? - Busca en un proyecto tuyo los tres síntomas del callout de aviso. Por cada uno, escribe la corrección concreta.
- Argumenta por qué mutar un
inoutno rompe la pureza que aquí importa, y qué sí la rompería. Usa el criterio de “no observar nada fuera de los argumentos”.