Comunicación entre niveles: acciones delegate que suben una pila plana
En una pila no hay padres intermedios: hay un único reducer raíz que ve todas las pantallas a la vez, y eso cambia la forma de la comunicación. Esta lección aplica el contrato `delegate` del Nivel 14 a la navegación profunda, muestra cómo la raíz reconoce un hecho ocurrido tres pantallas abajo, cómo escribe en una pantalla que no es la de arriba mediante el subíndice por identidad y caso, cómo combina esa escritura con un salto de retroceso, y por qué la comunicación entre hermanos pasa siempre por el único punto que los ve a todos.
La escena es cotidiana y en cualquier otra arquitectura resulta incómoda: el usuario está en la cuarta pantalla de un flujo, elimina un elemento, y la lista que quedó dos niveles más abajo tiene que enterarse. En un árbol de controladores esa noticia recorre tres fronteras, cada una con su reenvío y su traducción, o bien se resuelve con el atajo sucio de una notificación global que nadie sabe quién escucha. En una pila de TCA no recorre nada, porque no hay nada que recorrer: todas las pantallas apiladas son hermanas dentro de una misma colección y tienen un único padre, el reducer que posee la pila. Esa planitud, que en la primera lección apareció como una economía de envoltorios, se revela aquí como lo que de verdad es: la propiedad estructural que convierte la comunicación a distancia en una operación local.
- Aplicar el contrato
delegatea una pantalla apilada y reconocer su hecho desde la raíz con un patrón de dos envoltorios. - Escribir en cualquier elemento de la pila mediante el subíndice por identidad y caso, sin importar su profundidad.
- Combinar la reacción a un hecho profundo con un salto de retroceso en la misma pasada del reducer.
- Encaminar la comunicación entre pantallas hermanas a través del único reducer que las ve a todas.
Un solo padre para toda la profundidad
Una pantalla apilada no sabe que está apilada, y por tanto se comunica igual que cualquier hijo del Nivel 14: emite hechos del dominio por su canal publicado y no llama a nadie.
@Reducer
struct Resena {
enum Action {
case delegate(Delegate)
case bloquearPulsado
case borrarPulsado
@CasePathable
enum Delegate: Equatable {
case autorBloqueado(Autor.ID)
case resenaBorrada(Resena.ID)
}
}
// el hijo emite pero nunca maneja lo suyo
}
Lo que cambia respecto a la navegación en árbol es quién escucha. En un árbol, cada nivel puede interceptar el hecho de su hijo y traducirlo a un hecho propio antes de subirlo, de modo que el mensaje se reescribe en cada frontera. En una pila no hay fronteras intermedias: el hecho de la cuarta pantalla llega al reducer raíz con exactamente dos capas, el caso de la pila y el caso del elemento, igual que llegaría el de la primera.
case let .camino(.element(id: _, action: .resena(.delegate(.autorBloqueado(autorID))))):
state.autoresBloqueados.insert(autorID)
return .none
Si la reacción no depende de qué pantalla concreta emitió el hecho, ignora el id con un guion bajo como en el ejemplo. Si sí depende —porque vas a mutar al emisor o a truncar desde él—, ligalo. Y recuerda cerrar siempre con case .camino: return .none, porque la raíz recibe también las acciones internas de todas las pantallas y el contrato exige no mirarlas.
Escribir en una pantalla que no es la de arriba
La consecuencia práctica de que la pila sea un valor plano es que la raíz puede leer y escribir cualquier elemento, no solo el último. El subíndice acepta la identidad y, opcionalmente, un key path de caso que estrecha el tipo a la feature concreta.
// Leer un elemento cualquiera de la pila
state.camino[id: id]
// Estrecharlo a un caso y mutarlo en su sitio
state.camino[id: idDelCatalogo, case: \.catalogo]?.filtro = .recientes
// Recorrer la pila buscando a quién avisar
for id in state.camino.ids {
state.camino[id: id, case: \.listaResenas]?.ocultar(autorID)
}
Esa última forma responde a la escena de la introducción sin ninguna coreografía: el bloqueo ocurrido arriba se propaga hacia abajo escribiendo directamente en los estados que deben cambiar. No hay mensajes descendentes, ni suscripciones, ni delegados inversos, porque no hace falta comunicar nada a nadie: las pantallas de más abajo son estado que la raíz tiene delante y puede modificar como modificaría un campo propio.
El key path de caso que acompaña al identificador cumple una función que va más allá de la comodidad: estrecha el tipo. Sin él obtendrías el State del enum entero y tendrías que abrirlo a mano; con él, la expresión devuelve un opcional del estado de esa feature concreta, que vale nulo si el elemento existe pero es de otro caso. La escritura encadenada con el opcional se convierte entonces en una operación segura por construcción: si la pantalla que buscabas no está donde creías, no pasa nada, y desde luego no pasa nada silenciosamente incorrecto.
| Dirección | Mecanismo | Quién lo ejecuta |
|---|---|---|
| Pantalla profunda hacia la raíz | caso delegate | acción de la pila |
el hijo emite, la raíz reconoce |
| Raíz hacia una pantalla cualquiera | subíndice por id y caso | la raíz escribe en el estado |
| Entre pantallas hermanas | ambos, encadenados | la raíz traduce el hecho en escritura |
El patrón completo: reaccionar tres niveles abajo
La reacción interesante casi nunca es solo escribir: es escribir y además recolocar al usuario. Ambas cosas ocurren en la misma pasada del reducer, sobre el mismo valor, y por tanto la interfaz solo ve el resultado final.
case let .camino(.element(id: id, action: .resena(.delegate(.resenaBorrada(resenaID))))):
// 1. Actualizar la lista que quedó dos niveles más abajo
for otroID in state.camino.ids {
state.camino[id: otroID, case: \.listaResenas]?.resenas.remove(id: resenaID)
}
// 2. Y sacar al usuario de la pantalla que acaba de dejar de tener sentido
state.camino.pop(from: id)
return .none
Merece la pena señalar que el orden de las dos mutaciones no importa, y que eso también es una propiedad y no una casualidad. Como todo ocurre dentro de una única pasada síncrona del reducer, la interfaz no observa ningún estado intermedio: no existe un instante en que la reseña esté borrada de la lista pero la pantalla siga en pie, ni al revés. La atomicidad, que en el modelo imperativo hay que construir con cuidado para evitar parpadeos, aquí viene dada por la forma misma de la función de reducción.
Dos mutaciones, una acción, ninguna orden de navegación. La pantalla de la reseña borrada desaparece junto con todo lo que tuviera encima, la lista de abajo aparece ya sin la fila, y SwiftUI anima la diferencia entre el antes y el después. Nada de esto exige que la reseña conozca a la lista, ni que la lista sepa que existe una pantalla de detalle: el único que conoce a ambas es quien posee la colección.
Dos capas, no cinco
El hecho de la pantalla más profunda llega a la raíz con los mismos envoltorios que el de la primera.
Escritura por identidad
El subíndice con id y caso permite mutar cualquier elemento apilado, no solo el visible.
Hermanas vía la raíz
Dos pantallas del flujo se comunican a través del único reducer que las ve a las dos.
Reacción atómica
Escribir abajo y truncar la pila ocurren en la misma pasada; la vista solo ve el resultado.
flowchart TD P4[Pantalla 4 Resena] -->|delegate resenaBorrada| R[Reducer raiz] R -->|escribe por id y caso| P2[Pantalla 2 Lista] R -->|pop from id| T[La pila se trunca] P2 --> V[La vista refleja el nuevo valor] T --> V style R fill:#89b4fa,color:#11111b style V fill:#a6e3a1,color:#11111b
El testing hereda esa planitud y se vuelve casi aburrido, que es el mejor elogio posible para una prueba de navegación. No hace falta montar las cuatro pantallas ni simular el recorrido: se inyecta el hecho en el id que corresponda y se afirma el estado resultante de toda la pila.
await store.send(.camino(.element(id: 3, action: .resena(.delegate(.resenaBorrada(rid)))))) {
$0.camino[id: 1, case: \.listaResenas]?.resenas.remove(id: rid)
$0.camino.pop(from: 3)
}
Conviene medir lo que se ha ganado, porque el problema que aquí se disuelve es uno de los más antiguos y peor resueltos del desarrollo de interfaces: cómo hace una parte de la aplicación para enterarse de algo que ocurrió en otra parte lejana. El catálogo histórico de respuestas es un muestrario de compromisos malos. Los centros de notificaciones globales desacoplan tanto que nadie puede saber quién escucha ni en qué orden, y un fallo se investiga leyendo la aplicación entera. Los delegados encadenados obligan a cada nivel intermedio a conocer y reenviar mensajes que no le incumben, de modo que añadir un dato al hecho original exige tocar cuatro archivos. Los objetos compartidos inyectados por todas partes convierten cualquier lectura en una pregunta sobre quién más pudo escribir y cuándo. Todos comparten el mismo supuesto no examinado: que la distancia entre dos pantallas en la interfaz implica distancia entre ellas en el programa. StackState rompe ese supuesto. La cuarta pantalla y la segunda están a distancia cero en el tipo, porque las dos son elementos de la misma colección dentro del mismo estado, y esa colección la posee un reducer que las tiene ambas delante. El hecho que ocurre arriba no viaja: se emite una vez, lo recoge quien lo va a atender, y la reacción es una escritura sobre un campo, tan local y tan auditable como incrementar un contador. Lo notable es que esto se consigue sin renunciar al aislamiento —la reseña sigue sin saber que existe una lista, y por eso sigue siendo montable en cualquier otro contexto—, porque el desacoplamiento se sostiene en el contrato delegate y no en la lejanía. Es la combinación difícil de lograr: piezas independientes entre sí, y a la vez un lugar único, concreto y legible donde todas sus relaciones están escritas.
- Elige un flujo tuyo de al menos tres pantallas donde una acción en la última deba afectar a la primera. Declara el
enum Delegatede la pantalla profunda nombrando el hecho en pasado y sin vocabulario de interfaz. - Reconócelo en la raíz con el patrón de dos envoltorios y comprueba que compila sin tocar ninguna pantalla intermedia. Anota cuántos archivos has editado.
- Escribe la reacción usando el subíndice por identidad y caso sobre el elemento que hay que actualizar, recorriendo
idssi no sabes de antemano dónde está. - Añade en la misma rama un salto de retroceso con
pop(from:)y verifica que la interfaz anima una sola transición, no dos encadenadas. - Escribe la prueba inyectando el hecho directamente en el id de la pantalla profunda, sin recorrer el flujo. Si necesitas simular el recorrido para que pase, hay acoplamiento en alguna parte: búscalo.