wandres.dev
EFFECT A FONDO · async como valor

No hacer nada, enviar de inmediato y combinar: el álgebra de los efectos

Los efectos son valores, y como valores tienen un álgebra pequeña y precisa: none es el elemento neutro que declara que no queda trabajo, send inyecta una acción de inmediato y está reservado a la conversación entre padre e hijo, merge ejecuta varios efectos a la vez fundiendo sus acciones, y concatenate los encadena en orden estricto. Esta lección explica qué garantiza cada combinador, por qué reutilizar lógica con send es un error clásico, y cómo elegir entre paralelo y secuencia sin dejar efectos inalcanzables.

⏱ 18 min

Si los efectos son valores, entonces se combinan; y si se combinan, hay un álgebra. La de TCA es deliberadamente pequeña: un elemento neutro que significa no queda nada por hacer, un constructor que inyecta una acción de inmediato, y dos combinadores que juntan varios efectos en uno solo, uno en paralelo y otro en secuencia. Cuatro piezas bastan porque cada una expresa una relación temporal distinta entre trabajos, y toda la coreografía asíncrona de una feature se escribe eligiendo bien entre esas relaciones. Conocerlas de memoria no es erudición: es la diferencia entre una pantalla que arranca sus tres suscripciones a la vez y otra que las encadena sin querer y se queda esperando la primera para siempre.

🎯 Al terminar esta lección sabrás
  • Explicar qué declara .none y por qué el compilador te obliga a pronunciarlo en cada rama.
  • Usar .send solo para la conversación entre padre e hijo, y saber por qué no sirve para reutilizar lógica.
  • Elegir entre .merge y .concatenate según la relación temporal que quieras garantizar.
  • Reconocer los efectos inalcanzables que produce encadenar mal y corregirlos.

El neutro: declarar que no queda trabajo

Cada rama de tu switch tiene que devolver un efecto, y la mayoría devuelve .none. No es un relleno sintáctico: es una afirmación con contenido. Dice que la transición terminó dentro del reducer, que el estado ya refleja todo lo que esa acción provoca y que el store no tiene nada que arrancar. Que el compilador te obligue a escribirlo en cada rama es un acierto de diseño, porque convierte en explícita una decisión que en otras arquitecturas se toma por omisión.

case let .nombreEditado(texto):
  state.nombre = texto
  state.valido = !texto.isEmpty
  return .none

.none es además el elemento neutro del álgebra: fusionarlo o encadenarlo con cualquier efecto devuelve ese efecto intacto. Esa propiedad tiene un uso muy práctico cuando construyes efectos condicionalmente, porque te permite escribir la rama vacía sin casos especiales.

var extra: Effect<Action> = .none
if state.debeSincronizar {
  extra = .run { send in
    try await sincronizador.sincronizar()
    await send(.sincronizado)
  }
}
return .merge(.run { send in await send(.listo) }, extra)

.send: la conversación entre padre e hijo

.send(.accion) construye un efecto que emite esa acción de inmediato, sin esperar a nada. Su lugar legítimo es estrecho y conviene decirlo pronto: sirve para que una feature hija le hable a su padre —el patrón de las acciones de delegado— o para que un padre empuje una acción a un hijo tras una presentación. Fuera de esa conversación, casi siempre es la herramienta equivocada.

// En el hijo: la unica forma legitima de hablar hacia arriba
case .guardarPulsado:
  state.guardado = true
  return .send(.delegado(.seGuardo(state.item)))

// En el padre: escucha ese caso y decide que significa para el
case let .editor(.presented(.delegado(.seGuardo(item)))):
  state.items[id: item.id] = item
  state.editor = nil
  return .none

Aquí la vuelta por el store no es un coste sino el mecanismo mismo: la acción de delegado es un hecho real que cruza la frontera entre dos features, y merece aparecer en la traza y en los tests como una acción recibida más. El hijo no sabe qué hará el padre con ese hecho —quizá guardarlo, quizá también cerrar la hoja, quizá nada—, y esa ignorancia es lo que mantiene al hijo reutilizable.

El mal uso clásico es emplearlo para reutilizar lógica: tienes un caso .refrescar que ya hace lo que quieres y desde .alAparecer devuelves .send(.refrescar) para no repetirte. Parece elegante y es peor de lo que parece. Añade una vuelta completa por el store —y por tanto una acción intermedia que aparecerá en los tests y en los registros—, hace la traza más difícil de leer, y acopla dos casos que quizá mañana no quieran hacer exactamente lo mismo. La reutilización correcta es la de siempre: extrae un método que devuelva el efecto y llámalo desde ambas ramas.

private func cargar(_ state: inout State) -> Effect<Action> {
  state.cargando = true
  return .run { send in await send(.respuesta(...)) }
}

case .alAparecer: return cargar(&state)
case .refrescar:  return cargar(&state)
💡
Si dudas si usar send, pregúntate quién habla con quién

La regla operativa cabe en una frase: .send es un mensaje entre dos features, no un atajo dentro de una. Si el emisor y el receptor de la acción son el mismo reducer, no necesitas un efecto, necesitas una función. Si son un padre y un hijo que se comunican por un caso de delegado, entonces sí, .send es exactamente la pieza pensada para eso.

.merge y .concatenate: paralelo y secuencia

Los dos combinadores fusionan varios efectos en uno solo, y se diferencian únicamente en la relación temporal que garantizan. .merge los arranca todos a la vez y funde sus acciones según van llegando, sin orden prometido entre efectos distintos. .concatenate los ejecuta uno tras otro: el segundo no empieza hasta que el primero ha terminado del todo.

flowchart LR
S[Store recibe el Effect combinado] --> MG[merge arranca A y B a la vez]
MG --> AB[acciones entrelazadas segun llegan]
S --> CC[concatenate arranca solo A]
CC --> CB[al terminar A arranca B]
CB --> OR[acciones en orden estricto]
style MG fill:#89b4fa,color:#11111b
style CC fill:#f9e2af,color:#11111b

El caso de uso arquetípico de .merge es la aparición de una pantalla que necesita varias cosas independientes: cargar datos, suscribirse a notificaciones y arrancar un reloj. Nada de eso depende de lo demás, así que encadenarlo sería regalar latencia.

case .alAparecer:
  return .merge(
    .run { send in await send(.datos(try await cliente.cargar())) },
    .run { send in
      for await n in centro.notificaciones() { await send(.notificacion(n)) }
    }
    .cancellable(id: CancelID.notificaciones),
    .run { send in
      for await _ in clock.timer(interval: .seconds(1)) { await send(.tick) }
    }
    .cancellable(id: CancelID.reloj)
  )

.concatenate, en cambio, se reserva para cuando el orden es parte del significado: guardar y solo después recargar, o encadenar pasos de una animación que deben verse uno detrás de otro.

case .guardarYRecargar:
  let borrador = state.borrador
  return .concatenate(
    .run { send in
      try await api.guardar(borrador)
      await send(.guardado)
    },
    .run { send in
      await send(.datos(try await api.cargar()))
    }
  )

Aquí el orden no es una preferencia estética: recargar antes de que el guardado haya terminado devolvería datos viejos y pintaría en pantalla justo lo contrario de lo que el usuario acaba de hacer. Pagas la latencia de la suma porque la alternativa es una condición de carrera con consecuencias visibles. Ambos combinadores existen también como método sobre un efecto, con .merge(with:) y .concatenate(with:), útiles cuando construyes el efecto por acumulación en varias ramas de un condicional.

Pieza Qué declara Relación temporal
.none No queda trabajo pendiente Ninguna, termina aquí
.send Una acción entra ya en el sistema Inmediata, sin espera
.merge Varios trabajos independientes Simultáneos, orden no garantizado
.concatenate Varios trabajos dependientes Estrictamente secuenciales
⚠️
Un efecto de larga vida al principio de un concatenate bloquea a los siguientes

.concatenate espera a que cada efecto termine, y un efecto que itera un stream no termina nunca por su cuenta. Si pones una suscripción como primer elemento, el resto de la cadena es código inalcanzable: nunca se ejecutará, y el síntoma será una funcionalidad que simplemente no aparece, sin error ni aviso. Los efectos de larga vida van siempre en un .merge, nunca al principio de un .concatenate.

Que los efectos formen un álgebra es lo que permite que los reducers formen otra

Es tentador ver .merge y .concatenate como utilidades de conveniencia, y son bastante más que eso: son la razón de que la composición de reducers funcione. Piensa en lo que hace un reducer padre cuando ejecuta al hijo y luego su propia lógica: recibe un efecto del hijo, produce el suyo, y tiene que devolver un solo efecto que represente ambos trabajos. Solo puede hacerlo si los efectos se combinan, y solo puede hacerlo sin casos especiales si existe un elemento neutro que absorba el caso en que uno de los dos no tenía nada que hacer. Dicho con precisión: los efectos forman un monoide con .none como identidad, y ese hecho aparentemente escolar es lo que permite que un Scope, un forEach o un ifLet combinen efectos de decenas de hijos sin que nadie escriba una sola condición. La composabilidad del nivel de arriba descansa sobre la composabilidad del nivel de abajo. Y hay una segunda lectura, más operativa. Elegir entre .merge y .concatenate es elegir qué garantía temporal quieres, y esa es una decisión de diseño que en la programación imperativa se toma sin darse cuenta —el orden de las líneas la decide por ti— y aquí se toma a la vista de todos. Cuando escribes .merge estás afirmando que estos trabajos son independientes y que su orden de llegada no puede afectar a la corrección de tu reducer; cuando escribes .concatenate estás afirmando lo contrario, que hay una dependencia real y que pagarás latencia por respetarla. Ambas afirmaciones son verificables, discutibles y revisables en una revisión de código, que es exactamente lo que no ocurre con un await puesto una línea antes que otro por costumbre. El álgebra no es un adorno matemático: es la forma de hacer explícitas, y por tanto criticables, las decisiones temporales de tu aplicación.

⚔️ Escribe la coreografía con el combinador correcto
  1. Recorre un reducer tuyo y justifica cada .none: ¿de verdad no queda trabajo pendiente en esa rama?
  2. Busca un .send que uses para reutilizar lógica y sustitúyelo por un método privado que devuelva el efecto. Compara los dos tests resultantes.
  3. Escribe un caso .alAparecer que fusione tres efectos independientes con .merge y dale a cada uno su identidad de cancelación.
  4. Convierte un guardado seguido de una recarga en un .concatenate y razona por qué ahí sí quieres pagar la latencia del orden.
  5. Pon a propósito una suscripción de larga vida como primer elemento de un .concatenate, comprueba que el segundo nunca corre, y explica el síntoma que verías en producción.