Acciones delegate: el contrato explícito del hijo
Si todo ancestro ve todas las acciones de sus descendientes, hace falta una frontera que distinga el vocabulario privado de una feature de lo que esa feature promete públicamente. Esa frontera es el `enum` anidado `delegate`, y esta lección la trata como lo que es: una interfaz publicada, con reglas de uso tan estrictas como las de cualquier API. Verás la anatomía completa del patrón en código, las tres reglas que lo sostienen —el hijo emite pero nunca maneja, nadie más emite, el padre solo mira ahí—, y por qué esta inversión de dependencia hecha con un tipo suma consigue lo mismo que el patrón delegado clásico sin referencias, sin ciclo de vida y sin un solo protocolo.
La lección anterior terminó con una incomodidad deliberada: el padre puede reconocer cualquier acción del hijo, incluida la pulsación de un botón concreto de una pantalla concreta. Puede, y funciona a la primera, y esa comodidad es precisamente el problema. Una feature que puede ser espiada en todos sus movimientos internos deja de ser reemplazable: cualquier renombrado, cualquier división de un caso en dos, cualquier cambio en el momento de emisión rompe a un observador que la feature ni sabía que tenía. TCA no resuelve esto con una restricción del compilador —no la hay— sino con una convención tan sencilla y tan universalmente respetada que se ha vuelto indistinguible de una regla del lenguaje: el enum anidado delegate.
- Separar el vocabulario privado de una feature de su interfaz publicada mediante un
enumanidadodelegate. - Escribir el patrón completo: declaración anotada, emisión desde el hijo y consumo por patrón desde el padre.
- Aplicar las tres reglas del contrato y detectar en revisión de código cuándo alguna se está violando.
- Comparar esta inversión de dependencia con el patrón delegado clásico y enumerar qué elimina exactamente.
El padre mirando donde no debe
Supón un editor de perfil dentro de una lista. Cuando el guardado tiene éxito, la lista debe refrescar la fila y cerrar el editor. La solución rápida es que el padre reconozca la acción interna del hijo:
// ANTIPATRÓN: el padre depende de un detalle interno del hijo
case .editor(.respuestaGuardado(.success)):
state.editor = nil
return .none
Esto compila, funciona y es una trampa. respuestaGuardado es vocabulario doméstico del editor: describe cómo el editor habla consigo mismo sobre su propia llamada de red. El día que el editor añada un reintento, o divida la respuesta en dos casos, o guarde en local antes que en remoto, el padre se romperá sin que nadie en el módulo del hijo tuviera forma de saber que alguien dependía de eso. La feature dejó de ser una pieza Lego en el instante en que su interior pasó a ser observable.
La corrección no consiste en prohibir mirar —no hay forma de prohibirlo— sino en dar al hijo un sitio donde declarar qué está dispuesto a que se mire.
Anatomía del contrato
El patrón tiene tres partes y ninguna es opcional. Primero, un enum anidado dentro del Action del hijo, anotado con @CasePathable porque la macro @Reducer solo anota el enum de nivel superior:
@Reducer
struct EditorPerfil {
@ObservableState
struct State: Equatable {
var perfil: Perfil
var guardando = false
}
enum Action {
case delegate(Delegate)
case guardarPulsado
case cancelarPulsado
case guardadoConExito(Perfil)
case guardadoFallido
@CasePathable
enum Delegate: Equatable {
case perfilGuardado(Perfil)
case edicionDescartada
}
}
@Dependency(\.apiClient) var apiClient
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .delegate:
return .none // el hijo NUNCA maneja lo suyo
case .guardarPulsado:
state.guardando = true
return .run { [perfil = state.perfil] send in
try await apiClient.guardar(perfil)
await send(.guardadoConExito(perfil))
} catch: { _, send in
await send(.guardadoFallido)
}
case let .guardadoConExito(perfil):
state.guardando = false
return .send(.delegate(.perfilGuardado(perfil)))
case .guardadoFallido:
state.guardando = false
return .none
case .cancelarPulsado:
return .send(.delegate(.edicionDescartada))
}
}
}
}
Segundo, el padre reconoce únicamente el caso delegate y trata todo lo demás con un comodín:
case let .editor(.delegate(.perfilGuardado(perfil))):
state.filas.actualizar(perfil)
state.editor = nil
return .none
case .editor(.delegate(.edicionDescartada)):
state.editor = nil
return .none
case .editor:
return .none // el resto no es asunto del padre
Y tercero, el nombrado. Compara guardarPulsado con perfilGuardado. El primero describe un gesto sobre un control; el segundo describe un hecho del dominio, en pasado y sin mencionar interfaz. Esa diferencia de registro no es estilística: un nombre de dominio sobrevive a un rediseño completo de la pantalla, un nombre de interfaz no.
Las tres reglas del contrato
| Familia de acciones | Quién puede emitirla | Quién puede observarla | Papel |
|---|---|---|---|
| Eventos de la vista | solo el hijo | solo el hijo | vocabulario privado |
| Respuestas de efectos | solo el hijo | solo el hijo | vocabulario privado |
Casos de delegate |
solo el hijo | el padre | cualquier ancestro | interfaz publicada |
De esa tabla salen las tres reglas. La primera: el hijo emite acciones delegate pero jamás las maneja; su rama en el switch es siempre case .delegate: return .none. Si el hijo reaccionara a su propio delegate, el canal dejaría de ser de salida pura y su semántica se volvería ambigua para todos. La segunda: nadie fuera del hijo emite una acción delegate del hijo; enviarla desde el padre sería falsificar un hecho que el padre no presenció. La tercera: el padre solo hace patrón contra delegate; cualquier otro patrón contra el interior del hijo es el antipatrón de la primera sección, sin excepciones que valga la pena defender.
El contrato, además, se prueba solo, y esa es la señal más fiable de que la frontera está bien puesta. El test del hijo afirma que emite el hecho, sin saber quién escucha; el test del padre afirma qué hace cuando el hecho llega, sin arrancar al hijo:
// Test del hijo: solo verifica que publica el hecho
await store.send(.guardadoConExito(perfil)) {
$0.guardando = false
}
await store.receive(\.delegate.perfilGuardado)
// Test del padre: inyecta el hecho directamente, sin hijo real
await store.send(.editor(.delegate(.perfilGuardado(perfil)))) {
$0.filas.actualizar(perfil)
$0.editor = nil
}
Dos pruebas independientes, ninguna de las cuales necesita a la otra mitad. Si te ves obligado a montar el hijo entero para probar al padre, o a simular al padre para probar al hijo, el contrato no existe todavía: hay acoplamiento en alguna parte.
Un buen caso delegate nombra algo que ocurrió en el dominio y adjunta el dato mínimo necesario para que un observador actúe: perfilGuardado(Perfil), elementoEliminado(UUID), sesionCaducada. Un mal caso delegate nombra lo que el padre debería hacer: cierraElEditor, refrescaLaLista, navegaAAjustes. La diferencia es quién decide. Si el hijo emite hechos, cada padre decide qué hacer con ellos y el mismo hijo sirve en tres contextos distintos; si el hijo emite órdenes, ha vuelto a saber dónde vive y acabas de perder toda la propiedad Lego por la puerta de atrás. Y para el caso concreto de cerrarse a sí mismo existe una herramienta mejor que un caso delegate: la dependencia dismiss, que permite al hijo terminar su propia presentación sin nombrar a nadie.
Vale la pena poner las dos versiones una al lado de la otra, porque resuelven el mismo problema con ontologías opuestas y la comparación revela qué se está comprando exactamente. En el patrón delegado de toda la vida, el hijo declara un protocolo con los métodos que puede llamar y guarda una referencia débil a quien lo implemente; el padre se registra, implementa los métodos y, cuando algo ocurre, el hijo invoca el método directamente sobre esa referencia. La inversión de dependencia es correcta —la interfaz vive con el hijo, no con el padre— y por eso el patrón ha sobrevivido cuarenta años. Pero mira todo lo que arrastra: hay un puntero, así que hay ciclo de vida, así que hay weak y hay riesgo de ciclo de retención; hay como mucho un delegado, así que dos interesados exigen un mecanismo distinto; la llamada es síncrona y ocurre dentro de la pila del hijo, así que el padre puede reentrar en el hijo a mitad de una mutación; y el evento no existe como dato en ninguna parte, así que no se puede registrar, ni serializar, ni reproducir, ni afirmar en un test sin montar un doble. La versión de TCA conserva íntegra la inversión —el enum Delegate se declara dentro del hijo, en el módulo del hijo, y es el padre quien se adapta a él— y sustituye el puntero por un valor. El hijo no llama a nadie: emite un caso, que es un dato inerte, y lo suelta en el flujo. No sabe si hay cero observadores o cinco, no puede reentrar en nadie porque no invoca nada, y no necesita liberar ninguna referencia porque nunca tuvo ninguna. Lo que gana el sistema con ese cambio es desproporcionado respecto a lo modesto de la sintaxis: el evento aparece en el registro de _printChanges como cualquier otra acción, se afirma en un TestStore con la misma línea con que se afirma un incremento, se reproduce replicando una lista de acciones, y admite tantos oyentes como niveles tenga el árbol sin que ninguno estorbe a los demás. Y sin embargo lo verdaderamente importante no es ninguna de esas comodidades, sino el hecho estructural que las produce: al convertir la comunicación entre features en un valor que atraviesa un canal único, TCA elimina la última categoría de conexión oculta que quedaba en la aplicación. No hay ya nada entre dos features que no sea un dato con nombre viajando por un sitio que puedes mirar.
- Busca en tu app un
switchde un padre que reconozca una acción interna de un hijo —una pulsación, una respuesta de red— y anótala. - Traduce cada uno de esos patrones a un hecho del dominio en pasado, sin vocabulario de interfaz, y declara con ellos un
enum Delegateanotado con@CasePathabledentro delActiondel hijo. - Emítelos desde el hijo con
.senden los puntos exactos donde el hecho ocurre, y añade la ramacase .delegate: return .nonepara cumplir la primera regla. - Reescribe el padre para que solo haga patrón contra
.delegatey cierre con un comodín. Comprueba que el comportamiento observable no ha cambiado en absoluto. - Verifica que el contrato aguanta: renombra una acción interna del hijo y divide uno de sus casos en dos. Si el padre sigue compilando y funcionando sin tocarlo, la frontera está bien puesta.