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

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.

⏱ 19 min

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.

🎯 Al terminar esta lección sabrás
  • Explicar por qué el reducer devuelve un Effect en vez de ejecutar el trabajo.
  • Construir efectos asíncronos con .run y realimentar el resultado con send.
  • Combinar efectos con .none, .merge y .concatenate, y cancelarlos por identidad.
  • Reconocer el Effect como el eco directo de los Cmd y Sub de 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.

⚠️
No hagas trabajo asíncrono con Task fuera del efecto

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.

Separar la descripción de la ejecución es la idea, y no es de TCA: es de las funciones puras

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.

⚔️ Domina el ciclo de vida de un efecto
  1. Escribe un case buscarPulsado que devuelva un .run inyectando un cliente por @Dependency, esperando con try await y reenviando el resultado con await send(.resultados(...)).
  2. Añade el catch: del .run para traducir el fallo en una acción falloDeCarga. Explica por qué el error se convierte en acción y no en excepción.
  3. Convierte tu búsqueda en un debounce: añade .cancellable(id:cancelInFlight:) y un clock.sleep de 300 ms, y razona qué pasa si el usuario teclea rápido.
  4. Escribe una acción pararPulsado que devuelva .cancel(id:) para detener un efecto de larga vida en marcha. Identifica dónde se guarda esa identidad.
  5. Traza la correspondencia con Elm: empareja .run con Cmd, un efecto de larga vida con Sub, y explica qué papel hace el store frente al runtime de Elm.