El viaje de una acción del hijo
Una acción enviada desde la vista más profunda de la app no se queda ahí: sube envuelta hasta la raíz del store, se procesa en el dominio completo y desciende ofreciéndose a todos los reducers del camino, en orden estricto de arriba abajo. Esta lección sigue ese recorrido paso a paso —el reenvolvido que hace el store con `scope`, la ejecución secuencial del `body`, la fusión de efectos y el remapeo de las acciones futuras— y demuestra con código por qué colocar el `Reduce` del padre antes o después de un `Scope` cambia por completo qué estado observa. Termina con la consecuencia incómoda que motiva la lección siguiente: absolutamente todos los ancestros ven absolutamente todas las acciones del hijo.
Hay una intuición falsa muy extendida sobre TCA: que cada feature procesa sus propias acciones en su propio rincón, como si cada reducer tuviera un pequeño motor privado. No existe tal cosa. Hay un único store, un único estado y un único reducer raíz, y toda acción del sistema —incluida la que nace en el botón más enterrado de la pantalla más profunda— viaja hasta esa raíz, se envuelve tantas veces como niveles haya atravesado y se ofrece después a cada uno de los reducers del camino. Entender ese viaje con precisión es lo que separa a quien usa Scope de quien sabe por qué su Reduce a veces ve el estado viejo.
- Seguir una acción desde el
sendde la vista hija hasta el reducer raíz y de vuelta. - Explicar el reenvolvido que hacen el store con
scopey el operadorScopesobre las acciones y sobre los efectos. - Predecir qué estado observa el
Reducedel padre según su posición relativa a losScopedelbody. - Reconocer que todo ancestro ve toda acción del hijo, y por qué eso es a la vez el poder y el peligro de la arquitectura.
De la vista hija a la raíz, y vuelta
La vista del hijo no tiene un store propio: tiene una vista del store del padre, obtenida con scope, que usa exactamente los mismos dos caminos que el operador de la lección anterior.
struct PanelView: View {
let store: StoreOf<Panel>
var body: some View {
ContadorView(
store: store.scope(state: \.contador, action: \.contador)
)
}
}
Cuando ContadorView ejecuta store.send(.incrementar), ese store con scope no reduce nada. Toma la acción del hijo, la envuelve en el caso del padre —obteniendo .contador(.incrementar)— y la reenvía hacia arriba. Si el panel viviera a su vez dentro de una pestaña, el store del panel repetiría la operación y la acción llegaría a la raíz como .pestana(.panel(.contador(.incrementar))). El número de envoltorios es exactamente la profundidad del árbol, y esa cifra volverá a aparecer, con mala cara, en la última lección del nivel.
En la raíz sí ocurre algo. El store llama al reducer raíz con el estado completo y la acción completa, y el reducer raíz ejecuta su body de arriba abajo, ofreciendo esa misma acción a cada elemento de la lista sin excepción.
La palabra scope sobre el store engaña un poco, porque sugiere que se fabrica un store nuevo para el hijo. No se fabrica nada: lo que devuelve es una vista sobre el mismo y único store, con los mismos dos caminos que usa el operador de la lección anterior. Por eso no existe sincronización entre el estado del padre y el del hijo —no hay dos copias que puedan divergir—, ni un ciclo de vida propio que gestionar, ni un momento en que un store hijo pueda quedarse obsoleto respecto al padre. Toda la app tiene un solo estado, y el árbol de stores es un árbol de lentes sobre él.
flowchart TD V[Vista hija hace send de incrementar] --> SC[Store con scope reenvuelve la accion] SC --> R[Store raiz recibe contador punto incrementar] R --> B[body del padre corre de arriba abajo] B --> E1[Scope contador extrae y ejecuta el hijo] E1 --> M[Estado del hijo mutado en la rebanada] B --> E2[Reduce del padre ve la accion envuelta] M --> E2 E2 --> EF[Efectos de todos los elementos fusionados en orden] EF --> R style R fill:#f38ba8,color:#11111b style M fill:#a6e3a1,color:#11111b
El orden del body decide qué estado ves
Aquí está el punto que arruina tardes enteras a quien no lo ha visto explicado. El body es una lista ordenada, y TCA ejecuta cada elemento en secuencia sobre el mismo estado mutable. Por tanto, un elemento colocado después de un Scope observa el estado ya modificado por el hijo, y uno colocado antes observa el estado previo.
@Reducer
struct Panel {
@ObservableState
struct State: Equatable {
var contador = Contador.State()
var historial: [Int] = []
}
enum Action {
case contador(Contador.Action)
}
var body: some ReducerOf<Self> {
Scope(state: \.contador, action: \.contador) {
Contador()
}
Reduce { state, action in
switch action {
case .contador(.incrementar), .contador(.decrementar):
state.historial.append(state.contador.cuenta) // valor YA actualizado
return .none
case .contador:
return .none
}
}
}
}
Con este orden, state.contador.cuenta es el valor posterior al incremento, porque el Scope ya corrió. Si intercambiaras las dos líneas del body, el mismo código registraría el valor anterior, sin ningún aviso del compilador y sin ninguna advertencia en ejecución: simplemente el historial iría desfasado en un paso. Las dos posiciones son legítimas y ambas tienen usos —el estado previo sirve para calcular deltas o decidir en función de lo que había—, pero la elección tiene que ser consciente.
Coloca los Scope primero y el Reduce de coordinación al final cuando quieras reaccionar a lo que el hijo hizo, que es el caso mayoritario. Coloca el Reduce primero cuando necesites vetar, transformar o registrar la acción antes de que el hijo la toque. Y no mezcles: si tu body alterna reducers de coordinación entre varios Scope, cada uno observará un estado intermedio distinto y estarás dependiendo de un orden que nadie documentó y que el próximo refactor romperá.
Efectos: el viaje continúa en el tiempo
Las mutaciones terminan cuando termina el body, pero los efectos no. Cada elemento del body devuelve un Effect, y TCA los fusiona en el orden en que se produjeron; el store los ejecuta y todo lo que emitan vuelve a entrar por la puerta principal.
Ese retorno es la parte que conviene visualizar. Si el hijo devuelve un Effect<Contador.Action> que dentro de dos segundos emitirá .respuestaRecibida, el Scope ya lo mapeó a Effect<Panel.Action>, de modo que lo que llegará al store será .contador(.respuestaRecibida). Y al llegar, el ciclo se repite entero: el reducer raíz corre otra vez su body completo, el Scope extrae otra vez, el hijo muta otra vez y el Reduce del padre vuelve a tener su oportunidad. No hay un canal rápido ni una vía lateral: un efecto no es una llamada de vuelta al hijo, es una acción nueva que entra por arriba como cualquier otra.
Reduce { state, action in
switch action {
case .contador(.respuestaRecibida):
state.cargando = false // el padre reacciona a un efecto del hijo
return .none
case .contador:
return .none
}
}
Fíjate en el case .contador final, sin patrón interno. Es obligatorio: el enum del hijo puede tener veinte casos y el switch del padre debe ser exhaustivo. Ese caso comodín, que parece burocracia, es en realidad la declaración explícita de que el padre ignora deliberadamente todo lo demás que el hijo hace.
Sobre la fusión conviene precisar un punto que suele darse por supuesto: los efectos de los distintos elementos del body se combinan, no se sustituyen. Que el Scope devuelva un efecto no impide que el Reduce siguiente devuelva otro, y ambos se ejecutarán. Esa combinación respeta el orden de la lista al arrancarlos, pero no ordena en absoluto cuándo terminan: dos efectos asíncronos lanzados en el mismo ciclo compiten libremente, y si el orden de sus respuestas importa, eso hay que modelarlo con cancelación o con estado explícito, nunca confiando en la posición dentro del body.
Quién ve exactamente la acción
Recapitulemos el alcance, porque es la conclusión que arrastra todo el nivel. Una acción emitida por una hoja es visible, sin excepción, para tres audiencias.
El propio hijo
Su Reduce la recibe ya desenvuelta, en su propio vocabulario, y es el único que la ve así. Para él la acción es simplemente .incrementar.
El padre inmediato
La ve envuelta un nivel, como .contador(.incrementar), y puede hacer patrón contra ella con toda la precisión que quiera, incluidos los valores asociados.
Todo ancestro hasta la raíz
Cada nivel superior la ve con un envoltorio más. Ninguno está obligado a mirar, pero todos pueden, y el hijo no tiene forma de saber cuántos lo hacen.
La herramienta para comprobar todo esto en tu propia app cabe en una línea: ._printChanges() aplicado al body del reducer raíz imprime cada acción con sus envoltorios y el diferencial exacto del estado antes y después. Es el instrumento que convierte este recorrido de una explicación en una observación.
var body: some ReducerOf<Self> {
Scope(state: \.contador, action: \.contador) { Contador() }
Reduce { state, action in .none }
}
._printChanges()
Nota dónde va el modificador: sobre el body entero, no sobre un elemento. Es coherente con todo lo anterior —el resultado de componer sigue siendo un reducer, así que admite los mismos modificadores que cualquiera de sus partes—.
Detente en lo que acabas de establecer, porque no es un detalle de implementación sino la característica que define el carácter de esta arquitectura. Toda acción, sin excepción, atraviesa la cadena completa de reducers desde la raíz hasta la hoja, y en cada escalón hay un switch que podría reconocerla. Eso significa que el reducer raíz de tu aplicación puede, hoy mismo, sin tocar una línea del hijo, escribir un patrón contra la pulsación de un botón que vive seis niveles más abajo y actuar en consecuencia. Ninguna arquitectura basada en referencias te concede algo así: para que un objeto reaccione a un evento de otro hace falta que alguien los presente, que se guarde un puntero, que se registre un observador, y ese cableado hay que escribirlo, mantenerlo y desmontarlo. Aquí no hay cableado porque el canal ya existe: es el propio flujo de acciones, y está siempre completamente abierto. De ahí salen las tres capacidades que hacen de TCA lo que es —registrar toda la app con una sola línea de _printChanges, probar la interacción entre features escribiendo un solo TestStore en la raíz, reproducir una sesión entera replicando una lista de acciones— porque las tres consisten simplemente en escuchar un flujo que ya pasa por delante. Y de ahí sale también el único peligro serio de la arquitectura, que ninguna macro puede evitarte: si el padre puede reconocer cualquier acción del hijo, entonces puede acoplarse a los detalles internos del hijo con una facilidad obscena, y lo hará, porque es cómodo y funciona a la primera. El día que renombres una acción interna del hijo, o partas un caso en dos, o cambies cuándo se emite, romperás en silencio a un padre que jamás debió estar mirando. El flujo total es un privilegio que exige una disciplina explícita, y esa disciplina tiene nombre y forma en TCA: se llama acción delegate, y es lo único que separa esta apertura radical de un acoplamiento global.
- Añade
._printChanges()albodydel reducer raíz de una app tuya con al menos dos niveles de anidamiento y pulsa un control de la feature más profunda. - En la consola, cuenta cuántos envoltorios tiene la acción registrada y comprueba que coincide exactamente con la profundidad del árbol de features.
- Añade al
bodydel padre unReducecolocado después de losScopeque copie a un campo propio un valor del estado del hijo cada vez que llegue una acción de ese hijo. Verifica que registra el valor posterior. - Mueve ese mismo
Reduceantes de losScopesin cambiar una sola línea de su cuerpo. Comprueba que ahora registra el valor anterior y que ni el compilador ni el runtime protestan. - Provoca un efecto asíncrono en el hijo y observa en la consola cómo su acción de respuesta reaparece en la raíz con todos los envoltorios puestos, y cómo vuelve a recorrer el
bodydel padre desde el primer elemento.