Effect: el trabajo asíncrono como valor devuelto
El reducer es puro y síncrono, así que no puede ejecutar trabajo asíncrono: lo describe. El Effect es ese valor devuelto —una descripción de trabajo que el runtime ejecutará por ti— y su constructor central es run, que abre un contexto async donde puedes esperar y reenviar el resultado como nuevas acciones con send. Esta lección explica por qué el reducer nunca ejecuta el efecto, el álgebra de efectos (none, run, send, merge, concatenate), la cancelación por identidad con cancellable y cancel, y cómo todo esto es el eco directo de los Cmd de Elm: la impureza empujada fuera del núcleo puro hacia un runtime que la orquesta.
Un reducer es puro y síncrono; una llamada a la red no cabe dentro de él. La respuesta de TCA a esa tensión es tan elegante como la de Elm: el reducer no ejecuta el trabajo asíncrono, lo devuelve como un valor. Ese valor es el Effect, una descripción de trabajo pendiente que el reducer entrega al runtime de la arquitectura para que lo ejecute por su cuenta y, cuando termine, realimente el sistema con nuevas acciones. El reducer, así, nunca toca el mundo: solo dice qué habría que hacer. Es el mismo truco que permite que una función pura conviva con la red, el disco y el reloj sin dejar de ser pura, porque la impureza no ocurre dentro de ella, sino después, fuera, en un lugar que la arquitectura controla y sabe reproducir.
- Explicar por qué el reducer devuelve un
Effecten vez de ejecutar el trabajo. - Construir efectos asíncronos con
.runy realimentar el resultado consend. - Combinar efectos con
.none,.mergey.concatenate, y cancelarlos por identidad. - Reconocer el
Effectcomo el eco directo de losCmdySubde The Elm Architecture.
El reducer describe, el runtime ejecuta
La distinción entre describir y ejecutar es el corazón de esta lección. Cuando el reducer termina, devuelve un Effect; ese valor no ha hecho nada todavía. Es el Store —el runtime que verás en la lección 5— quien lo recibe, lo ejecuta fuera del reducer y canaliza de vuelta cada acción que el efecto produzca. Devolver .none significa entregar una descripción vacía: la transición terminó y no hay trabajo pendiente. Devolver un .run significa entregar una tarea que el runtime arrancará por ti.
case .actualizarPulsado:
state.cargando = true
return .run { send in
let items = try await cliente.cargarArticulos()
await send(.datosRecibidos(items))
}
Fíjate en la coreografía: el reducer muta el estado de forma síncrona —state.cargando = true— y acto seguido devuelve la descripción de lo asíncrono. La llamada a la red no ocurre en la línea del return; ocurre más tarde, cuando el runtime ejecute la closure. El reducer ya habrá acabado, puro e intacto.
.run: la asincronía como valor de primera clase
.run es el constructor central de efectos. Abre un contexto async respaldado por una Task que el store gestiona, y te entrega un send de tipo Send<Action> que es invocable: await send(.loQueSea) empuja una nueva acción al sistema. Dentro de ese contexto puedes esperar cualquier cosa, iterar sobre un stream y emitir varias acciones. El manejo de errores va en el catch final del propio .run, para que un fallo también se traduzca en una acción del dominio en vez de en una excepción perdida.
return .run { send in
let items = try await cliente.cargarArticulos()
await send(.datosRecibidos(items))
} catch: { error, send in
await send(.falloDeCarga(error.localizedDescription))
}
Lo importante es que dentro de .run nunca aparecen dependencias globales: el cliente que se usa aquí llegó inyectado por @Dependency (lección 4), de modo que en un test puedes sustituirlo por una versión que devuelve datos fijos sin tocar la red. El efecto sigue siendo, por tanto, tan controlable como el reducer que lo devolvió. Además, send admite animar el cambio resultante con await send(.datosRecibidos(items), animation: .default).
Como .run puede emitir varias acciones a lo largo de su vida, es también el hogar natural de los streams: un efecto que escucha eventos —notificaciones del sistema, mensajes de un socket, posiciones del GPS— itera con for await y hace send en cada elemento, viviendo tanto como el runtime se lo permita.
case .empezarAEscuchar:
return .run { send in
for await evento in centro.eventos() {
await send(.llegoEvento(evento))
}
}
.cancellable(id: CancelID.escucha)
Ese único constructor —una closure async con un send— cubre así todo el espectro de lo asíncrono: desde una petición puntual que emite una acción y termina, hasta una suscripción perpetua que emite miles. La diferencia no está en el tipo de efecto, sino en si la closure retorna o se queda iterando.
El álgebra de efectos: combinar y cancelar
Los efectos son valores, y como tales se combinan con un pequeño álgebra. .none es el efecto neutro. .merge(a, b) ejecuta varios efectos en paralelo y funde sus acciones. .concatenate(a, b) los ejecuta en orden, cada uno tras terminar el anterior. Y .send(.accion) inyecta una acción de forma síncrona —útil sobre todo para que un padre avise a un hijo, no para lógica cotidiana—. La pieza que convierte todo esto en algo robusto es la cancelación por identidad: un efecto de larga vida —un temporizador, una búsqueda, un stream— se marca con .cancellable(id:) y se detiene desde otra acción con .cancel(id:).
.none
El efecto neutro: la transición terminó y no queda trabajo pendiente. Es el retorno más frecuente de un reducer.
.run
Abre un contexto async con un send invocable. Es la única puerta legítima a la red, el disco, el reloj y los streams.
.merge y .concatenate
Combinadores de efectos: merge corre varios en paralelo y funde sus acciones; concatenate, en secuencia estricta, uno tras otro.
.cancellable
Identidad y ciclo de vida: marca un efecto con un id y cancélalo —o reemplázalo en vuelo— desde otra acción.
Combinar es tan cotidiano como devolver un efecto suelto. Al aparecer una pantalla puedes arrancar a la vez la carga de datos y un reloj que tictaquea, cada uno con su propia identidad para poder cancelarlos por separado cuando la vista desaparezca.
case .alAparecer:
return .merge(
.run { send in
await send(.datosRecibidos(try await cliente.cargarArticulos()))
},
.run { send in
for await _ in clock.timer(interval: .seconds(1)) {
await send(.tick)
}
}
.cancellable(id: CancelID.reloj)
)
case .desaparecer:
return .cancel(id: CancelID.reloj)
La cancelación no es un lujo opcional: un efecto de larga vida que nadie corta es una fuga de tareas que sobrevive a la pantalla que la abrió. Por eso todo .run que itera un stream se marca con una identidad y se cancela cuando la feature deja de necesitarlo.
enum CancelID { case busqueda }
case let .consultaCambiada(texto):
state.consulta = texto
return .run { send in
try await clock.sleep(for: .milliseconds(300))
let r = try await cliente.buscar(texto)
await send(.resultados(r))
}
.cancellable(id: CancelID.busqueda, cancelInFlight: true)
Ese cancelInFlight: true es el debounce idiomático de TCA: cada nueva pulsación cancela el efecto anterior con la misma identidad, así que solo la última consulta —tras 300 ms de calma— llega a la red. La identidad de un efecto es una propiedad tan real como su contenido, y gestionarla bien es lo que separa una feature que fuga tareas de una que las controla.
flowchart LR R[reducer puro] -->|devuelve Effect| RT[runtime del store] RT -->|ejecuta fuera del reducer| W[mundo exterior red y reloj] W -->|resultado| RT RT -->|send de nuevas acciones| R style R fill:#cba6f7,color:#11111b style RT fill:#89b4fa,color:#11111b
El eco de los Cmd de Elm
Nada de esto es nuevo: es Elm, traducido a Swift con concurrencia estructurada. En The Elm Architecture (Nivel 25), update devuelve una pareja (Model, Cmd Msg), donde Cmd es una descripción de efectos que el runtime de Elm ejecuta para producir nuevos Msg. El Effect de TCA es exactamente ese Cmd: una descripción inerte que un runtime convierte en trabajo y realimenta como acciones. Los efectos de larga vida —observar notificaciones, un reloj que tictaquea— son el equivalente de las Sub (suscripciones) de Elm. La línea genealógica es limpia: Elm inventó separar la descripción de la ejecución, Redux la reflejó con middleware y thunks, y TCA la lleva a su forma más pura sobre async/await, con cancelación estructurada y dependencias inyectables que Elm resolvía con su runtime cerrado.
La tentación del recién llegado es lanzar un Task { } suelto dentro del reducer para hacer la llamada de red y mutar el estado desde ahí. Eso rompe TCA por completo: el trabajo escapa al control del store, no se puede cancelar por identidad, no aparece en el TestStore y el reducer deja de ser puro. Todo lo asíncrono ha de salir por el Effect devuelto, sin excepción; esa es la única puerta legítima al mundo.
El Effect parece una herramienta de TCA y en realidad es una idea mucho más vieja y más grande: la única forma conocida de que una función pura coexista con un mundo impuro es que la función no actúe sobre el mundo, sino que devuelva un plan y deje que otro lo ejecute. Elm lo llamó Cmd, Haskell lo llamó IO, Redux lo aproximó con middleware, y TCA lo llama Effect; son cuatro nombres para el mismo hallazgo. La potencia de ese hallazgo es que traza una frontera nítida entre decidir y hacer: el reducer decide, en un mundo de valores puros, testeable y reproducible; el runtime hace, en el mundo real, una sola vez y en un solo sitio. Todo lo difícil —la red que falla, el reloj que avanza, la tarea que hay que cancelar cuando el usuario cambia de pantalla— queda confinado tras esa frontera, y confinar la dificultad en un punto es lo que la hace tratable. Por eso el Effect no es asíncrono ejecutándose, es asíncrono descrito: un valor que puedes inspeccionar, combinar con merge y concatenate, cancelar por identidad y sustituir entero en un test sin que el reducer se entere. Cuando interiorizas que devolver trabajo es distinto de hacerlo, dejas de ver TCA como una librería con reglas raras y empiezas a verlo como la consecuencia inevitable de querer, a la vez, funciones puras y aplicaciones que hablan con el mundo. No se puede tener lo uno sin describir lo otro.
- Escribe un
case buscarPulsadoque devuelva un.runinyectando un cliente por@Dependency, esperando contry awaity reenviando el resultado conawait send(.resultados(...)). - Añade el
catch:del.runpara traducir el fallo en una acciónfalloDeCarga. Explica por qué el error se convierte en acción y no en excepción. - Convierte tu búsqueda en un debounce: añade
.cancellable(id:cancelInFlight:)y unclock.sleepde 300 ms, y razona qué pasa si el usuario teclea rápido. - Escribe una acción
pararPulsadoque devuelva.cancel(id:)para detener un efecto de larga vida en marcha. Identifica dónde se guarda esa identidad. - Traza la correspondencia con Elm: empareja
.runconCmd, un efecto de larga vida conSub, y explica qué papel hace el store frente al runtime de Elm.