Scope: la firma explicada pieza a pieza
`Scope` es el operador que materializa la composición: toma un reducer hijo y lo hace correr dentro del dominio del padre. Esta lección lo desarma argumento por argumento —el `WritableKeyPath` que localiza el estado, el `CaseKeyPath` que identifica la acción, el cierre marcado con `@ReducerBuilder` que construye al hijo— y después abre la caja para leer su implementación completa, que cabe en cinco líneas y explica todo lo demás: extraer la acción o rendirse, correr al hijo sobre una rebanada del estado por referencia, y volver a envolver las acciones futuras del efecto. También cubre la variante para estado enumerado y por qué un desajuste entre los dos caminos produce una advertencia en tiempo de ejecución en lugar de un error de compilación.
Cuando alguien escribe por primera vez Scope(state: \.contador, action: \.contador) suele leerlo como una sola cosa: la línea que conecta al hijo. Pero ahí hay dos conexiones independientes que casualmente comparten nombre, y solo una de ellas es un key path en el sentido corriente de Swift. Confundirlas es la fuente número uno de desconcierto en las primeras semanas con TCA, porque el compilador acepta ambas con idéntica sintaxis y el error, cuando llega, llega en tiempo de ejecución. Esta lección separa las dos coordenadas, recorre la firma completa del inicializador y termina leyendo el cuerpo real de Scope, que es corto hasta la decepción y esclarecedor por la misma razón.
- Distinguir el key path de estado —un camino dentro de un
struct— del case key path de acción —un caso dentro de unenum—. - Leer la firma completa de
Scopee identificar qué aporta cada argumento y de dónde se infiere cada tipo genérico. - Reconstruir la implementación de
Scope: extraer, ejecutar sobre la rebanada y reenvolver el efecto. - Aplicar la variante de estado enumerado y diagnosticar el desajuste que produce una advertencia en tiempo de ejecución.
Dos coordenadas que se escriben igual
El dominio de una feature tiene dos mitades, y cada una necesita su propio puente. La mitad del estado vive en un struct, así que para localizar el trozo que le corresponde al hijo basta un camino de campos: eso es un key path ordinario. La mitad de la acción vive en un enum, así que para localizar el caso que le corresponde al hijo hace falta algo distinto: un camino que sepa tanto extraer el valor asociado cuando el caso coincide como construir el caso entero a partir de un valor. Eso es un case key path, y lo aporta la biblioteca swift-case-paths a través de la anotación @CasePathable que la macro @Reducer pone por ti sobre el enum Action.
@Reducer
struct Panel {
@ObservableState
struct State: Equatable {
var contador = Contador.State() // un campo del struct
}
enum Action {
case contador(Contador.Action) // un caso del enum
}
var body: some ReducerOf<Self> {
Scope(state: \.contador, action: \.contador) {
Contador()
}
}
}
Que la sintaxis sea idéntica no es un accidente afortunado sino trabajo de la macro. @CasePathable genera para el enum un tipo espejo con una propiedad por caso y lo expone mediante búsqueda dinámica de miembros, de modo que Swift acepta escribir \.contador sobre un enum igual que lo aceptaría sobre un struct. Lo que obtienes al hacerlo no es un KeyPath sino un CaseKeyPath, un valor que sabe hacer dos cosas: extraer el valor asociado cuando el caso coincide —operación que puede fracasar y por eso devuelve un opcional— y construir el caso completo a partir de un valor —operación que nunca fracasa—. Esa doble capacidad es justo la que Scope necesita, una para cada dirección del viaje.
Las dos barras invertidas se escriben igual y significan cosas distintas. \.contador en la posición state: es un WritableKeyPath que apunta al campo; \.contador en la posición action: es un CaseKeyPath que apunta al caso. Que compartan nombre es una convención afortunada —nombra el caso igual que el campo y todo se lee simétrico— pero no es una obligación ni una consecuencia: son dos objetos de tipos incompatibles que Swift resuelve por el tipo esperado en cada parámetro.
La firma, argumento por argumento
Esta es la declaración que importa, con los genéricos a la vista y algún detalle omitido por claridad.
public struct Scope<ParentState, ParentAction, Child: Reducer>: Reducer {
public init(
state toChildState: WritableKeyPath<ParentState, Child.State>,
action toChildAction: CaseKeyPath<ParentAction, Child.Action>,
@ReducerBuilder<Child.State, Child.Action> child: () -> Child
)
}
Cuatro observaciones, en orden de importancia decreciente. Primera: Scope conforma Reducer con State igual a ParentState y Action igual a ParentAction. Es decir, desde fuera un Scope es indistinguible de cualquier otro reducer del padre, y por eso puede aparecer como un elemento más en la lista del body. Esa es la clausura de la lección anterior hecha código.
Segunda: el key path de estado es escribible, no de solo lectura. Tiene que serlo, porque el hijo va a mutar su estado en el sitio y Scope necesita pasarle una referencia mutable a la rebanada. Si el campo del padre fuese let, no compilaría, y ese error de compilación es exactamente el correcto.
Tercera: los genéricos ParentState y ParentAction no se escriben nunca. Se infieren del contexto, porque @ReducerBuilder ya sabe qué dominio está construyendo cuando lee el body del padre. Por eso la línea real es tan corta pese a que el tipo completo es largo.
Cuarta: el último argumento es un cierre marcado con @ReducerBuilder, no un valor. Eso significa que dentro de las llaves puedes listar varios reducers, y todos correrán sobre el dominio del hijo, en orden. Es un detalle que casi nadie usa el primer año y que, cuando se necesita, evita crear un tipo intermedio solo para agrupar dos comportamientos:
Scope(state: \.contador, action: \.contador) {
Contador()
Analitica() // otro reducer sobre el MISMO dominio del hijo
}
Ambos reducers ven el State y el Action de Contador, no los del padre. El Scope abrió una ventana al dominio del hijo y todo lo que pongas dentro mira por esa ventana.
Si el proyecto no compila, el culpable casi siempre es el camino del estado: los key paths ordinarios se verifican por completo en tiempo de compilación. Si el proyecto compila pero una feature hija parece muerta —no reacciona a nada—, el culpable casi siempre es el camino de la acción, o el orden de los elementos del body. Ese reparto no es casual y conviene interiorizarlo pronto: el estado tiene garantías estáticas totales, la acción solo parciales, porque un caso de un enum puede simplemente no coincidir en tiempo de ejecución sin que eso sea un error de tipos.
Lo que Scope hace por dentro
Aquí está el cuerpo, simplificado pero fiel. Merece la pena leerlo despacio porque contiene, literalmente, toda la semántica de la composición padre-hijo.
public func reduce(
into state: inout ParentState,
action: ParentAction
) -> Effect<ParentAction> {
guard let childAction = action[case: toChildAction]
else { return .none }
return self.child
.reduce(into: &state[keyPath: toChildState], action: childAction)
.map { toChildAction($0) }
}
Tres movimientos. El primero es un filtro: action[case: toChildAction] intenta extraer la acción del hijo; si la acción que llega pertenece a otro caso del padre, el guard devuelve .none y el hijo ni se entera de que hubo tráfico. Un Scope es, ante todo, un reducer que ignora casi todo lo que le llega.
El segundo es la ejecución sobre la rebanada: &state[keyPath: toChildState] produce una referencia mutable al campo del hijo dentro del estado del padre. El hijo recibe un inout de su propio State y muta ahí mismo. No hay copia ni sincronización posterior; el estado del hijo nunca fue un objeto aparte, siempre fue una porción del estado del padre vista a través de un camino.
El tercero es el reenvolvido: el hijo devuelve un Effect<Child.Action>, pero el padre solo entiende Effect<ParentAction>, así que Scope mapea cada acción futura del efecto envolviéndola en el caso del padre. Esto tiene una consecuencia operativa enorme: cuando ese efecto emita algo dentro de tres segundos, lo que entrará en el sistema no será la acción del hijo sino la del padre que la contiene, y por tanto volverá a recorrer el body del padre desde arriba. Ese viaje es el tema de la lección siguiente.
Filtrar
action[case:] devuelve un opcional. Si no coincide, el Scope se retira devolviendo .none y el hijo ni siquiera se ejecuta. La mayoría de las acciones de una app son ignoradas por la mayoría de los Scope.
Enfocar
&state[keyPath:] entrega al hijo una referencia mutable a su porción. No hay copia ni sincronización: el estado del hijo siempre fue un trozo del estado del padre.
Reenvolver
El efecto del hijo se mapea al espacio de acciones del padre. Lo que vuelva del futuro entrará por la raíz, no por un atajo lateral hacia el hijo.
Y una ausencia notable, que conviene tener presente para no confundir operadores: Scope no cancela nada. ifLet y forEach sí desmontan los efectos en vuelo del hijo cuando su estado desaparece, porque en ellos el hijo puede dejar de existir. En Scope el hijo existe siempre —esa es su cardinalidad—, así que no hay ningún momento en el que cancelar tuviera sentido. Si un hijo enchufado con Scope necesita interrumpir su propio trabajo, es asunto suyo y lo resuelve con .cancellable y un identificador propio, sin que el padre intervenga.
La variante de estado enumerado
Hasta ahora el estado del hijo era un campo, presente siempre. Existe una segunda forma de Scope para cuando el estado del padre es él mismo un enum y el del hijo es uno de sus casos, situación habitual en el enum de destinos de navegación:
@Reducer
enum Destino {
case detalle(Detalle)
case ajustes(Ajustes)
}
// forma manual equivalente, con estado enumerado
Scope(state: \.detalle, action: \.detalle) {
Detalle()
}
En esta variante el primer argumento también es un CaseKeyPath, no un WritableKeyPath, porque ahora hay que extraer un caso del estado y volver a construirlo tras la mutación. La diferencia de fondo es de cardinalidad: con estado en struct, los estados de todos los hijos coexisten y siempre hay algo donde escribir; con estado en enum, exactamente un hijo está vivo y los demás ni siquiera tienen memoria asignada. Esa segunda forma es la que usarás en cuanto modeles navegación, porque un destino de pantalla es por naturaleza excluyente: se está en el detalle o en los ajustes, nunca en los dos. Y aquí aparece la única grieta real de Scope: si llega una acción del caso detalle mientras el estado está en el caso ajustes, no hay nada coherente que hacer. TCA no falla en silencio ni adivina: emite una advertencia en tiempo de ejecución que nombra el Scope culpable y explica que recibió una acción de un hijo cuyo estado estaba en otro caso.
Cuando veas esa advertencia, resiste la tentación de silenciarla con un guard. Suele significar una de tres cosas, todas de diseño: que un efecto del hijo sobrevivió al cambio de estado y emitió tarde —lo que se corrige con cancelación o con los operadores de ciclo de vida ifLet e ifCaseLet, que la aplican solos—; que la vista siguió enviando acciones sobre un store ya obsoleto; o que dos caminos de navegación compiten por el mismo campo. La advertencia no es ruido: es un invariante roto pidiendo que lo mires.
La metáfora del cable —conectar el hijo al padre— es cómoda y es falsa, y sustituirla por la correcta cambia cómo diseñas. Un cable transporta lo mismo por los dos extremos; Scope no transporta nada, traduce. En el lado del estado la traducción va de fuera hacia dentro: toma el estado grande del padre y expone una vista mutable del trozo pequeño, de modo que el hijo escribe creyendo que su State es todo el universo cuando en realidad es un campo dentro de una estructura que jamás verá. En el lado de la acción la traducción va en los dos sentidos: hacia dentro extrae, con posibilidad de fracaso, porque una acción del padre puede no ser del hijo; hacia fuera inyecta, sin posibilidad de fracaso, porque toda acción del hijo cabe en el caso del padre. Esa combinación —una proyección con escritura sobre un producto y una inyección con extracción parcial sobre una suma— tiene nombres viejos en programación funcional, lente y prisma, y la razón de mencionarlos no es erudición sino predicción: en cuanto reconoces que Scope es un par lente-prisma, sabes de antemano qué se puede componer y qué no, y por qué. Sabes que dos lentes se componen dando una lente, y por eso puedes anidar Scope dentro de Scope indefinidamente. Sabes que un prisma puede fallar al extraer, y por eso el desajuste de casos es una advertencia en ejecución y no un error de compilación —no es una imperfección de TCA, es una propiedad de las sumas—. Y sabes, sobre todo, que el hijo es genuinamente ignorante: no recibió una referencia al padre ni un delegado ni un contexto, recibió un inout a un trozo de memoria y un valor de su propio tipo de acción. Toda la asimetría de la arquitectura —el padre lo sabe todo, el hijo no sabe nada— está contenida en esas cinco líneas del cuerpo de Scope, y no hay ninguna otra magia esperándote más adelante.
- Escribe tu propio
struct MiScopeconformandoReducer, con los tres campos que necesita: el key path de estado, el case key path de acción y el reducer hijo. - Implementa
reducecon los tres movimientos: extraer conaction[case:], ejecutar sobre&state[keyPath:]y mapear el efecto reenvolviendo la acción. - Sustituye un
Scopereal de tu app porMiScopey comprueba que la feature sigue comportándose exactamente igual, efectos incluidos. - Rompe el tercer movimiento a propósito: devuelve
.noneen lugar de mapear el efecto. Anota qué deja de funcionar y por qué el estado sí sigue mutando. - Rompe ahora el primero: quita el
guardy fuerza la extracción. Documenta el fallo que aparece y relaciónalo con la advertencia de desajuste de casos que describe la sección final.