wandres.dev
MODELAR LA ACTION · enums que cuentan qué pasó

Separar orígenes: view, _internal y delegate

Cuando una feature crece, su enum de acciones se convierte en un cajón donde conviven cosas de naturaleza incompatible: gestos del usuario, respuestas de la red, resultados de relojes y mensajes destinados al padre. La convención madura de TCA consiste en partir ese cajón en tres subenums según el origen y la audiencia de cada suceso: view para lo que la vista puede enviar, _internal para lo que solo el propio reducer produce, y delegate para lo que la feature comunica hacia arriba sin saber quién escucha. Esta lección justifica la partición, muestra las invariantes que cada grupo debe respetar y explica por qué delegate es la pieza que impide que un hijo conozca a su padre.

⏱ 18 min

Un enum Action plano funciona bien mientras la feature es pequeña, y deja de funcionar exactamente cuando más lo necesitas. A los treinta casos el switch mezcla el toque de un botón con la respuesta de una llamada de red y con un mensaje que en realidad iba dirigido al padre, y nada en el tipo distingue quién tenía derecho a enviar cada uno. La convención que Point-Free consolidó como práctica estándar consiste en partir la Action según dos preguntas que el nombre plano oculta: de dónde viene el suceso y para quién es. Las respuestas producen tres grupos —view, _internal y delegate— y esa partición convierte una lista amorfa en un contrato con tres cláusulas verificables.

🎯 Al terminar esta lección sabrás
  • Partir la Action de una feature en subenums por origen y audiencia.
  • Restringir la superficie que la vista puede enviar mediante el grupo view.
  • Aislar en _internal los sucesos que solo el propio reducer tiene derecho a producir.
  • Usar delegate para que el hijo hable hacia arriba sin conocer jamás a su padre.

Tres orígenes, tres audiencias

La partición no es decorativa: cada grupo obedece invariantes distintas sobre quién puede enviarlo y quién debe manejarlo.

Grupo Quién lo envía Quién lo maneja
view Solo la vista de esta feature El reducer de esta feature
_internal Solo efectos de esta feature El reducer de esta feature
delegate Solo el reducer de esta feature El reducer del padre

La tercera fila es la que sorprende y la que más rinde: el reducer que emite una acción delegate no la maneja, y el que la maneja no la emite. Es la única categoría cuyo emisor y cuyo destinatario están en niveles distintos del árbol.

@Reducer
struct Editor {
  @ObservableState
  struct State: Equatable {
    var texto = ""
    var guardando = false
  }

  enum Action {
    case view(View)
    case _internal(Internal)
    case delegate(Delegate)

    enum View: Equatable {
      case alAparecer
      case textoEditado(String)
      case guardarPulsado
    }
    enum Internal: Equatable {
      case guardadoTerminado(Bool)
      case borradorRecuperado(String)
    }
    enum Delegate: Equatable {
      case documentoGuardado(Documento.ID)
      case edicionCancelada
    }
  }
}
flowchart TD
V[Vista] --> AV[Action view]
AV --> R[Reducer de la feature]
R --> EF[Effect]
EF --> AI[Action _internal]
AI --> R
R --> AD[Action delegate]
AD --> P[Reducer padre]
style AV fill:#a6e3a1,color:#11111b
style AI fill:#f9e2af,color:#11111b
style AD fill:#f38ba8,color:#11111b

view: la única puerta abierta hacia dentro

Agrupar bajo view los sucesos originados en la interfaz cumple dos funciones. La primera es documental: leer ese subenum equivale a leer el inventario completo de gestos que la pantalla admite, sin ruido de infraestructura. La segunda es defensiva: al declarar el grupo se hace evidente que ningún otro emisor debería usarlo, y en la lección siguiente sobre ViewAction verás cómo esa convención social se convierte en una restricción que el compilador impone.

Reduce { state, action in
  switch action {
  case .view(.alAparecer):
    return .run { send in
      await send(._internal(.borradorRecuperado(borradores.leer())))
    }
  case let .view(.textoEditado(nuevo)):
    state.texto = nuevo
    return .none
  case .view(.guardarPulsado):
    state.guardando = true
    return .run { [texto = state.texto] send in
      await send(._internal(.guardadoTerminado(api.guardar(texto))))
    }
  // ...
  }
}

Fíjate en la simetría del flujo: una acción view entra, el reducer devuelve un efecto, y ese efecto solo puede regresar como acción _internal. La vista nunca produce una _internal y el efecto nunca produce una view; cada carril tiene un único sentido.

_internal: sucesos que solo el reducer puede provocar

El grupo _internal recoge lo que ocurre por debajo de la interfaz: la respuesta de una petición, el disparo de un temporizador, la llegada de un elemento de un stream. El guion bajo inicial no tiene significado para el compilador; es una señal convencional, tomada de la costumbre de Swift para lo semiprivado, que declara que ese subenum es asunto interno de la feature y que ningún emisor externo debería construirlo.

Esta separación paga dividendos inmediatos en testing. Un test que envía .view(.guardarPulsado) y afirma que recibe ._internal(.guardadoTerminado(true)) distingue con claridad la causa —un gesto— del efecto —una respuesta del sistema—, mientras que en un enum plano ambas cosas se ven idénticas y la lectura del test no revela la dirección del flujo.

💡
El guion bajo es una advertencia, no una barrera

Swift no impide construir ._internal(...) desde fuera del módulo si el enum es público. La protección real llega por dos vías complementarias: hacer la feature un módulo SPM y no exportar más de lo necesario, y adoptar @ViewAction para que la vista solo pueda enviar el subenum View. El guion bajo comunica la intención; la arquitectura la hace cumplir.

delegate: hablar hacia arriba sin mirar hacia arriba

El grupo delegate resuelve el problema más delicado de la composición: cómo comunica un hijo un desenlace relevante al padre sin adquirir ninguna referencia hacia él. La respuesta de TCA es que el hijo emite un hecho dirigido a nadie en particular, y el padre, que ya enruta las acciones del hijo, lo intercepta.

// En el hijo: emitir, nunca manejar
case ._internal(.guardadoTerminado(true)):
  state.guardando = false
  return .send(.delegate(.documentoGuardado(state.id)))

case .delegate:
  return .none   // el hijo jamás reacciona a sus propias delegate

// En el padre: manejar, nunca emitir
Scope(state: \.editor, action: \.editor) { Editor() }
Reduce { state, action in
  switch action {
  case let .editor(.delegate(.documentoGuardado(id))):
    state.recientes.insert(id, at: 0)
    state.editor = nil
    return .none
  default:
    return .none
  }
}

La invariante es rígida y conviene enunciarla como norma: el hijo emite acciones delegate y devuelve .none para ellas; el padre las maneja y nunca las envía. Cumplirla mantiene la propiedad Lego intacta, porque Editor sigue compilando y corriendo aislado en una preview aunque nadie escuche sus delegaciones. Romperla —dejar que el hijo mute su propio estado al manejar una delegate— crea un canal de doble sentido que reintroduce el acoplamiento que la partición venía a eliminar.

👆

view: hacia dentro

Gestos del usuario. Entran desde la interfaz y mueren en el reducer de la feature.

⚙️

_internal: en circuito

Respuestas del sistema. Nacen de un efecto de la feature y regresan a su propio reducer.

📡

delegate: hacia fuera

Desenlaces notificables. Salen de la feature y solo tienen sentido para quien la contiene.

Partir la Action por origen es introducir tipos direccionales en un sistema sin direcciones

Un enum Action plano tiene un defecto que su simplicidad disimula: todos sus casos son igual de accesibles para todos los emisores, de modo que el tipo no codifica ninguna de las restricciones que la arquitectura sí impone. Nada en case guardadoTerminado(Bool) avisa de que ese caso solo debería nacer de un efecto, y nada en case documentoGuardado(ID) revela que su destinatario legítimo vive un nivel más arriba. Toda esa información existe, es crítica, y vive únicamente en la cabeza de quien escribió la feature; el día que esa persona cambia de equipo, la información se pierde y aparecen los envíos ilegales que nadie sabe justificar. Partir la Action en view, _internal y delegate no añade capacidad expresiva —el conjunto de casos es idéntico— sino que le da al tipo una dimensión que antes no tenía: la dirección. Cada subenum declara un sentido de circulación, y esa declaración convierte reglas orales en estructura verificable, primero por revisión humana y luego, con @ViewAction y con fronteras de módulo, por el compilador. La consecuencia más profunda no es la higiene sino la composición: delegate es literalmente la construcción que permite que un reducer hijo produzca consecuencias en su padre sin poseer ni un puntero, ni un protocolo de delegación, ni un closure de callback hacia él. El hijo enuncia un hecho al vacío y el padre, que ya lo contenía, lo recoge; la asimetría entre quien conoce y quien es conocido queda preservada, y con ella la posibilidad de arrancar cualquier feature en solitario. Por eso esta partición se paga sola en la primera pantalla de tamaño medio y se vuelve indispensable en cuanto la app tiene profundidad: no estás organizando casos por estética, estás introduciendo tipos con dirección en un sistema que, sin ellos, permitía que cualquiera enviara cualquier cosa a cualquier destino.

⚔️ Parte la Action de una feature real
  1. Toma una feature con al menos diez acciones y clasifica cada caso en view, _internal o delegate según quién lo envía y quién lo maneja.
  2. Reescribe la Action con los tres subenums anidados y adapta el switch a los patrones compuestos. Comprueba que sigue siendo exhaustivo.
  3. Verifica la invariante: busca cualquier rama del hijo que maneje una delegate mutando estado y conviértela en return .none.
  4. Lleva esa lógica al padre, interceptando la acción del hijo con el patrón anidado completo, y comprueba que el hijo compila sin conocerlo.
  5. Escribe un test que envíe una view, reciba una _internal y termine emitiendo una delegate. Lee el test: debe narrarse como un recorrido de ida y vuelta.