Las acciones cuentan qué pasó, no qué hacer
El error más caro al empezar con TCA no vive en el reducer sino en el nombre de los casos de la Action: escribirlos como órdenes en infinitivo —cargarDatos, guardar, mostrarAlerta— en lugar de como hechos consumados. Esta lección demuestra que esa diferencia gramatical no es cuestión de gusto sino de reparto de poderes: una acción imperativa presupone la decisión y la traslada a quien la envía, mientras que una acción declarativa se limita a testificar un suceso y deja intacta la potestad del reducer. Verás el paralelismo exacto con la distinción entre comando y evento en event sourcing, la convención de nombres de Point-Free, el olor característico del enum imperativo y el procedimiento para refactorizarlo sin alterar el comportamiento.
Toda arquitectura unidireccional descansa sobre un reparto de poderes: la vista observa y testifica, el reducer delibera y muta, el runtime ejecuta. La Action es la frontera exacta donde ese reparto se firma o se traiciona, y se traiciona con una sola palabra mal elegida. Un caso llamado cargarDatos es una orden: quien lo envía ya resolvió que hay que cargar, y el reducer queda degradado a ejecutor obediente. Un caso llamado alAparecer es un hecho: la vista levanta acta de lo ocurrido y el reducer conserva íntegra la facultad de decidir si eso implica cargar, no hacer nada o cancelar lo anterior. La diferencia parece cosmética; es constitucional.
- Distinguir una acción que testifica un hecho de una que emite una orden, y nombrar en consecuencia.
- Explicar por qué el nombre imperativo filtra decisiones desde el reducer hacia la vista.
- Aplicar la convención de Point-Free: sujeto, suceso y tiempo pasado en cada caso del
enum. - Refactorizar un
enumde acciones imperativo sin alterar el comportamiento observable.
Testificar frente a ordenar
Compara dos versiones del mismo dominio. La primera está escrita como una lista de métodos disfrazada de enum; la segunda, como un registro de sucesos.
// Imperativa: cada caso es una orden
enum Action {
case cargarDatos
case guardar
case mostrarAlerta(String)
case navegarADetalle(Articulo.ID)
}
// Declarativa: cada caso es un hecho
enum Action {
case alAparecer
case guardarPulsado
case guardadoFallo(String)
case filaPulsada(Articulo.ID)
}
La lista imperativa contiene decisiones ya tomadas. mostrarAlerta presupone que la alerta debe mostrarse, y por tanto alguien —la vista, un efecto, otra feature— tuvo que evaluar la condición antes de enviarla. La lista declarativa contiene únicamente observaciones: el guardado falló con un mensaje, y si eso merece una alerta, un banner o un reintento silencioso lo dictamina el reducer, que es el único lugar donde esa política puede vivir sin duplicarse.
El paralelismo con el modelado de dominios es exacto. En event sourcing y en CQRS se distingue con precisión entre un comando —una petición en imperativo que el sistema puede rechazar— y un evento —un hecho en pasado que ya ocurrió y no admite negociación—. La Action de TCA es un evento, nunca un comando. Es un registro append-only de lo que sucedió, y por eso el reducer se comporta como el intérprete de ese registro y no como su destinatario.
La consecuencia práctica más inmediata es la reutilización. Un hecho admite respuestas distintas en contextos distintos sin cambiar de nombre: alAparecer puede significar cargar la primera vez, refrescar si los datos caducaron o no hacer absolutamente nada si ya hay una petición en vuelo, y las tres políticas conviven en la misma rama del reducer sin que la vista sepa cuál se aplicó.
case .alAparecer:
guard !state.cargando, state.articulos.isEmpty else { return .none }
state.cargando = true
return .run { send in
await send(.datosRecibidos(try await api.articulos()))
}
Con cargarDatos esa deliberación no cabe: el nombre afirma que hay que cargar, y toda condición para no hacerlo tendría que evaluarse antes del envío, es decir, fuera de la única función que el TestStore observa.
La fuga de decisiones hacia la vista
Cuando un caso es una orden, la lógica que justifica esa orden tiene que ejecutarse antes del envío, y el único sitio disponible suele ser el cuerpo de la vista. Ahí empieza la erosión: aparece un if en el botón, luego un cálculo, luego una condición compuesta, y de pronto la vista contiene reglas de negocio que ningún TestStore puede alcanzar.
// Fuga: la vista decide
Button("Guardar") {
if store.nombre.isEmpty {
store.send(.mostrarAlerta("El nombre es obligatorio"))
} else {
store.send(.guardar)
}
}
// Correcto: la vista testifica, el reducer decide
Button("Guardar") { store.send(.guardarPulsado) }
La versión corregida no es más corta por casualidad. La vista dejó de conocer la regla de validación, y esa regla pasó a una rama del switch que el test puede recorrer sin simulador. Toda condición que sobreviva dentro de una vista es una porción de dominio inaccesible para la verificación.
flowchart LR U[Usuario pulsa] --> V[Vista testifica el hecho] V --> A[Action guardarPulsado] A --> R[Reducer delibera] R --> S[State nuevo] R --> E[Effect] E --> A2[Action guardadoFallo] A2 --> R
Convención de nombres: sujeto, suceso, tiempo pasado
La regla operativa de Point-Free se enuncia en una línea: nombra el caso por lo que ocurrió, incluyendo quién o qué lo provocó, y en tiempo pasado o participio. El nombre resultante suele leerse como un titular breve.
| Orden (evitar) | Hecho (preferir) | Qué se recupera |
|---|---|---|
cargarDatos |
alAparecer |
El reducer decide si cargar, refrescar o ignorar |
guardar |
guardarPulsado |
La validación vuelve al reducer |
setNombre(String) |
nombreEditado(String) |
El campo deja de ser un setter remoto |
mostrarAlerta(String) |
guardadoFallo(String) |
La política de presentación se centraliza |
navegarADetalle(ID) |
filaPulsada(ID) |
La navegación se decide como estado |
La acción es un acta
Describe un suceso ya consumado y su carga: quién lo produjo y con qué datos. No contiene ninguna instrucción sobre la respuesta esperada.
El reducer es el juez
Recibe el acta y dicta la consecuencia: qué campos cambian y qué efecto se devuelve. Es el único punto del sistema autorizado a decidir.
Hay una prueba empírica muy fiable. Activa _printChanges en el reducer y lee la traza de una sesión: si la secuencia de acciones se deja narrar como la crónica de lo que hizo el usuario y lo que respondió el sistema, los nombres son correctos. Si la traza parece el registro de llamadas a un objeto, los nombres son órdenes y la arquitectura ya está filtrando decisiones.
Ante un caso dudoso, pregunta quién tuvo que pensar para producirlo. Si la respuesta es el usuario, el sistema operativo, la red o el reloj, es un hecho legítimo. Si la respuesta es tu propio código evaluando una condición, has movido una decisión fuera del reducer y el nombre te lo está avisando.
Refactorizar un enum imperativo
El refactor es mecánico y se hace caso a caso, sin big bang. Para cada orden, identifica el suceso real que la provocaba y renómbrala por él; después traslada al reducer la condición que se había quedado en la vista.
case .guardarPulsado:
guard !state.nombre.isEmpty else {
state.alerta = .nombreObligatorio
return .none
}
state.guardando = true
return .run { [perfil = state.perfil] send in
await send(.guardadoTerminado(Result { try await api.guardar(perfil) }))
}
Observa que el efecto no devuelve una orden como .mostrarExito, sino otro hecho: guardadoTerminado con su resultado. La regla se aplica igual a las acciones que nacen del sistema que a las que nacen del dedo del usuario. Una vez completado el barrido, el enum entero se lee como el catálogo cerrado de lo que puede pasarle a la feature, y ese catálogo es exactamente el guion que tus tests van a representar.
El resultado se aprecia mejor en el test que en el reducer. Un caso con nombre de hecho produce una prueba que se lee como el acta de una sesión, con los envíos correspondiendo a lo que hizo el usuario y las recepciones a lo que devolvió el mundo.
await store.send(.guardarPulsado) { $0.guardando = true }
await store.receive(\.guardadoTerminado.success) {
$0.guardando = false
$0.ultimoGuardado = fecha
}
Hay un último matiz que evita un malentendido frecuente. Nombrar en pasado no obliga a inventar participios forzados para todo: lo esencial no es la morfología del verbo sino que el caso describa un suceso y no una consecuencia. alAparecer, filaPulsada y respuestaRecibida cumplen la regla aunque su forma gramatical difiera, porque ninguno prejuzga qué debe hacer el reducer. Cuando dudes entre dos nombres, elige el que un observador externo podría haber escrito sin conocer tu código: si para nombrar el caso necesitas saber cómo reacciona la feature, has vuelto a codificar la decisión en el nombre.
Que una acción se llame en pasado no es una manía estilística heredada de Elm: es la única forma sintáctica que impide, por construcción, que el emisor decida. Un verbo en imperativo transporta una intención, y la intención es una decisión comprimida; en el momento en que la vista comprime una decisión y la envía, el reducer deja de ser la autoridad única sobre el dominio y pasa a compartirla con cada punto de envío del código. Esa autoridad compartida es indetectable al principio y letal a los seis meses, porque las decisiones duplicadas divergen: dos botones que envían guardar validan de forma distinta, tres pantallas que envían mostrarAlerta aplican políticas de presentación incompatibles, y ningún test las cruza porque los tests solo ven lo que llega al reducer, no lo que se pensó antes de enviar. El nombre en pasado cierra esa grieta con una elegancia casi desproporcionada: si el caso solo puede describir algo que ya ocurrió, es imposible codificar en él una decisión sobre lo que debería ocurrir, y toda la deliberación se ve forzada a descender al único lugar donde es pura, inspeccionable y testeable. Por eso la Action no es una API de tu feature sino su registro histórico: la lista exhaustiva y cerrada de los hechos que el mundo puede depositar en ella. Diseñada así, el reducer se convierte en una función que interpreta historia, los tests se convierten en narraciones legibles de sesiones reales, y la depuración se reduce a leer una traza que ya cuenta la verdad sin necesidad de reconstruirla. Nombra hechos y habrás construido una constitución; nombra órdenes y habrás construido un objeto con enum por fachada.
- Abre la
Actionde una feature tuya y clasifica cada caso en dos columnas: orden o hecho. Cuenta cuántos hay de cada tipo. - Para cada orden, escribe qué suceso real del mundo la provocaba y renómbrala con ese suceso en pasado, incluyendo el sujeto.
- Busca en las vistas todo
if,guardo cálculo que preceda a unsend. Traslada esa condición a la rama correspondiente del reducer y deja elsenddesnudo. - Revisa los efectos: sustituye cualquier acción de retorno con nombre imperativo por un hecho con su resultado asociado.
- Activa
_printChanges, recorre la pantalla y lee la traza en voz alta. Si no se deja narrar como una crónica, quedan órdenes disfrazadas.