El operador forEach: un reducer hijo por elemento
Si el reducer es un valor, nada impide multiplicarlo: `forEach` toma un reducer hijo y lo corre sobre cada elemento de una colección identificada, enrutando cada acción al elemento exacto que la emitió. Esta lección disecciona la forma de la acción dirigida —`IdentifiedActionOf` y su caso `element(id:action:)`—, el orden forzado en el que corren hijo y padre, la cancelación automática de los efectos de un elemento cuando ese elemento desaparece, y el patrón de acción delegada con el que una fila pide algo al padre sin conocerlo.
En el Nivel 3 aprendiste que Scope, ifLet y forEach cubren las tres cardinalidades del estado hijo: uno, cero o uno, y cero o muchos. Las dos primeras eran fáciles de imaginar porque hay un solo hijo del que hablar. La tercera es donde la idea de reducer como valor enseña de verdad los dientes: un único Fila() —una instancia, escrita una vez, que no sabe nada de listas— se convierte en el comportamiento de cien filas simultáneas, cada una con su estado, sus efectos en vuelo y su ciclo de vida independiente. forEach es el operador que realiza esa multiplicación, y lo hace apoyándose entero en la identidad que estableciste en la lección anterior. Aquí desmontamos su mecánica: cómo viaja una acción hasta su elemento, en qué orden corren el hijo y el padre, y qué garantías de ciclo de vida vienen incluidas.
- Modelar la acción dirigida por id con
IdentifiedActionOfy leer con soltura su casoelement(id:action:). - Ensamblar
forEachsobre el reducer padre y entender por qué el hijo corre siempre antes que el padre. - Confiar en la cancelación automática de los efectos de un elemento cuando ese elemento sale de la colección.
- Comunicar una fila con su padre mediante acciones delegadas, sin que la fila sepa que vive en una lista.
La acción dirigida: quién habla y qué dice
Para que la acción de una fila llegue a esa fila necesita llevar dos datos: la identidad del emisor y el mensaje. TCA empaqueta ambos en IdentifiedAction, un enum genérico con un caso element(id:action:). La forma abreviada IdentifiedActionOf<Fila> significa IdentifiedAction<Fila.State.ID, Fila.Action>, y es la que verás casi siempre.
@Reducer
struct Lista {
@ObservableState
struct State: Equatable {
var filas: IdentifiedArrayOf<Fila.State> = []
}
enum Action {
case filas(IdentifiedActionOf<Fila>)
}
}
Un solo caso en el Action del padre cubre el diálogo completo con todas las filas presentes y futuras. Cuando la fila con identidad abc emite .textoCambio("hola"), lo que entra por la puerta del padre es .filas(.element(id: abc, action: .textoCambio("hola"))): un sobre con remitente y contenido. Fíjate en la asimetría que esto conserva: la fila emite su acción de siempre, la que emitiría si viviera sola en una preview; es el sistema de enrutado, no la fila, quien añade el remite. La feature hija sigue sin saber que hay una colección.
Ensamblar forEach: la multiplicación del reducer
El operador se aplica como modificador sobre el reducer padre, igual que ifLet, y toma tres cosas: el key path al estado de la colección, el key path de caso a la acción dirigida, y el reducer hijo que hay que multiplicar.
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .filas(.element(id: id, action: .delegate(.pidioBorrarse))):
state.filas.remove(id: id)
return .none
case .filas:
return .none
}
}
.forEach(\.filas, action: \.filas) {
Fila()
}
}
Ese Fila() se escribe una vez y no se instancia por elemento: forEach guarda un solo reducer y lo aplica al estado del elemento que la identidad señale, en tiempo constante gracias al índice interno de IdentifiedArrayOf. Si llega una acción cuyo id ya no existe en la colección —el caso tardío de la lección anterior—, forEach no muta nada y emite un aviso en tiempo de ejecución para que investigues el desajuste en vez de quedarte con un fallo mudo.
Enrutado por identidad
Una acción element(id:action:) alcanza a su elemento en tiempo constante, o a nadie si el elemento ya murió.
Orden hijo y luego padre
El reducer del elemento corre primero. Así el padre puede eliminarlo con seguridad al procesar la misma acción.
Cancelación automática
Los efectos en vuelo de un elemento se cancelan solos cuando el elemento sale de la colección.
Aviso ante id ausente
Una acción dirigida a un elemento inexistente no muta nada y avisa en tiempo de ejecución en vez de fallar en silencio.
forEach fuerza el orden: primero el reducer de la fila, después el Reduce del padre. Si fuera al revés, el padre podría eliminar el elemento al procesar la acción y la fila jamás llegaría a reaccionar a su propio mensaje. Con el orden correcto, el fragmento de arriba funciona: la fila procesa .delegate(.pidioBorrarse) —quizá cancelando algo suyo— y solo entonces el padre la retira de la colección. Ese detalle explica por qué el Reduce del padre se escribe arriba en el body pero se ejecuta después: forEach es un modificador que envuelve, no un hermano en la lista.
Ciclo de vida: efectos que mueren con su elemento
La garantía más valiosa de forEach no es el enrutado sino el desmontaje. Cada efecto que lanza una fila queda internamente asociado a la identidad de esa fila, de modo que en el instante en que el elemento sale de la colección, todos sus efectos en vuelo se cancelan solos: el temporizador para, el stream se cierra, la petición se aborta.
@Reducer
struct Fila {
@ObservableState
struct State: Equatable, Identifiable {
let id: UUID
var segundos = 0
}
enum Action {
case alAparecer
case tick
case delegate(Delegate)
enum Delegate { case pidioBorrarse }
}
@Dependency(\.continuousClock) var clock
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .alAparecer:
return .run { send in
for await _ in self.clock.timer(interval: .seconds(1)) {
await send(.tick)
}
}
case .tick:
state.segundos += 1
return .none
case .delegate:
return .none
}
}
}
}
Cien filas con cien relojes en marcha, y el padre no escribe una sola línea de limpieza: borrar la fila apaga su reloj. Compáralo con el equivalente en un modelo de objetos, donde cada celda mantiene una suscripción que alguien tiene que recordar cancelar en el momento adecuado, y donde el olvido produce esa clase de fuga que solo se manifiesta tras media hora de scroll. Aquí la corrección no depende de la disciplina de quien escribe el padre, sino de la estructura del operador.
Esa asociación entre identidad y ciclo de vida es también lo que hace legible el test de una colección, porque la acción dirigida se escribe igual en la prueba que en la app y la aserción se hace sobre el elemento por su id.
await store.send(.filas(.element(id: id, action: .tick))) {
$0.filas[id: id]?.segundos = 1
}
await store.send(.filas(.element(id: id, action: .delegate(.pidioBorrarse)))) {
$0.filas.remove(id: id)
}
El TestStore exhaustivo del Nivel 3 sigue exigiéndolo todo: si la fila tenía un efecto vivo y el padre la borra, el test pasa solo porque forEach canceló ese efecto; de no hacerlo, la prueba fallaría por acciones pendientes al terminar. La cancelación automática no es únicamente una comodidad de producción: es una afirmación que la suite verifica en cada ejecución.
El caso delegate del ejemplo cierra el círculo de la comunicación hacia arriba. La fila no borra su propia entrada —no puede, no ve la colección— y tampoco llama a nadie: se limita a anunciar una intención. El padre, que sí ve la colección, decide qué significa esa intención. La fila sigue siendo enchufable en cualquier contexto porque su vocabulario no menciona a ninguno.
flowchart TD V[Fila en pantalla emite su accion] --> W[El scope envuelve con el id] W --> P[Accion filas element id accion] P --> H[Reducer de la fila corre primero] H --> D[El padre corre despues y ve toda la lista] D --> R[El padre retira el elemento] R --> C[forEach cancela los efectos de ese id] style H fill:#89b4fa,color:#11111b style C fill:#a6e3a1,color:#11111b
El nombre engaña, porque sugiere un bucle, y un bucle es lo único que aquí no ocurre. forEach no recorre la colección aplicando código a cada elemento; toma un reducer —un valor que describe un comportamiento— y devuelve otro reducer cuyo dominio es la colección entera, con el id operando como el selector que elige a qué instancia del comportamiento corresponde cada mensaje. Es la misma operación que hace un compilador cuando instancia un genérico, o que hace el álgebra cuando eleva una función sobre elementos a una función sobre listas: no se repite trabajo, se cambia de nivel. La consecuencia práctica es que una lista de cien features cuesta lo mismo de escribir que una sola, porque en el código fuente solo hay una; lo que se multiplica es el estado, no la lógica. Y la consecuencia conceptual es más profunda: al asociar la identidad no solo al dato sino al ciclo de vida de sus efectos, forEach convierte cada elemento en un proceso con nacimiento y muerte definidos por su presencia en un array. Borrar una fila deja de ser quitar una entrada de una estructura para pasar a ser terminar una vida: se detiene su reloj, se aborta su petición, se libera su suscripción, y todo eso ocurre por el solo hecho de que un valor salió de una colección. Esa es la razón por la que las listas dinámicas —históricamente el rincón más infestado de fugas, celdas recicladas con datos ajenos y callbacks huérfanos de todo el desarrollo de interfaces— dejan de ser un territorio peligroso: no porque el programador se haya vuelto más cuidadoso, sino porque el ciclo de vida dejó de estar en sus manos y pasó a ser una propiedad del estado.
- Escribe una feature
Filamínima con un reloj que incremente un contador cada segundo, y un casodelegatecon una intención de borrado. - Móntala en un padre con
IdentifiedArrayOfyforEach, y añade una acción del padre que cree filas nuevas con@Dependency(\.uuid). - Arranca cinco filas, deja correr los relojes y borra la del medio. Comprueba con un
printen el efecto que ese reloj concreto deja de emitir y que los otros cuatro siguen. - Invierte mentalmente el orden hijo-padre: describe qué pasaría con la acción de borrado si el padre corriera primero, y por qué el orden que impone
forEachlo evita. - Intenta que la fila se borre a sí misma sin acción delegada. Cuando compruebes que no tiene manera de hacerlo, habrás tocado con el dedo el aislamiento que hace enchufable a la feature.