Diseñar la jerarquía: hojas, contenedores y profundidad
Saber usar `Scope` no dice nada sobre dónde colocar los `Scope`, y esa segunda pregunta es la que decide si una app envejece bien. Esta lección cierra el nivel con el criterio de diseño: distinguir la feature hoja —toda lógica, ningún hijo— de la feature contenedor —todo ensamblaje, casi ninguna lógica—, reconocer el híbrido que casi siempre son dos features soldadas, y medir el coste real de un árbol vertical, que se paga en envoltorios por evento, en reenvíos manuales de acciones `delegate` y en tests ilegibles. Termina con las maniobras de aplanado, incluida la más importante y la peor entendida: que la profundidad de navegación no tiene por qué ser profundidad de reducers.
Los cuatro capítulos anteriores te dieron el mecanismo completo: qué significa componer, cómo funciona Scope por dentro, por dónde viaja una acción y con qué contrato hablan padre e hijo. Nada de eso te dice cuántos niveles debe tener tu app ni qué merece ser una feature. Y esa pregunta importa más que todas las anteriores juntas, porque los errores de mecanismo los caza el compilador en minutos, mientras que un árbol mal repartido no falla nunca: simplemente hace que cada cambio cueste un poco más que el anterior, durante años, sin que nadie sepa señalar el momento en que empezó. Esta lección da el criterio.
- Clasificar cada feature como hoja o contenedor y detectar el híbrido que suele esconder dos features soldadas.
- Cuantificar el coste de la profundidad: envoltorios por acción, reenvíos manuales de
delegatey anidamiento en los tests. - Aplicar las maniobras de aplanado, desde adoptar al nieto hasta usar
StackStatepara colapsar la navegación. - Separar la profundidad del árbol de vistas de la profundidad del árbol de reducers, que no tienen por qué coincidir.
Dos especies de feature
Casi todo reducer sano pertenece a una de dos categorías puras, y saber a cuál pertenece el que estás escribiendo resuelve la mayoría de las dudas de diseño sin discusión.
Feature hoja
Su body es un único Reduce. No tiene Scope, ni ifLet, ni forEach. Concentra toda la lógica de una porción coherente del dominio, declara sus dependencias y publica un enum Delegate con los hechos que le importan al exterior. Se prueba entera con un TestStore sin montar nada alrededor.
Feature contenedor
Su body es casi todo ensamblaje: una lista de operadores de composición y, al final, un Reduce delgado que solo reconoce casos delegate de sus hijos. No tiene lógica de dominio propia porque su dominio es la coordinación.
El híbrido sospechoso
Mucha lógica propia y varios hijos a la vez. Casi siempre son dos features soldadas: una hoja con su dominio y un contenedor con su ensamblaje. Partirlas en dos tipos suele eliminar más código del que añade.
El contenedor canónico se reconoce a simple vista porque su Reduce final no menciona nada que no sea un delegate:
var body: some ReducerOf<Self> {
Scope(state: \.cabecera, action: \.cabecera) { Cabecera() }
Reduce { state, action in
switch action {
case let .lista(.delegate(.filaSeleccionada(id))):
state.detalle = Detalle.State(id: id)
return .none
case .lista, .cabecera, .detalle:
return .none
}
}
.forEach(\.lista, action: \.lista) { Fila() }
.ifLet(\.$detalle, action: \.detalle) { Detalle() }
}
De ahí sale la prueba diagnóstica más útil del nivel: si el Reduce de un contenedor reconoce algo que no sea un caso delegate, está haciendo el trabajo de alguno de sus hijos. O bien esa lógica pertenece al hijo y hay que bajarla, o bien el hijo no debió ser una feature aparte.
El coste real de la profundidad
La profundidad no es cara por elegancia: es cara por aritmética. Cada nivel añade un envoltorio a toda acción que lo atraviese, y ese envoltorio se paga en cuatro monedas distintas.
La primera es sintáctica. Una acción nacida a cuatro niveles llega a la raíz como .pestana(.lista(.fila(id, .delegate(.seleccionada)))), y la profundidad del patrón es una medida literal de la distancia de acoplamiento entre quien emite y quien escucha.
La segunda, y la peor, es el reenvío manual. Si el nieto emite un delegate que le importa al abuelo, hay dos salidas y ninguna es gratis. O el abuelo hace patrón cuatro niveles hacia dentro, y entonces los intermedios dejan de encapsular nada; o cada nivel intermedio declara su propio caso delegate y lo reemite, lo que significa escribir esto una vez por escalón:
// en el nivel intermedio: puro reenvío, N veces
case .fila(.delegate(.seleccionada)):
return .send(.delegate(.filaSeleccionada))
Ese bloque es intrascendente y multiplicativo: cada evento nuevo cuesta tantas reescrituras como niveles haya. Cuando lo veas aparecer tres veces en el mismo trayecto, no lo estás escribiendo, lo estás pagando.
La tercera es el testing. Las afirmaciones del TestStore heredan exactamente el mismo anidamiento, y un store.receive con cuatro tramos deja de leerse como documentación para leerse como un acertijo. La cuarta es la observabilidad: la salida de _printChanges se vuelve una columna de sangrías donde ya no distingues qué feature hizo qué.
flowchart TD R[Raiz] --> P[Pestana] P --> L[Lista] L --> F[Fila] F --> E[Etiqueta] E -. delegate reenviado cuatro veces .-> R R --> S[StackState de destinos] S --> D1[Detalle] S --> D2[Ajustes] S --> D3[Perfil] style E fill:#f38ba8,color:#11111b style S fill:#a6e3a1,color:#11111b
Tres maniobras de aplanado
La primera es preferir la anchura. Un contenedor con cinco hijos directos es más simple que una cadena de cinco niveles, y la asimetría tiene una explicación exacta: la anchura se paga una sola vez, al declarar el State y el Action del contenedor, mientras que la profundidad se paga en cada acción que atraviesa el árbol y en cada delegate que hay que reenviar.
La segunda es adoptar al nieto. Cuando un nivel intermedio no hace nada salvo reenviar —su Reduce es todo reemisión y su State no tiene campos propios—, ese nivel no es una feature, es una tubería con nombre. Bórralo y deja que el abuelo tenga al nieto como hijo directo. El diagrama pierde una caja y la app pierde una clase entera de errores.
La tercera es dejar de confundir navegación con anidamiento. Aquí está el malentendido caro del nivel: mucha gente construye un árbol de reducers que replica el árbol de pantallas, de modo que una pila de cinco pantallas produce cinco niveles de composición. No hace falta, y StackState existe precisamente para evitarlo:
@Reducer
enum Camino {
case detalle(Detalle)
case ajustes(Ajustes)
case perfil(Perfil)
}
@Reducer
struct AppFeature {
@ObservableState
struct State: Equatable {
var raiz = Lista.State()
var camino = StackState<Camino.State>()
}
enum Action {
case raiz(Lista.Action)
case camino(StackAction<Camino.State, Camino.Action>)
}
var body: some ReducerOf<Self> {
Scope(state: \.raiz, action: \.raiz) { Lista() }
Reduce { state, action in
switch action {
case let .raiz(.delegate(.filaSeleccionada(id))):
state.camino.append(.detalle(Detalle.State(id: id)))
return .none
default:
return .none
}
}
.forEach(\.camino, action: \.camino) { Camino() }
}
}
Lee el State: la pila es un StackState<Camino.State>, es decir, una colección plana. Detalle, Ajustes y Perfil están todos al mismo nivel de composición aunque el usuario los recorra uno detrás de otro, y una acción emitida en la quinta pantalla llega a la raíz con dos envoltorios, no con cinco. La navegación creció; el árbol de reducers, no.
Cuando un dato lo necesitan features lejanas entre sí, la salida no es bajarlo por cinco niveles ni devolverlo por otros cinco con delegate. Eso es tunelar, y produce el mismo reenvío multiplicativo de antes disfrazado de arquitectura. Para ese caso existe @Shared, que da acceso directo con semántica de valor y sigue siendo testeable, y que el Nivel 25 trata a fondo. La regla para no abusar es simple: usa delegate para hechos que ocurren, y estado compartido para datos que existen.
El error de diseño que más caro sale en TCA no es usar mal Scope: es dar por hecho que el árbol de features debe parecerse al árbol de vistas. La suposición es comprensible, porque en casi todo lo demás que has usado ambas cosas coinciden por construcción —un view model por pantalla, un controlador por pantalla, un componente por trozo visible— y porque las dos jerarquías se dibujan igual en la pizarra. Pero no son la misma jerarquía y ni siquiera responden a la misma pregunta. El árbol de vistas responde a qué está dentro de qué en la pantalla; el árbol de reducers responde a quién es dueño de qué estado y durante cuánto tiempo. Que una etiqueta viva dentro de una fila que vive dentro de una lista que vive dentro de una pestaña es un hecho de composición visual sin ninguna implicación sobre propiedad: si la etiqueta no tiene estado propio ni ciclo de vida propio, no es una feature, es una vista que recibe datos. Y al revés: dos pantallas que el usuario ve una tras otra pueden ser hermanas en el reducer, como acabas de ver con StackState, porque el orden en que aparecen es una propiedad de la pila y no de la propiedad del estado. La consecuencia práctica de separar ambas jerarquías es liberadora. Un store con scope se puede pasar hacia abajo por todas las vistas que haga falta sin añadir un solo nivel de composición, de modo que una interfaz visualmente profunda puede descansar sobre un árbol de reducers de dos o tres niveles. Y el criterio para crear una feature deja de ser estético —esto se ve como una unidad— y pasa a ser verificable con tres preguntas: ¿tiene estado que le pertenece y que nadie más debería tocar?, ¿tiene un ciclo de vida propio, un momento en que nace y otro en que muere junto con sus efectos?, ¿tiene hechos que contar al exterior que merezcan un delegate? Si las tres respuestas son que sí, es una feature y merece su nivel. Si alguna es que no, casi seguro que estás a punto de añadir un escalón que cobrará peaje a cada acción que pase por ahí durante el resto de la vida del proyecto, a cambio de una caja más bonita en un diagrama que nadie volverá a mirar.
- Dibuja el árbol de reducers de tu app y anota junto a cada nodo si es hoja, contenedor o híbrido, aplicando la prueba del
Reduceque solo reconocedelegate. - Elige la acción que nace en el nodo más profundo y escribe el patrón completo con que la vería la raíz. Cuenta los envoltorios: ese número es tu profundidad efectiva.
- Busca reenvíos puros de
delegate—un caso que solo reemite otro sin tocar estado— y cuenta cuántos hay en el trayecto más largo. Cada uno es un candidato a desaparecer. - Localiza al menos un nivel intermedio sin estado propio y sin ciclo de vida propio. Elimínalo y conecta al nieto como hijo directo del abuelo; comprueba cuántas líneas se van con él.
- Si tu jerarquía replica una pila de navegación, reescríbela con
StackStateyforEachy vuelve a contar los envoltorios del punto dos. Anota la diferencia y valídala corriendo los tests existentes.