wandres.dev
COMPOSICIÓN: SCOPE · padre e hijo

La idea de composición: el todo con la forma de las partes

Antes de tocar una sola línea de `Scope` conviene entender qué significa exactamente que un reducer se componga con otro. Esta lección disecciona la operación fundacional de TCA: un reducer padre que embebe a un hijo incrustando su estado como campo y sus acciones como caso, de modo que el resultado sigue siendo, sin ningún adjetivo, un reducer. Verás por qué el estado compone como producto y la acción como suma, por qué esa asimetría no es un capricho de diseño sino un reflejo exacto de la realidad —todos los estados coexisten, solo una acción ocurre a la vez—, y por qué la autosemejanza resultante convierte a la aplicación entera en un reducer más, indistinguible en tipo del más humilde de sus contadores.

⏱ 18 min

La palabra composición se usa con tanta ligereza en arquitectura de software que ha perdido casi todo su filo. Se dice que dos objetos se componen cuando uno guarda una referencia al otro; se dice que dos capas se componen cuando una llama a la otra. TCA usa la palabra en su sentido matemático, mucho más estrecho y mucho más exigente: dos reducers se componen cuando el resultado de juntarlos es, otra vez, un reducer —del mismo protocolo, con las mismas capacidades, apto para ser juntado de nuevo con un tercero sin que nada en el sistema note la diferencia—. Esta lección establece esa idea antes de que veas el operador que la implementa, porque el operador es fácil y la idea no lo es.

🎯 Al terminar esta lección sabrás
  • Definir la composición de reducers como una operación cerrada sobre el protocolo Reducer, no como una relación entre objetos.
  • Reconocer las dos incrustaciones que todo padre hace: el estado del hijo como campo y sus acciones como caso.
  • Entender por qué el estado se compone como producto y la acción como suma, y qué dice esa asimetría sobre el dominio.
  • Ver la autosemejanza del árbol: la app completa es un reducer del mismo tipo que su hoja más pequeña.

Un reducer dentro de otro reducer

Empecemos por la pieza que se deja componer. Una feature hoja no sabe nada del mundo: declara su propio State, su propio Action y la mutación que los relaciona. Nada en su código menciona un contenedor, un padre o una aplicación.

@Reducer
struct Fila {
  @ObservableState
  struct State: Equatable, Identifiable {
    let id: UUID
    var titulo: String
    var marcada = false
  }
  enum Action {
    case marcaAlternada
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .marcaAlternada:
        state.marcada.toggle()
        return .none
      }
    }
  }
}

Ahora el padre. Para embeber a Fila, Panel debe hacer exactamente dos cosas, y ninguna más: darle sitio a su estado y darle sitio a sus acciones. El estado del hijo pasa a ser un campo del struct del padre; las acciones del hijo pasan a ser un caso del enum del padre, un caso que transporta como valor asociado la acción entera del hijo.

@Reducer
struct Panel {
  @ObservableState
  struct State: Equatable {
    var cabecera = Cabecera.State()
    var fila = Fila.State(id: UUID(), titulo: "Primera")
  }
  enum Action {
    case cabecera(Cabecera.Action)
    case fila(Fila.Action)
  }
  var body: some ReducerOf<Self> {
    Scope(state: \.cabecera, action: \.cabecera) { Cabecera() }
    Scope(state: \.fila, action: \.fila) { Fila() }
  }
}

Lee Panel con atención y fíjate en lo que no tiene: no tiene lógica. Su body es pura tubería. Panel es un reducer que no reduce nada por su cuenta; su trabajo íntegro consiste en decir dónde vive el estado de cada hijo y bajo qué caso viajan sus acciones. Esa es la forma canónica de un contenedor, y el hecho de que sea escribible sin una sola sentencia imperativa es la primera evidencia de que la composición aquí es estructural y no algorítmica.

Para apreciar lo que se gana conviene mirar la alternativa que casi todo el mundo escribe primero: un solo reducer gigante que lo hace todo, sin hijos.

// La versión sin composición: funciona, y no se deja partir
@Reducer
struct PanelMonolitico {
  @ObservableState
  struct State: Equatable {
    var tituloCabecera = ""
    var filaTitulo = ""
    var filaMarcada = false
  }
  enum Action {
    case tituloCambiado(String)
    case marcaAlternada
  }
  var body: some ReducerOf<Self> { Reduce { state, action in /* ... */ .none } }
}

Este código no está mal escrito y hace exactamente lo mismo. Lo que no tiene es fronteras: nadie puede decir qué campos pertenecen a la cabecera y cuáles a la fila salvo por el prefijo del nombre, que no es una garantía sino una costumbre. No hay forma de arrancar solo la fila en una preview, ni de probar la cabecera sin instanciar el panel entero, ni de reutilizar ninguna de las dos partes en otra pantalla. La versión compuesta paga por adelantado un poco de ceremonia —dos tipos, dos incrustaciones— y a cambio obtiene tres propiedades que el monolito no puede conceder a ningún precio.

🔬

Aislamiento verificable

Fila compila y corre sin Panel. No es una promesa de estilo: si el hijo mencionara al padre, el hijo no compilaría fuera de él.

🔁

Reutilización sin copia

El mismo Fila sirve suelto, repetido en una lista o presentado en una hoja modal, porque en ningún punto de su código hay una referencia a dónde vive.

🧪

Prueba proporcional

Probar la fila cuesta lo que cuesta la fila. En el monolito, probar cualquier cosa cuesta lo que cuesta el panel entero, y ese coste crece con cada campo añadido.

Producto en el estado, suma en la acción

La incrustación no es simétrica, y la asimetría es la parte interesante. El estado del padre es un struct: contiene el estado de Cabecera y el de Fila, los dos a la vez, siempre. La acción del padre es un enum: es una acción de Cabecera o una de Fila, nunca las dos. En vocabulario de teoría de tipos, el estado compone como producto cartesiano y la acción como suma —también llamada coproducto—.

Esa diferencia no es una preferencia sintáctica de Swift: es una lectura fiel del dominio. En cualquier instante, todos los estados de todas las features vivas coexisten —la cabecera tiene un título mientras la fila tiene una marca—, y por eso su combinación es una conjunción. En cualquier instante, en cambio, el sistema está procesando exactamente una acción —el usuario pulsó esto o llegó aquella respuesta de red, jamás ambas simultáneamente—, y por eso su combinación es una disyunción. El tipo del padre no describe una decisión de arquitectura: describe cómo es el mundo.

ℹ️
Por qué esto importa más de lo que parece

Cuando el estado es un producto y la acción una suma, la aritmética del dominio se vuelve calculable. El número de estados posibles del padre es el producto de los estados posibles de sus hijos, lo que explica por qué añadir un campo booleano redundante multiplica por dos el espacio de estados inválidos y por qué modelar bien —hacer irrepresentable lo imposible— tiene un rendimiento tan desproporcionado. El número de acciones posibles, en cambio, es la suma de las de sus hijos: crece de forma lineal, y por eso una app grande tiene un Action enorme pero perfectamente manejable, mientras que un State mal modelado se vuelve intratable mucho antes.

La autosemejanza del árbol

Aquí llega la consecuencia que da nombre a la lección. Panel conforma Reducer. Fila conforma Reducer. Y si mañana una Pantalla embebe a Panel, esa Pantalla conformará Reducer con exactamente las mismas obligaciones y los mismos poderes. No existe en TCA un tipo distinto para la raíz, ni un protocolo especial para los contenedores, ni una clase base para las hojas. Hay un único protocolo y todo lo habita.

flowchart TD
App[AppFeature es un Reducer] --> P1[Panel es un Reducer]
App --> P2[Ajustes es un Reducer]
P1 --> H1[Cabecera es un Reducer]
P1 --> H2[Fila es un Reducer]
P2 --> H3[Perfil es un Reducer]
H2 --> N1[Etiqueta es un Reducer]
style App fill:#f38ba8,color:#11111b
style H1 fill:#a6e3a1,color:#11111b
style H2 fill:#a6e3a1,color:#11111b
style H3 fill:#a6e3a1,color:#11111b
style N1 fill:#a6e3a1,color:#11111b

Esa uniformidad es la definición técnica de una operación cerrada: componer reducers devuelve un reducer, igual que sumar enteros devuelve un entero. Y como es cerrada, es asociativa en la práctica —da igual si agrupas primero dos hijos en un panel y luego el panel en la app, o si reorganizas el árbol entero— y admite anidamiento sin límite sin introducir un solo concepto nuevo. La complejidad de una app de doscientas pantallas no es cualitativamente distinta de la de una app de dos: solo hay más aplicaciones de la misma operación.

Conviene además reparar en el elemento neutro, porque completa la analogía y no es un adorno teórico. EmptyReducer es un reducer que no muta nada y no devuelve efectos; componerlo con cualquier otro deja a ese otro intacto, exactamente como sumar cero. Aparece de verdad en el código cuando un contenedor solo necesita aplicar operadores de ciclo de vida sin tener lógica propia:

var body: some ReducerOf<Self> {
  EmptyReducer()
    .ifLet(\.$hoja, action: \.hoja) { Hoja() }
}

Con una operación cerrada, asociativa y con neutro, lo que tienes entre manos ya no es un patrón de diseño: es una estructura algebraica, y las estructuras algebraicas se comportan igual a toda escala. Esa es, literalmente, la razón por la que la app entera cabe en el mismo diagrama que una de sus hojas.

La composición cerrada es lo que separa una arquitectura de un conjunto de convenciones

Casi todas las arquitecturas de interfaz que has usado componen mal en este sentido preciso, y merece la pena ver exactamente dónde fallan. En MVVM, un view model padre contiene un view model hijo, pero el todo no es del mismo tipo que las partes: para orquestar varios aparece un coordinador, que no es un view model; para conectar la navegación aparece un router, que tampoco lo es; y cada uno de esos tipos nuevos trae su propio ciclo de vida, sus propias reglas de test y su propia forma de fallar. Lo que ha ocurrido es que la operación de juntar no era cerrada: al componer dos cosas de tipo A obtuviste algo de tipo B, y entonces necesitaste vocabulario nuevo para componer B, y ese vocabulario nuevo tampoco fue cerrado, y así hasta que la arquitectura dejó de ser una idea y pasó a ser un manual de convenciones que solo los veteranos del equipo recuerdan enteras. TCA hace la apuesta contraria y la lleva hasta el final: solo hay reducers, componer reducers produce reducers, y por tanto todo lo que aprendiste sobre uno vale íntegro para todos los demás, para siempre, a cualquier escala. Un TestStore que prueba una hoja prueba igual a la raíz. Una preview que arranca una feature arranca igual la app. La incrustación de estado como producto y de acción como suma no es folclore funcional: es la maquinaria mínima que hace posible esa clausura, porque solo un struct puede alojar todos los estados a la vez y solo un enum puede alojar todas las acciones sin permitir dos a la vez. Cuando entiendes esto, Scope deja de ser una API que memorizas y pasa a ser lo único que podría haber sido: el par de proyección e inyección que traduce entre el dominio del padre y el del hijo. La lección siguiente lo desmonta pieza a pieza, pero ya no te dirá nada que no esté implícito aquí.

⚔️ Compón un dominio a mano, sin operadores
  1. Elige dos features hoja reales de tu app, con su State y su Action ya escritos, y no las modifiques.
  2. Escribe a mano el State de un padre que las contenga como campos y el Action de ese padre que las contenga como casos. No escribas todavía el body.
  3. Cuenta el espacio de estados: si el hijo A tiene un booleano y un enum de tres casos, y el hijo B tiene dos booleanos, ¿cuántos estados distintos puede tener el padre? Anota cuántos de ellos son realmente alcanzables en tu app.
  4. Cuenta el espacio de acciones sumando las de ambos hijos. Compara ese crecimiento lineal con el crecimiento multiplicativo del punto anterior.
  5. Localiza en tu lista de estados posibles al menos uno que sea inválido —una combinación que tu app nunca debería alcanzar— y reescribe el State del padre, o el de algún hijo, para que ese estado deje de ser representable.