Estructurar features anidadas: cuándo anidar y cuándo separar
Cuando una feature contiene a otra, su State contiene el State de la hija: la jerarquía de pantallas se convierte en una jerarquía de valores, y el árbol de estado de la aplicación entera acaba siendo un único valor compuesto. Esta lección estudia las tres formas de contención —el hijo siempre presente, el hijo opcional con `@Presents` y la colección con `IdentifiedArrayOf`—, y sobre todo el criterio para decidir cuándo una parte merece ser una feature anidada y cuándo conviene dejarla como campos del padre o como hermana coordinada. Se analizan los costes reales de anidar en exceso —cascadas de comparación, envoltorios de acción y cadenas de rutas— y el problema del dato compartido entre hermanas, que se eleva al padre o se comparte con `@Shared`.
La propiedad que da nombre a la arquitectura tiene una consecuencia estructural que se pasa por alto: si una feature se compone de otras, entonces su State contiene los State de esas otras, y la aplicación entera termina siendo un único valor con la forma exacta de su árbol de pantallas. No hay una capa aparte donde vivan los módulos ni un registro donde se busquen: hay un valor grande cuyos subvalores son las features, y toda la composición de la que habla el nombre empieza siendo composición de datos. Eso convierte una pregunta aparentemente organizativa —¿esto debería ser una feature aparte?— en una pregunta de modelado con consecuencias medibles, porque anidar no es solo crear un archivo nuevo: es decidir que un trozo del valor tiene su propio ciclo de vida, sus propias transiciones y su propia frontera de acciones. Anidar bien reparte la complejidad; anidar por costumbre la multiplica y le añade ceremonia.
- Componer el
Statede un padre con el de sus hijas en las tres formas canónicas de contención. - Aplicar un criterio explícito para decidir si una parte merece ser feature anidada o campos del padre.
- Reconocer los costes reales de la anidación profunda en comparación, acciones y rutas.
- Resolver el dato compartido entre features hermanas elevándolo al padre o usando
@Shared.
Las tres formas de contener un hijo
La primera es la contención incondicional: el hijo existe siempre que existe el padre, y su estado es un campo corriente. Es la forma de las secciones permanentes de una pantalla, de las pestañas de un contenedor, de cualquier parte que no aparece ni desaparece.
@ObservableState
struct State: Equatable {
var cabecera: Cabecera.State = .init()
var listado: Listado.State = .init()
}
La segunda es la contención opcional, para lo que se presenta y se descarta. El campo es un opcional marcado con @Presents, y su valor nulo o no nulo es el hecho de estar presentado.
@ObservableState
struct State: Equatable {
var listado: Listado.State = .init()
@Presents var detalle: Detalle.State?
}
La tercera es la contención múltiple, para las colecciones de instancias homogéneas: una fila por elemento, cada una con su propio estado. Aquí IdentifiedArrayOf no es una preferencia estilística sino un requisito de la composición, porque la acción de un hijo debe dirigirse por identificador estable y no por índice.
@ObservableState
struct State: Equatable {
var filas: IdentifiedArrayOf<Fila.State> = []
}
Las tres tienen su operador hermano en el reducer —Scope, ifLet y forEach— pero lo importante en este nivel es notar que la decisión ya está tomada en el State. La forma del dato determina la forma de la composición, el ciclo de vida de los efectos del hijo y qué garantías obtienes gratis: en la opcional, poner el campo a nulo cancela los efectos del hijo; en la múltiple, eliminar un elemento cancela los suyos. La gestión de ciclo de vida que en otras arquitecturas exige código explícito aquí es una consecuencia de la estructura del valor.
Hay una cuarta forma que se olvida al enumerarlas y que en realidad es la más importante de reconocer: la contención excluyente, donde el hijo es uno de varios posibles y se modela con un enum de destino. Es la aplicación directa de la lección sobre estados imposibles al terreno de la composición, y evita el error de declarar tres opcionales cuando solo uno puede estar presente a la vez.
@ObservableState
struct State: Equatable {
var listado: Listado.State = .init()
@Presents var destino: Destino.State?
}
Con un solo hueco de destino es aritméticamente imposible tener el detalle y el editor presentados simultáneamente, mientras que con tres opcionales independientes el tipo permite ocho combinaciones de las que siete son absurdas. La misma disciplina de cardinalidad que aplicaste a los booleanos se aplica, sin cambios, a los hijos.
El criterio para anidar
Una parte merece ser feature anidada cuando cumple simultáneamente tres condiciones, y conviene ser estricto porque el falso positivo es caro.
Tiene lógica propia
Responde a acciones y produce efectos que solo le conciernen. Si únicamente muestra datos y notifica toques hacia arriba, es una vista con parámetros, no una feature.
Tiene ciclo de vida propio
Aparece, desaparece, se repite o se reutiliza en más de un sitio. Si nace y muere exactamente con el padre y solo existe una vez, la frontera no está comprando nada.
La tercera condición es que su estado sea razonablemente autónomo: que el conjunto de datos que necesita se pueda enunciar sin arrastrar medio estado del padre. Cuando una supuesta hija necesita leer siete campos del padre para funcionar, no es una hija, es una vista sobre el estado del padre, y forzar la anidación produce el peor de los mundos: duplicación de datos hacia abajo, sincronización hacia arriba por acciones de delegado y dos fuentes de verdad para lo mismo.
El caso contrario también existe y es igual de frecuente. Un padre cuyo State acumula treinta campos que se agrupan claramente en tres bloques sin interacción entre sí está pidiendo a gritos tres features anidadas; el síntoma es un reducer con un switch de cincuenta casos donde ninguna rama toca campos de más de un bloque. La separación no se justifica por el tamaño del archivo sino por la ausencia de acoplamiento: si dos grupos de campos nunca se leen juntos en la misma rama, ya son dos dominios distintos conviviendo en una struct.
Existe una prueba operativa que zanja la discusión mejor que cualquier criterio abstracto, y consiste en intentar escribir el constructor del hijo. Si puedes construir su State inicial enumerando datos que le pertenecen y que tienen sentido por sí solos, la frontera existe. Si al escribirlo te encuentras pasando seis parámetros que son copias de campos del padre, la frontera no existe y la estás fabricando: lo que tienes no es una hija, es una proyección del padre, y su sitio es una vista que recibe lo que necesita o un grupo de propiedades computadas.
El error más común es crear una feature porque una parte de la interfaz se reutiliza. Reutilizar apariencia es asunto de vistas con parámetros, no de dominios. Crea una feature cuando lo que se repite es el comportamiento —el mismo estado, las mismas acciones, los mismos efectos—; si lo único que se repite es el aspecto, extrae una vista y pásale los datos que necesite.
Lo que cuesta anidar de más
Cada nivel de anidamiento tiene un precio acumulativo que conviene tener presente. La acción del nieto viaja envuelta dos veces antes de llegar a la raíz, y cada envoltorio es un caso más en un enum intermedio que alguien tiene que escribir y mantener. Las rutas de acceso se alargan y las de caso también, y un cambio en la estructura de un hijo obliga a tocar los operadores de composición de todos sus ascendientes. La comparación de igualdad del padre desciende recursivamente por todo el subárbol, de modo que un State raíz muy profundo es también un State caro de comparar. Y la depuración se resiente: seguir el rastro de una acción a través de cinco envoltorios es medible en minutos de lectura.
enum Action {
case listado(Listado.Action)
}
// En Listado:
enum Action {
case fila(IdentifiedActionOf<Fila>)
}
Una acción originada en una fila llega a la raíz como .listado(.fila(.elemento(id, .favoritoPulsado))), y cualquier lógica del ancestro que quiera reaccionar a ella tiene que reconocer ese patrón completo. Con dos niveles se lee sin esfuerzo; con cinco, la expresión ocupa una línea entera y deja de comunicar nada. Ese deterioro de legibilidad es el indicador más fiable de que hay un nivel de más.
Nada de esto condena la anidación —es la forma en que TCA escala— pero sí desaconseja la anidación decorativa, esa que envuelve tres campos y dos acciones en una feature entera porque parecía más ordenado. La regla práctica es que cada frontera debe pagar su ceremonia con una simplificación real del padre: si tras extraer la hija el reducer del padre no encogió, la extracción no compró nada y solo añadió indirección.
Queda el problema que aparece en cuanto hay más de una hija: el dato compartido entre hermanas. Dos features de un mismo padre necesitan el mismo usuario, el mismo carrito, la misma preferencia. La tentación es que cada una guarde su copia y se sincronicen por acciones de delegado, y eso es exactamente el estado duplicado que la lección anterior desmontó, ahora repartido entre módulos y por tanto más difícil de auditar.
@ObservableState
struct State: Equatable {
var usuario: Usuario
var perfil: Perfil.State
var ajustes: Ajustes.State
}
La primera respuesta, y la más barata, es elevar el dato al ancestro común y hacer que las hijas lo reciban al construirse o lo lean a través del alcance que el padre les da. La segunda, cuando el dato debe ser realmente compartido y escribible desde varios sitios o persistido, es @Shared, que introduce una referencia con semántica controlada dentro de un mundo de valores y que se estudia en su propio nivel. Lo que nunca es respuesta es la copia sincronizada a mano: dos verdades separadas por una frontera de módulo divergen antes y peor que dos campos de la misma struct.
La elevación tiene un límite práctico que conviene anticipar. Si un dato hay que subirlo cuatro niveles para encontrar el ancestro común de quienes lo usan, todos los niveles intermedios acaban transportando información que no les concierne, y eso es un olor tan malo como la duplicación. Cuando la distancia entre los consumidores es grande, @Shared deja de ser una comodidad y pasa a ser la opción estructuralmente correcta: reconoce explícitamente que ese dato no pertenece a ninguna feature en particular sino a la aplicación, y evita convertir a media jerarquía en cartero.
Cuando una hija necesita comunicar algo al padre, el canal correcto es un subenum de acciones de delegado que el padre observa. No es un truco para reconectar lo que separaste: es la declaración explícita de qué parte del comportamiento de la hija le importa al mundo exterior. Una hija con un delegado de dos casos tiene una frontera limpia; una con doce casos está pidiendo que revises si la separación tenía sentido.
flowchart TB P[State del padre] --> H1[Hija incondicional con Scope] P --> H2[Hija opcional con Presents e ifLet] P --> H3[Coleccion de hijas con forEach] P --> DC[Dato compartido elevado al padre] DC --> H1 DC --> H3 H2 -.->|al asignar nil se cancelan sus efectos| X[Ciclo de vida derivado del valor] style P fill:#89b4fa,color:#11111b style DC fill:#a6e3a1,color:#11111b
Hay una equivalencia que da vértigo cuando se ve por primera vez: en TCA el organigrama de tu aplicación no está en los directorios ni en los módulos de Swift, está en la forma del valor raíz. Cada frontera que dibujas al decidir que algo es una feature anidada es simultáneamente una frontera de datos, de acciones, de efectos, de ciclo de vida, de testabilidad y de reutilización, porque las seis se derivan de la misma estructura. Eso es lo que hace la decisión tan potente y tan poco reversible. Una frontera bien puesta te regala seis propiedades a la vez: el subárbol se puede testear en aislamiento, sus efectos se cancelan solos cuando el valor desaparece, su lógica no puede tocar lo que está fuera de su alcance porque el reducer literalmente no lo recibe, y montarlo en otro sitio consiste en incrustar su tipo en otro valor. Una frontera mal puesta te cobra las seis: obliga a duplicar datos que estaban al otro lado, a inventar acciones de delegado para volver a unir lo que separaste, a sincronizar copias que no deberían existir y a razonar sobre un ciclo de vida que ya no coincide con el del dominio. Por eso la pregunta de si algo debe ser una feature no se responde mirando cuántas líneas tiene el archivo, sino mirando el dato: si el conjunto de información que esa parte necesita se puede cerrar sobre sí mismo, la frontera existe en el dominio y tú solo la estás transcribiendo; si para cerrarla tienes que arrastrar campos ajenos o abrir canales de vuelta, la frontera no estaba ahí y la estabas inventando. Modelar bien el árbol de estado es, literalmente, diseñar la arquitectura, y todo lo demás en TCA —los operadores de composición, la navegación como dato, el testing exhaustivo— es maquinaria al servicio de una decisión que se toma dibujando la forma de un valor.
- Dibuja el árbol de
Statede una pantalla compuesta real, indicando para cada hija si su contención es incondicional, opcional o múltiple. - Somete cada hija a las tres condiciones: lógica propia, ciclo de vida propio y estado autónomo. Marca las que solo cumplen dos.
- Busca hijas que lean muchos campos del padre. Pregúntate si son features o vistas con parámetros, y colapsa las que no se sostengan.
- Localiza cualquier dato duplicado entre hermanas y elévalo al ancestro común. Elimina las acciones de delegado que existían solo para sincronizarlo.
- Cuenta cuántos envoltorios atraviesa la acción más profunda hasta la raíz. Si son más de tres, revisa si algún nivel intermedio existe solo por orden estético.