El orden importa: quién ve la acción primero
Los reducers listados en un `body` no son un conjunto sino una secuencia: TCA los ejecuta de arriba abajo sobre el mismo `inout State`, ninguno puede detener la propagación y cada uno ve el estado tal como se lo dejó el anterior. Esta lección establece la semántica exacta de ese recorrido, demuestra el bug canónico de colocar el `Reduce` del padre antes del `Scope` del hijo, explica por qué `ifLet`, `ifCaseLet` y `forEach` fijan el orden hijo primero sin dejarte opción, y separa el orden de las mutaciones —que es secuencial y observable— del de los efectos, que se combinan con `merge` y corren en paralelo.
Un body que lista tres reducers parece una declaración desordenada, como si dijera «esta feature está hecha de estas tres piezas». No lo es. La lista es una secuencia estricta, y la posición de cada elemento decide qué versión del estado ve, si sus mutaciones son visibles para los demás y si llega a tiempo de reaccionar antes de que otro le quite el suelo. La mayoría de los bugs desconcertantes de una feature madura en TCA —el padre que lee un valor viejo, el hijo que nunca se entera de su propia acción, el binding que no ha llegado a escribirse— no son bugs de lógica: son bugs de orden. Esta lección convierte esa intuición vaga en una regla operativa que puedes aplicar sin dudar.
- Describir la semántica exacta de la ejecución de un
body: recorrido secuencial, estado compartido y ausencia de propagación cancelable. - Diagnosticar el bug canónico del padre que corre antes que el hijo y leer estado obsoleto.
- Recordar qué órdenes fija la biblioteca por ti en
ifLet,ifCaseLetyforEach, y cuáles decides tú. - Separar el orden de las mutaciones del de los efectos, que se combinan con
mergey no respetan la lista.
Una acción, un recorrido de arriba abajo
Cuando el Store recibe una acción, llama una sola vez al reduce de tu feature, y ese reduce recorre los elementos del body en el orden en que los escribiste. No hay filtrado previo, no hay despacho selectivo: todos los reducers del body reciben todas las acciones. Un Scope que apunta al hijo también recibe las acciones del padre; simplemente, al no poder extraer el caso que le corresponde, no hace nada y devuelve .none.
De ahí se sigue la propiedad más importante y la menos comentada: no existe un mecanismo de next ni de stopPropagation. Ningún reducer puede consumir una acción para que los siguientes no la vean. Esta es la diferencia estructural con los middleware de Redux, donde cada capa decide si llama a la siguiente; en TCA la cadena siempre se recorre entera, y lo único que un reducer puede hacer para influir en los que vienen detrás es escribir en el estado.
var body: some ReducerOf<Self> {
Reduce { state, action in
state.trazas.append("primero: \(state.contador)")
state.contador += 1
return .none
}
Reduce { state, action in
state.trazas.append("segundo: \(state.contador)") // ve el valor ya incrementado
return .none
}
}
El segundo Reduce observa el estado que el primero dejó escrito, porque ambos operan sobre el mismo inout State dentro de la misma pasada. Eso convierte la lista en algo más parecido a una tubería que a una colección: cada elemento transforma el valor que recibe y se lo entrega al siguiente.
Conviene fijar también la unidad de esa pasada: una acción despachada produce exactamente un recorrido completo del body, ni más ni menos. Si alguno de los reducers devuelve un efecto que a su vez envía otra acción, esa acción no continúa el recorrido en curso sino que inicia uno nuevo desde el primer elemento de la lista. De ahí que ningún reducer llegue a ver jamás un estado a medio consolidar de otra acción, y de ahí también que el TestStore te obligue a escribir un receive por cada acción que regresa de un efecto: está modelando exactamente esa granularidad.
flowchart TD A[accion entra al Store] --> R1[reducer 1 del body] R1 --> R2[reducer 2 del body] R2 --> R3[reducer 3 del body] R3 --> F[estado final visible por la vista] R1 -.efecto A.-> M[merge de efectos] R2 -.efecto B.-> M R3 -.efecto C.-> M M --> V[efectos en vuelo sin orden garantizado]
El padre después del hijo, y por qué
Aquí está el bug canónico del nivel. Un padre quiere reaccionar a una acción de su hijo leyendo el estado del hijo justo después de que este la procese. Si el Reduce del padre está listado antes del Scope, leerá el estado anterior a la mutación del hijo, porque el hijo aún no ha corrido.
// INCORRECTO: el padre corre antes y lee un valor obsoleto.
var body: some ReducerOf<Self> {
Reduce { state, action in
if case .formulario(.guardarPulsado) = action {
state.ultimoNombre = state.formulario.nombre // todavia el valor viejo
}
return .none
}
Scope(state: \.formulario, action: \.formulario) { Formulario() }
}
// CORRECTO: el hijo muta primero, el padre observa el resultado.
var body: some ReducerOf<Self> {
Scope(state: \.formulario, action: \.formulario) { Formulario() }
Reduce { state, action in
if case .formulario(.guardarPulsado) = action {
state.ultimoNombre = state.formulario.nombre // ya normalizado por el hijo
}
return .none
}
}
La regla que se destila es simple y casi siempre correcta: los hijos arriba, la lógica propia del padre abajo. Y la justificación no es estética sino defensiva: si el padre corriera primero, podría anular o eliminar el estado del hijo en respuesta a esa misma acción, y el hijo jamás llegaría a procesar su propio evento. Ese escenario —una acción que el hijo emite y nunca ve— produce fallos que no aparecen en ningún test que no sea exhaustivo.
Hay un caso legítimo para poner el Reduce del padre arriba: cuando quieres interceptar una acción antes de que el hijo la vea, típicamente para vetarla escribiendo en el estado del hijo o para registrar la intención en bruto. Si lo haces, documéntalo con un comentario en el body. Un orden inusual sin explicación es indistinguible de un descuido, y el siguiente que lo lea lo «arreglará».
Los órdenes que la biblioteca fija por ti
No todos los órdenes son negociables. Los operadores que gobiernan cardinalidad —ifLet para el hijo opcional, ifCaseLet para el hijo dentro de un enum, forEach para la colección— se aplican como modificadores sobre el reducer padre, y en los tres la biblioteca ejecuta el hijo antes que el padre, sin dejarte elegir. La razón es exactamente la defensiva del apartado anterior, elevada a invariante: si el padre corriese primero, podría poner el estado del hijo a nil o borrar la fila de la colección en la misma pasada, y el hijo quedaría sin oportunidad de reaccionar.
| Composición | Quién ve la acción primero | ¿Puedes cambiarlo? |
|---|---|---|
Varios elementos listados en body |
El que esté más arriba | Sí, reordenando |
Scope frente al Reduce del padre |
El que listes antes | Sí, y por defecto el Scope |
.ifLet, .ifCaseLet, .forEach |
Siempre el hijo | No, lo fija la biblioteca |
BindingReducer() frente a tu Reduce |
El que listes antes | Sí, y casi siempre el binding |
BindingReducer() merece su fila. Es el reducer que escribe en el estado el valor que llega en una acción binding, y si tu Reduce está listado antes, verá el campo con su valor anterior y tomará decisiones sobre datos que ya no son ciertos. Ponlo siempre en primer lugar salvo que tengas una razón nombrada para no hacerlo.
El caso de ifLet desconcierta porque la vista y la semántica no coinciden: el modificador se escribe al final de la cadena, pero el hijo que gobierna corre antes que el reducer al que va pegado.
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .hoja(.presented(.cerrarPulsado)):
state.hoja = nil // el padre anula DESPUES de que el hijo actue
return .none
default:
return .none
}
}
.ifLet(\.$hoja, action: \.hoja) { Hoja() }
}
Leído de arriba abajo parece que el padre manda primero; leído con la semántica correcta, el hijo procesa cerrarPulsado —guardando lo que tenga que guardar— y solo después el padre pone su estado a nil, momento en el que ifLet cancela además los efectos que el hijo dejara en vuelo. Invertir ese orden equivaldría a demoler el edificio antes de dejar salir a la gente.
Hijos arriba
Coloca cada Scope por encima del Reduce propio: el padre solo debería opinar sobre un estado que el hijo ya terminó de escribir.
Bindings primero
BindingReducer() en cabeza, para que ninguna lógica decida sobre un campo cuyo valor nuevo todavía no se ha escrito en el estado.
Coordinación al final
El Reduce que arbitra entre hijos se lee mejor al cierre: allí el estado ya es coherente y la narración del body queda en orden cronológico.
Los efectos no se ordenan, se mezclan
Cada reducer del body devuelve un Effect, y TCA los combina con la semántica de merge, no de concatenate. Traducido: las mutaciones de estado ocurren en el orden de la lista y son observables en ese orden, pero los efectos devueltos arrancan todos y corren concurrentemente, de modo que sus acciones de vuelta pueden llegar en cualquier orden.
var body: some ReducerOf<Self> {
Reduce { _, _ in .run { await $0(.rapida) } } // vuelve primero, quiza
Reduce { _, _ in .run { await $0(.lenta) } } // o quiza no
}
Confiar en que .rapida se procesará antes que .lenta porque su reducer estaba más arriba es un error de razonamiento muy caro. Si necesitas secuencia entre efectos, exprésala explícitamente —encadenando dentro de un mismo .run, o con Effect.concatenate—, nunca apoyándote en la posición en el body. El orden de la lista gobierna el estado; la concurrencia gobierna el tiempo.
Conviene decirlo sin rodeos porque cambia la manera de leer cualquier feature madura: la lista de reducers de un body no es una enumeración de componentes, es la escritura explícita de una función compuesta. Cuando pones tres reducers uno debajo de otro estás escribiendo, con otra sintaxis, la composición de tres transformaciones de estado, y la composición de funciones jamás ha sido conmutativa. Que TCA te permita expresarla como una lista vertical sin comas es una comodidad ergonómica prestada de SwiftUI, no una licencia para pensar en términos de conjunto. Esta constatación tiene un corolario incómodo y otro liberador. El incómodo es que el body es la única parte de tu feature cuyo significado depende del formato del fichero: mover una línea tres posiciones hacia arriba puede cambiar el comportamiento sin cambiar una sola expresión, y ninguna herramienta de refactor te avisará. El liberador es que, por eso mismo, el body es el sitio donde la arquitectura se hace legible: leído de arriba abajo cuenta, en orden cronológico, todo lo que le ocurre a una acción desde que entra hasta que sale, y esa narración es la documentación más fiel que tendrá nunca la feature. Trata cada reordenación como un cambio de comportamiento, escribe un test que fije el orden que te importa —el TestStore exhaustivo los detecta todos— y usa la propia disposición del body como el mapa que le entregas al siguiente que abra el fichero.
- Escribe un padre con un hijo compuesto mediante
Scopey unReducepropio que copie un campo del hijo tras una acción del hijo. - Coloca el
Reducedel padre antes delScopey escribe un test enTestStoreafirmando el valor copiado. Observa el fallo y anota exactamente qué valor recibiste. - Mueve el
Scopearriba, sin tocar ninguna otra línea, y verifica que el mismo test pasa. Has cambiado el comportamiento cambiando solo el orden. - Añade un
BindingReducer()al final delbodyen una feature con formulario y comprueba con_printChangesque tuReduceopera con el valor anterior del campo. - Lanza dos efectos desde dos reducers hermanos, cada uno con una espera distinta, y escribe un test que dependa de su orden de llegada. Hazlo fallar y luego reescríbelo con
Effect.concatenate.