El olor a acción que hace demasiado: granularidad correcta
Entre un enum con tres casos que lo deciden todo mediante parámetros booleanos y un enum con ochenta que narran cada paso interno del reducer hay una banda estrecha de granularidad correcta, y ambas orillas son patológicas. Esta lección enseña a diagnosticar las dos: la acción hipertrofiada que esconde decisiones en sus valores asociados y obliga a decodificarla, y la proliferación de acciones ficticias que dibujan una máquina de estados en el enum en lugar de en el State. Ofrece un criterio único y comprobable para decidir si algo merece ser un caso, explica por qué enviarse acciones a uno mismo para reutilizar lógica es casi siempre un error, y propone la traza de una sesión como juez final.
Las tres lecciones anteriores enseñaron a nombrar, a separar por origen y a componer acciones. Falta la pregunta que ninguna de ellas responde y que decide la calidad del resultado: cuántas debe haber. La intuición dice que más acciones equivale a más precisión, y es falsa en ambos extremos. Un enum con pocos casos y valores asociados cargados de decisiones traslada la deliberación al emisor y obliga al reducer a decodificar; un enum que enumera cada paso interno convierte el flujo de control del reducer en tráfico de mensajes y hace ilegibles tanto la traza como los tests. La granularidad correcta no es un punto medio arbitrario: se deduce de un criterio único, y esa deducción es lo que separa una Action diseñada de una acumulada.
- Diagnosticar la acción hipertrofiada que esconde decisiones en sus valores asociados.
- Reconocer la proliferación de acciones ficticias que suplantan a la máquina de estados del
State. - Aplicar el criterio del suceso externo para decidir si algo merece ser un caso del
enum. - Sustituir el reenvío de acciones a uno mismo por métodos compartidos cuando solo se busca reutilizar lógica.
La acción hipertrofiada
El primer olor es una acción cuyos valores asociados no transportan datos del suceso sino conclusiones sobre él. El síntoma más nítido son los parámetros booleanos y los nombres con conjunción.
// Olor: el emisor ya decidió y el reducer solo obedece
case botonPulsado(esValido: Bool, debeGuardar: Bool, cerrarDespues: Bool)
case actualizarYSincronizar(nombre: String?, edad: Int?)
Tres booleanos describen ocho comportamientos distintos comprimidos en un caso, y quien los calcula es forzosamente alguien externo al reducer: la vista, otra feature, un efecto. Es la fuga de decisiones de la primera lección, agravada porque ahora está camuflada dentro de la firma. El diagnóstico se confirma con tres preguntas: ¿hay un Bool entre los valores asociados que no describa un dato del mundo? ¿El nombre contiene una conjunción? ¿La rama del switch empieza con un if sobre esos parámetros? Una sola respuesta afirmativa basta.
La cura no consiste en partir el caso en ocho, sino en devolver al reducer la información cruda y dejarle deliberar.
case guardarPulsado // el reducer valida y decide si cerrar
case camposRestaurados(Perfil)
| Síntoma | Diagnóstico | Cura |
|---|---|---|
Bool en los valores asociados |
Decisión precalculada fuera | Enviar el dato crudo y decidir dentro |
| Nombre con conjunción | Dos sucesos fusionados | Partir en dos casos con nombre propio |
Rama larga con if inicial |
El caso es un despachador | Un caso por suceso, no por resultado |
| La vista calcula antes de enviar | Lógica fuera del reducer | Trasladar el cálculo a la rama |
La proliferación de acciones ficticias
El olor simétrico es más difícil de ver porque parece meticulosidad. Consiste en crear casos que ningún suceso del mundo produce y que existen solo para encadenar pasos internos.
// Olor: la maquina de estados dibujada en el enum
case iniciarValidacion
case validacionPaso1Completado
case validacionPaso2Completado
case prepararGuardado
case ejecutarGuardado
Ninguno de esos casos corresponde a algo que haya ocurrido fuera del reducer. Son etiquetas del flujo de control interno, y su presencia tiene tres costes acumulativos. La traza de _printChanges deja de ser una crónica de la sesión para convertirse en el volcado de una función. Los tests con TestStore, que exigen afirmar cada acción recibida, se llenan de líneas que no verifican ninguna regla. Y, lo más grave, la secuencia queda expuesta: cualquiera puede enviar ejecutarGuardado sin haber pasado por la validación, porque el enum no puede expresar el orden.
Las fases de un proceso pertenecen al State, modeladas con un enum anidado como viste al hacer imposibles los estados imposibles. Los pasos intermedios de un cálculo pertenecen a métodos privados o a extensiones del State. Ninguno de los dos pertenece a la Action.
@ObservableState
struct State: Equatable {
var fase: Fase = .editando
enum Fase: Equatable {
case editando
case validando
case guardando
case fallo(String)
}
}
enum Action {
case guardarPulsado // el usuario
case guardadoTerminado(Bool) // el sistema
}
Dos casos sustituyen a cinco, y el orden que antes quedaba a merced del emisor ahora lo custodia el tipo: no existe forma de expresar guardar sin haber validado, porque guardar no es una acción sino una fase a la que el reducer transita.
El criterio: un suceso externo, un caso
Toda la disciplina cabe en una frase comprobable: un caso de la Action debe corresponder a algo que ocurrió fuera del reducer. Solo cuatro fuentes cualifican, y ya las conoces de la lección sobre orígenes.
El usuario
Un gesto sobre la interfaz. Cualifica siempre, y su nombre es el gesto, no la consecuencia.
El sistema
La respuesta de una petición, un tic del reloj, un elemento de un stream, un aviso del entorno.
El hijo
Una acción delegate que sube desde una feature contenida y que el padre intercepta.
Nadie
Si ninguna de las tres anteriores lo produce, no es una acción: es una función que aún no has extraído.
La cuarta tarjeta es la que hace trabajo. Aplicada con rigor elimina de golpe las acciones ficticias y, aplicada al revés, obliga a partir las hipertrofiadas: si un mismo caso puede originarse en dos sucesos distintos, son dos casos.
El criterio tiene una formulación operativa que resuelve casi todas las dudas en un segundo: si puedes describir el suceso sin mencionar tu código, es una acción. «El usuario pulsó guardar», «la red devolvió un error 500» y «el editor terminó y avisó de que guardó el documento tres» se describen sin abrir el proyecto. «Hay que preparar el guardado» y «terminó el paso dos de la validación» no: solo significan algo para quien ya conoce la implementación, y por eso pertenecen al interior de una función, no al alfabeto de la feature.
Existe una excepción legítima y conviene delimitarla para que no se abuse de ella. Un efecto de larga duración que emite valores a lo largo del tiempo —un stream de localización, una conexión de sockets, un temporizador— necesita un caso por cada tipo de valor recibido, aunque esos casos parezcan pasos internos. No lo son: cada emisión es un suceso del sistema que ocurrió fuera del reducer y en un instante distinto, y por tanto cualifica bajo la segunda tarjeta.
Es tentador escribir return .send(.recargar) desde otra rama para no duplicar código. Point-Free desaconseja el patrón: .send dentro de un efecto introduce un salto asíncrono, aparece como una acción más en el TestStore y convierte una llamada de función en tráfico de mensajes observable. Si solo buscas compartir lógica, extrae un método —sobre el reducer o sobre el State— y llámalo desde ambas ramas. Reserva .send para cuando el envío represente de verdad un suceso nuevo, típicamente una delegate.
flowchart LR P[Pocas acciones cargadas] --> D[El reducer decodifica] M[Granularidad correcta] --> N[Un suceso externo por caso] E[Demasiadas acciones] --> F[Flujo de control como mensajes] D --> X[Decisiones fuera] F --> Y[Trazas y tests ilegibles] N --> Z[Traza narrable]
La traza como juez
Cuando el criterio deja dudas, existe una prueba empírica que nunca falla. Activa _printChanges, recorre un flujo completo y lee la secuencia de acciones. La granularidad es correcta cuando esa lectura se deja narrar como la crónica de lo que hizo el usuario y de lo que respondió el sistema, sin necesidad de consultar el código.
recibida Editor.Action.view(.alAparecer)
recibida Editor.Action._internal(.borradorRecuperado)
recibida Editor.Action.view(.textoEditado)
recibida Editor.Action.view(.guardarPulsado)
recibida Editor.Action._internal(.guardadoTerminado(true))
recibida Editor.Action.delegate(.documentoGuardado)
Esa traza se narra sola: la pantalla apareció, se recuperó un borrador, el usuario escribió, pulsó guardar, el servidor confirmó y la feature avisó a su padre. Si una línea es ambigua y hay que abrir el reducer para saber qué implicó, la acción está hipertrofiada. Si hay líneas que ningún observador externo habría podido presenciar, sobran acciones. El mismo criterio se aplica al leer un test escrito con TestStore: cada send debería corresponder a algo que un usuario o el sistema hicieron, y cada receive a algo que el mundo devolvió.
Merece la pena señalar que la granularidad no es una propiedad fija de la feature sino de su madurez. Al principio conviene pecar de casos con nombre propio, porque partir un caso más tarde es trivial y desenterrar una decisión sepultada en un Bool es caro. Lo que nunca conviene es dejar que la Action crezca por sedimentación, añadiendo un caso cada vez que hace falta encadenar algo: esa es la vía por la que un enum llega a ochenta casos sin que nadie tomara jamás la decisión de que los tuviera.
Piensa en el enum de acciones como en el alfabeto de un lenguaje cuyas frases son las sesiones de uso de tu aplicación. Un alfabeto demasiado pequeño obliga a sobrecargar cada símbolo: los valores asociados se llenan de banderas, cada carácter significa ocho cosas según el contexto, y el lector —el reducer, pero también el humano que depura a las tres de la mañana— tiene que decodificar antes de comprender. Un alfabeto demasiado grande produce el mal contrario: los símbolos dejan de corresponder a fonemas del mundo y pasan a codificar la mecánica interna del escriba, de modo que las frases se alargan sin ganar significado y el ruido sepulta la señal. La granularidad correcta es aquella en la que existe una biyección entre símbolos y sucesos externos observables, y esa biyección tiene una consecuencia que trasciende la estética: hace que la traza de una sesión sea, literalmente, una descripción verdadera y completa de lo que ocurrió. Ese es el activo que se está construyendo cuando se discute si un caso sobra o falta. Una app cuya traza es narrable se depura leyendo, se testea transcribiendo y se documenta sola, porque el registro de acciones es al mismo tiempo el guion de los tests, el log de producción y la especificación del comportamiento. Una app cuya traza es ilegible ha perdido todo eso aunque cada reducer sea individualmente correcto, y lo ha perdido por una decisión de diseño que parecía menor: cuántos casos poner. Por eso la pregunta por la granularidad no es de limpieza sino epistemológica. Estás decidiendo qué es capaz de saber tu sistema sobre su propio pasado, y un sistema que no puede narrarse tampoco puede demostrarse.
- Recorre la
Actionde una feature y marca cada caso con su fuente: usuario, sistema, hijo o ninguna. Los marcados como ninguna son candidatos a desaparecer. - Busca valores asociados de tipo
Boolque no describan un dato del mundo y rediseña esos casos para que transporten información cruda. - Convierte cada acción ficticia en un método privado o en una fase de un
enumdentro delState, y comprueba que el comportamiento no cambia. - Localiza todo
return .send(...)hacia tu propia feature: si solo reutiliza lógica, extrae un método compartido y elimina el envío. - Activa
_printChanges, ejecuta un flujo completo y transcribe la traza a prosa. Toda línea que no puedas narrar señala un caso mal dimensionado.