wandres.dev
MODELAR LA ACTION · enums que cuentan qué pasó

Acciones anidadas: el árbol de enums y su enrutamiento

Si el State de una app es un producto anidado de structs, la Action es su dual exacto: una suma anidada de enums donde el enum del padre incrusta el del hijo como un caso, y este a su vez el del nieto. Esta lección diseca ese árbol: cómo una acción viaja desde el Store raíz hasta la hoja que le corresponde, por qué los case paths generados por CasePathable son el mecanismo de descenso, en qué orden preciso ven la acción el hijo y el padre según se use Scope, ifLet o forEach, y cómo el padre puede interceptar una acción del hijo sin que este se entere. Al final entenderás que el enum de acciones de la app entera es un único tipo suma cuya forma calca la topología de features.

⏱ 18 min

El State de una aplicación en TCA es un tipo producto anidado: una struct que contiene otras struct. La Action es su imagen especular, un tipo suma anidado: un enum cuyos casos envuelven otros enum. Esa dualidad no es una coincidencia estética sino la condición que hace posible la composición, porque un reducer hijo solo puede correr dentro de un padre si existe una forma canónica de bajar hasta su rebanada de estado y de subir hasta su rebanada de acciones. En el estado se baja con un key path; en las acciones se baja con un case path. Esta lección recorre el árbol completo: cómo se construye, cómo se enruta hacia abajo, en qué orden se procesa y dónde puede el padre intervenir.

🎯 Al terminar esta lección sabrás
  • Construir el árbol de acciones incrustando el enum del hijo como caso del padre.
  • Explicar el papel de @CasePathable y de los case paths como duales de los key paths.
  • Determinar el orden exacto en que hijo y padre procesan una misma acción con Scope, ifLet y forEach.
  • Interceptar en el padre una acción del hijo sin acoplar al hijo con su contenedor.

El árbol: un caso por hijo

Componer dominios significa incrustar: el estado del hijo se convierte en un campo del padre y las acciones del hijo se convierten en un caso del padre. La macro @Reducer marca el enum como @CasePathable, y eso da a cada caso un case path simétrico al key path de un campo.

@Reducer
struct AppFeature {
  @ObservableState
  struct State: Equatable {
    var lista = Lista.State()
    @Presents var editor: Editor.State?
  }
  enum Action {
    case lista(Lista.Action)
    case editor(PresentationAction<Editor.Action>)
  }
  var body: some ReducerOf<Self> {
    Scope(state: \.lista, action: \.lista) { Lista() }
    Reduce { state, action in .none }
      .ifLet(\.$editor, action: \.editor) { Editor() }
  }
}

Una acción concreta se escribe entonces como una ruta completa desde la raíz hasta la hoja. Enviar .lista(.fila(.init(id: id, action: .view(.favoritoPulsado)))) no es verbosidad gratuita: es la dirección postal exacta del suceso dentro de la topología de features, y contiene toda la información necesaria para que el runtime sepa a qué reducer entregarlo y sobre qué porción de estado hacerlo correr.

No todos los eslabones del camino tienen la misma forma, y conviene reconocer los tres envoltorios que TCA añade según la cardinalidad del hijo. Un hijo único se incrusta desnudo; uno opcional o presentado viaja dentro de PresentationAction, que suma a las acciones del hijo el caso dismiss; una colección viaja dentro de IdentifiedActionOf, que empareja cada acción con el identificador del elemento; y una pila de navegación usa StackAction, que añade el índice del elemento y los avisos de apilado y desapilado.

enum Action {
  case cabecera(Cabecera.Action)                 // uno, siempre
  case editor(PresentationAction<Editor.Action>) // cero o uno
  case filas(IdentifiedActionOf<Fila>)           // cero o muchos
  case ruta(StackAction<Ruta.State, Ruta.Action>)// pila
}

Cada envoltorio aporta exactamente la información que el operador correspondiente necesita para localizar la rebanada de estado sobre la que correr el hijo. Esa correspondencia entre forma del caso y operador es total: el tipo del caso ya dice qué operador lo enruta.

flowchart TD
A[AppFeature Action] --> L[caso lista]
A --> E[caso editor]
L --> LF[caso fila con id]
LF --> FV[Fila Action view]
LF --> FD[Fila Action delegate]
E --> EP[PresentationAction presented]
EP --> EV[Editor Action view]

Case paths: el dual del key path

Un key path \State.lista es una pareja de operaciones sobre un producto: extraer el campo y escribirlo. Un case path \Action.Cases.lista es la pareja dual sobre una suma: intentar extraer el valor si el enum está en ese caso, y envolver un valor para construirlo. La asimetría es exactamente la que separa producto de suma: en el producto la extracción siempre tiene éxito, en la suma puede fallar, y por eso devuelve un opcional.

Esa dualidad es lo que permite que Scope, ifLet y forEach tengan la misma firma conceptual: reciben un camino hacia el estado y un camino hacia la acción, y con ambos construyen la lente completa que embebe el dominio del hijo en el del padre. Cuando llega una acción, el operador aplica el case path; si la extracción devuelve nil, la acción no era para ese hijo y el operador la deja pasar sin hacer nada. El enrutamiento es, literalmente, pattern matching sobre el prefijo de la ruta.

Los case paths no viven solo dentro de los operadores: son valores de primera clase que puedes usar en switch, en filtros y en los tests. El TestStore los aprovecha para afirmar sobre la forma de una acción sin escribir su carga completa, lo que resulta indispensable cuando el valor asociado no es Equatable o cuando solo interesa la rama.

await store.receive(\.lista.filas[id: fila].delegate.favoritoMarcado)

Esa expresión es una ruta compuesta por pasos de caso encadenados, y el compilador la verifica entera: si mañana renombras un caso intermedio, el test deja de compilar en lugar de fallar en ejecución con un mensaje opaco.

💡
El hijo nunca ve el prefijo

Dentro de Lista el switch opera sobre Lista.Action sin ningún rastro del caso lista que lo envuelve en el padre. El desenvolvimiento ocurre en el operador, no en el reducer, y por eso el mismo Lista() corre idéntico en la raíz de una preview, dentro de una pestaña o anidado tres niveles más abajo. El hijo ignora su profundidad porque nunca manipula la ruta.

El orden: quién ve la acción primero

Que dos reducers vean la misma acción exige saber cuál actúa antes, porque el segundo observa el estado ya mutado por el primero. TCA fija el orden con dos reglas distintas según la forma sintáctica.

Los elementos listados en body corren en el orden en que aparecen, de arriba abajo. Por eso el idioma habitual coloca los Scope primero y el Reduce de coordinación al final: cada hijo procesa lo suyo y el padre observa después el resultado consolidado.

Los operadores ifLet y forEach, en cambio, corren siempre el hijo antes que el padre, con independencia de dónde se escriba el modificador. La razón es de corrección, no de gusto: el padre debe poder anular el estado del hijo como reacción a una acción del hijo, y si el orden fuera el inverso, el hijo recibiría una acción sobre un estado que el padre acaba de destruir. Al correr primero, el hijo termina su trabajo y solo entonces el padre puede desmontarlo y disparar la cancelación automática de sus efectos en vuelo.

var body: some ReducerOf<Self> {
  Scope(state: \.cabecera, action: \.cabecera) { Cabecera() }  // 1
  Reduce { state, action in                                    // 2 respecto a Scope
    // ...
    return .none
  }
  .ifLet(\.$editor, action: \.editor) { Editor() }             // corre antes que el Reduce
}

Interceptar sin acoplar

El padre no se limita a enrutar: puede reaccionar a cualquier acción del hijo con solo escribir el patrón anidado completo en su switch. Esta capacidad es potente y por eso conviene disciplinarla.

Reduce { state, action in
  switch action {
  case let .editor(.presented(.delegate(.documentoGuardado(id)))):
    state.recientes.insert(id, at: 0)
    state.editor = nil
    return .none
  default:
    return .none
  }
}

La regla práctica es interceptar solo el subenum delegate, que existe precisamente para eso. Un padre que reacciona a .editor(.presented(.view(.guardarPulsado))) está espiando gestos que no le conciernen y se acopla a la interfaz interna del hijo: el día que el hijo renombre su botón, el padre deja de compilar sin que nada haya cambiado en el dominio compartido.

⚠️
Espiar al hijo invierte la dirección del conocimiento

La composición se sostiene sobre una asimetría: el padre conoce al hijo y el hijo ignora al padre. Interceptar acciones internas del hijo no rompe esa asimetría en el papel —el hijo sigue sin nombrar al padre— pero sí en la práctica, porque convierte cada caso interno del hijo en parte de su contrato público. A partir de ahí el hijo ya no puede refactorizarse en solitario, que era justamente la propiedad que la composición prometía.

🌳

La app es un solo enum

Todas las acciones posibles del programa forman un único tipo suma anidado cuya forma reproduce el árbol de features.

🧭

La ruta es la dirección

Cada acción enviada lleva escrito el camino desde la raíz hasta la hoja; el runtime no adivina destinatario, lo lee.

El enum de acciones de la app es la suma total de su historia posible

Cuando ensamblas features con Scope, ifLet y forEach no estás solo enchufando reducers: estás construyendo, sin escribirlo a mano, un único tipo gigantesco que enumera de forma exhaustiva y cerrada absolutamente todo lo que puede ocurrirle a la aplicación. Ese tipo tiene una propiedad que ninguna arquitectura basada en delegados, notificaciones o callbacks puede ofrecer: es finito, inspeccionable y estáticamente conocido. Cualquier suceso del programa —el toque más trivial en la fila número cuarenta de una lista anidada en la tercera pestaña— tiene un nombre canónico y un solo camino de llegada, y ese camino es el valor mismo de la acción. De ahí se derivan consecuencias que parecen mágicas y son puramente estructurales. La traza de _printChanges es legible como una crónica porque cada línea es una ruta completa. El deep linking se reduce a construir un State con la forma correcta, porque la topología de navegación es la misma topología de las acciones. Los tests de integración pueden atravesar varios niveles porque el padre puede enviar la ruta entera hasta la hoja sin instanciar nada del hijo. Y la depuración deja de ser arqueología: no hay un emisor anónimo que disparó un notification center, hay una ruta que dice de dónde vino. La dualidad de fondo es la que ordena todo: el State es el producto de todo lo que coexiste ahora, la Action es la suma de todo lo que puede ocurrir después, y el reducer es la función que va de la segunda a un cambio en el primero. Modelar bien esa suma anidada —un caso por hijo, ni un caso de más, jamás una referencia hacia arriba— no es organizar código: es escribir la gramática completa de lo que tu programa admite como historia.

⚔️ Recorre el árbol de acciones de tu app
  1. Dibuja el árbol de features de una app tuya y escribe, para cada nodo, el caso del enum padre que lo incrusta.
  2. Elige la hoja más profunda y escribe a mano la acción completa desde la raíz. Cuenta los niveles de anidamiento.
  3. Localiza en tu body los Scope, ifLet y forEach, y anota para cada acción quién la procesa primero y quién después.
  4. Provoca deliberadamente el caso límite: haz que el padre anule el estado de un hijo opcional en respuesta a una acción de ese hijo, y comprueba que el hijo ya había terminado y que sus efectos se cancelan.
  5. Audita las intercepciones del padre: si alguna alcanza un caso view del hijo, conviértela en una delegate nueva y observa cómo desaparece el acoplamiento.