Acciones como setters: el olor que delata la pérdida del dominio
Un slice cuyos reducers se llaman setNombre, setEmail, setCargando y setError no está modelando un dominio: está exponiendo la forma de su estado como si fuera un objeto con propiedades públicas. Esta lección explica qué se pierde exactamente en esa traducción —la narrativa del historial, el reparto de una acción entre varios reducers, la atomicidad de las transiciones y la capacidad de auditar—, muestra el refactor que devuelve el tiempo pasado a los nombres, y delimita los pocos casos en los que un setter genérico es la respuesta correcta y no un síntoma.
Abre un slice cualquiera y lee solo los nombres de sus reducers. Si lo que encuentras es setNombre, setEmail, setCargando, setError, setPaso y setVisible, acabas de diagnosticar sin necesidad de leer una línea de lógica: ese slice no describe qué le ocurre a tu aplicación, describe qué campos tiene. Es una traducción mecánica de la forma del estado a una interfaz de escritura, campo por campo, y su efecto es reducir Redux a un objeto mutable con ceremonia: dispones de un dispatch que hace exactamente lo que haría una asignación, con más pasos intermedios y ninguna de las ventajas por las que aceptaste esos pasos. El olor es fácil de detectar y difícil de valorar, porque el código funciona y nadie sabe señalar qué falta. Lo que falta es el dominio: en algún punto de la traducción se perdió la información sobre por qué el estado cambia, y esa información era todo el producto.
- Reconocer el olor del reducer-setter y su correlato: un historial de acciones que no cuenta ninguna historia.
- Enumerar las cuatro capacidades concretas que se pierden al convertir eventos en asignaciones.
- Refactorizar un slice de setters a acciones en tiempo pasado, con transiciones atómicas.
- Delimitar los casos legítimos de setter genérico y evitar el exceso contrario de inventar eventos.
Eventos frente a órdenes
La distinción que organiza todo este asunto es antigua y no es de Redux: una acción puede ser un evento, que informa de algo que ya sucedió, o una orden, que pide que algo suceda. Las dos son estructuras de datos idénticas y semánticamente opuestas. formularioEnviado es un evento: relata un hecho consumado, no admite negociación, y lo que cada parte del sistema haga con él es asunto suyo. setCargando es una orden dirigida a un campo concreto: no dice qué pasó, dice qué escribas, y presupone que quien la emite sabe cuál debe ser el estado resultante.
El Flux original y Redux se diseñaron alrededor del primer significado, y de ahí viene la convención del tiempo pasado que tantos equipos tratan como una preferencia estilística. No lo es. Nombrar en pasado obliga a saber qué ocurrió antes de escribir el reducer, y esa obligación es el mecanismo que impide que la lógica de negocio se filtre al componente. Cuando un componente despacha setCargando(true), setError(null) y luego setUsuarios(datos), la secuencia correcta de esas tres escrituras es conocimiento de dominio que ahora vive en el componente, disperso y sin nombre. Cuando despacha usuariosSolicitados y después usuariosRecibidos, ese conocimiento vive en el reducer, que es el único sitio donde puede testearse sin montar una vista.
// Setters: el componente conoce la forma del estado y orquesta la secuencia.
dispatch(setCargando(true))
dispatch(setError(null))
const datos = await api.usuarios()
dispatch(setUsuarios(datos))
dispatch(setCargando(false))
// Eventos: el componente relata, el reducer decide la forma resultante.
dispatch(usuariosSolicitados())
const datos = await api.usuarios()
dispatch(usuariosRecibidos(datos))
Conviene notar además dónde queda el conocimiento sobre la forma del estado en cada versión. Con setters, el componente sabe que existe un campo llamado cargando, otro llamado error y otro llamado usuarios, y sabe en qué orden hay que escribirlos; ese conocimiento se repite en cada sitio que despacha la secuencia, de modo que renombrar un campo o añadir un cuarto obliga a visitar todos. Con eventos, el componente solo sabe qué ocurrió, y la forma del estado queda encerrada en el slice, que es el único que puede cambiarla. La diferencia se paga la primera vez que hay que añadir un contador de reintentos o una marca de tiempo a esa transición: en un caso se toca un reducer, en el otro se busca en toda la base de código.
Fíjate en lo que la segunda versión gana además de brevedad: entre la primera y la segunda línea de la versión con setters existe un instante en el que el estado tiene cargando en verdadero y el error todavía sin limpiar. Ese estado intermedio es visible para los suscriptores, aparece en el historial y puede renderizarse. Cada despacho adicional multiplica los estados intermedios posibles, y varios de ellos representan situaciones que tu dominio considera imposibles. Una sola acción que produce la transición completa no puede ser interrumpida a medias: la atomicidad no es un detalle de rendimiento, es la garantía de que los estados imposibles no existen ni siquiera durante un instante.
Las cuatro capacidades que desaparecen
La narrativa del historial
Un registro de eventos se lee como el relato de la sesión del usuario. Un registro de asignaciones es una lista de escrituras sin causa, inútil para reproducir un fallo.
El reparto entre reducers
Un evento puede ser escuchado por varios slices a la vez. Un setter nombra un campo, y por tanto solo puede afectar a uno: la publicación se degrada en llamada directa.
La transición atómica
Un evento cambia de golpe todo lo que ese hecho implica. Varios setters seguidos exponen estados intermedios que el dominio considera imposibles.
La auditoría y la analítica
De pagoRechazado se deduce una métrica de negocio. De setEstadoPago no se deduce nada sin reconstruir su contexto a mano.
La primera tarjeta tiene una consecuencia práctica que se aprecia el día que llega un informe de error. Con eventos, la secuencia de acciones exportada desde las DevTools es un guion reproducible: la pegas en tu entorno, la reproduces sobre el estado inicial y obtienes exactamente el fallo del usuario, porque cada línea describe algo que ese usuario hizo. Con setters, esa misma secuencia describe el resultado y no la causa, así que reproducirla te lleva al estado final sin pasar por el camino que lo produjo, y precisamente el camino era lo que querías examinar. El historial deja de ser una grabación y se convierte en un volcado.
La segunda tarjeta es la que más consecuencias arquitectónicas tiene y la que menos se ve al principio, así que conviene desarrollarla. El dispatch de Redux no es una llamada a una función: es una publicación en un canal que todos los reducers escuchan. Esa es la razón por la que combineReducers pasa cada acción a todos sus hijos, y de ahí sale la capacidad de que un mismo hecho produzca efectos en partes del estado que no se conocen entre sí. Cuando llega sesionCerrada, el slice de sesión borra el token, el de carrito se vacía, el de notificaciones se limpia y el de caché se invalida, sin que ninguno tenga que saber de los otros ni que exista un coordinador. Un setter destruye esa propiedad por construcción: su nombre menciona un campo, así que solo un reducer puede atenderlo con sentido, y el sistema de publicación queda reducido a una invocación con pasos de más. Dicho de forma memorable: un setter convierte un canal de publicación en una llamada a procedimiento, y paga el precio del canal sin quedarse con su beneficio.
flowchart LR E[evento sesion cerrada] --> S1[slice sesion borra el token] E --> S2[slice carrito se vacia] E --> S3[slice avisos se limpia] E --> S4[slice cache se invalida] T[setter set token null] --> U[slice sesion] style E fill:#a6e3a1,color:#11111b style T fill:#f38ba8,color:#11111b
El refactor y sus límites
Hay que decir también que el refactor no siempre se hace entero ni conviene hacerlo de golpe. Un slice de setters que funciona y que nadie toca no es una emergencia; el criterio de prioridad es el mismo que para cualquier deuda, y aquí se mide por dos señales: cuánta lógica de dominio ha emigrado a los componentes que lo despachan, y con qué frecuencia hay que modificarlo. Un slice estable y con un solo consumidor puede vivir así indefinidamente. Uno que se toca cada semana y cuya secuencia de despachos está copiada en cuatro pantallas es exactamente donde el refactor se paga solo.
El procedimiento para deshacer un slice de setters empieza por un ejercicio que parece de redacción y es de análisis. Abre las DevTools, usa la aplicación como un usuario durante dos minutos y lee la lista de acciones resultante como si fuera una crónica. Si no puedes reconstruir lo que hiciste a partir de esa lista, tienes el diagnóstico. A continuación, para cada grupo de setters que se despachan juntos, busca el nombre del hecho que los provoca, escríbelo en pasado y conviértelo en una sola acción cuyo reducer haga todas las escrituras. La regla práctica es que si dos despachos aparecen siempre consecutivos en la misma secuencia, no son dos acciones: son una sola con dos consecuencias.
const checkoutSlice = createSlice({
name: 'checkout',
initialState: { paso: 'datos', pagando: false, error: null, pedidoId: null },
reducers: {
// El evento nombra el hecho; el reducer decide toda la forma resultante.
pagoIniciado(e) {
e.pagando = true
e.error = null
},
pagoConfirmado(e, a: PayloadAction<{ pedidoId: string }>) {
e.pagando = false
e.paso = 'confirmacion'
e.pedidoId = a.payload.pedidoId
},
pagoRechazado(e, a: PayloadAction<{ motivo: string }>) {
e.pagando = false
e.error = a.payload.motivo
},
},
})
Hay un beneficio colateral de este refactor que rara vez se menciona y que a menudo es el que convence a quien decide las prioridades: el catálogo de acciones se convierte en una descripción del producto legible por alguien que no programa. Una lista formada por sesionIniciada, pagoRechazado, pedidoCancelado y limiteDeCuotaAlcanzado puede revisarse con la persona responsable del negocio, que reconocerá los casos que faltan y señalará los que no existen en el dominio real. Una lista de asignaciones a campos no admite esa conversación con nadie. En proyectos donde hay que instrumentar analítica, cumplir requisitos de auditoría o construir un histórico de actividad, ese catálogo deja de ser documentación y pasa a ser la especificación de lo que hay que registrar.
Queda por delimitar el exceso contrario, porque existe y produce código peor que el problema original. Hay estado que genuinamente no tiene dominio, y forzarle una narrativa es teatro. La posición de un panel lateral, el modo claro u oscuro, el ancho de una columna redimensionable o el valor de un campo de texto mientras se escribe son datos de interfaz sin semántica de negocio, y bautizar su cambio como preferenciaDeAnchoDeColumnaAjustadaPorElUsuario no añade información: añade sílabas. Para esos casos, un reducer de escritura directa es la respuesta honesta, y la señal de que lo es resulta ser la misma que usamos en la lección anterior de este nivel: si el dato no debería estar en el store global, el problema no era el nombre de su setter.
La granularidad y el reparto
Recuperado el vocabulario del dominio, queda decidir cuántas acciones hacen falta, y aquí el criterio no es estético sino observacional. Una acción por hecho distinguible, entendiendo por hecho cada cosa que un usuario, un temporizador o el servidor pueden provocar y que tú querrías ver por separado en el historial. Ni una por campo, que es el error de partida, ni una por flujo entero, que es el exceso contrario y produce acciones tan gruesas que su carga útil se convierte en el estado completo y su reducer en una asignación disfrazada. Si al leer el registro no puedes distinguir dos situaciones que el negocio distingue, te falta granularidad; si tienes tres acciones que jamás aparecen separadas, te sobra.
El reparto entre slices es la prueba de fuego de que el refactor está bien hecho, porque solo funciona con eventos. Un slice declara sus propios hechos en sus reducers y escucha los ajenos en extraReducers, sin que el emisor sepa quién le escucha ni tenga que enterarse cuando aparezca un oyente nuevo. Esa asimetría —el emisor no conoce a sus consumidores— es lo que permite añadir una funcionalidad completa sin tocar el código que la dispara, y es imposible de conseguir con setters porque un setter nombra a su destinatario en el propio nombre.
// El emisor no sabe quien escucha; los oyentes se suman sin tocarlo.
const carritoSlice = createSlice({
name: 'carrito',
initialState,
reducers: {},
extraReducers: (b) => {
b.addCase(sesionCerrada, () => initialState)
b.addCase(organizacionCambiada, () => initialState)
},
})
Una advertencia final sobre el nombrado, porque es donde el refactor se degrada con más facilidad. La convención de prefijar con el dominio y describir el hecho en pasado no sirve para que el código se lea bonito: sirve para que el registro de acciones se pueda agrupar, filtrar y analizar por origen. Un catálogo donde conviven usuario/sesionIniciada y setUser no tiene dos estilos, tiene dos modelos mentales conviviendo en el mismo historial, y quien lo lea dentro de un año tendrá que averiguar cuál de los dos estaba vigente en cada archivo.
Ante un formulario con quince campos, la tentación razonable es escribir un único campoEditado con una carga útil que lleve el nombre del campo y el valor. Es mejor que quince setters y sigue siendo un setter: la carga útil menciona la forma del estado, así que el componente conserva el acoplamiento y el historial sigue sin narrar nada. Su virtud real es reducir el ruido, no recuperar el dominio, y por eso conviene reservarlo para el caso en que el borrador sea auténticamente local y solo llegue al store el hecho consumado. Si el formulario entero acaba en el store campo a campo, el setter parametrizado es un analgésico eficaz sobre un diagnóstico equivocado.
La razón por la que este error resulta tan difícil de argumentar en una revisión de código es que su coste no se paga en el momento en que se comete, sino cada vez que el sistema tiene que crecer, y crecer es precisamente lo que nadie está haciendo el día que escribe el slice. Un setter codifica una hipótesis muy fuerte sobre el futuro: que este cambio de estado tiene un solo significado, un solo destinatario y ninguna consecuencia más allá del campo que nombra. Un evento codifica la hipótesis contraria y mucho más humilde: que ha ocurrido un hecho, que ese hecho es cierto con independencia de quién lo escuche, y que la lista de interesados en él puede cambiar sin que el hecho cambie. Esta asimetría es la misma que separa una llamada a procedimiento de una publicación en un canal, la misma que separa el estado actual de un registro de sucesos, y la misma que hace que los sistemas construidos sobre hechos inmutables puedan responder preguntas que sus autores no anticiparon mientras los construidos sobre asignaciones solo puedan responder aquellas para las que se diseñó un campo. Cuando escribes pagoRechazado en lugar de setEstadoPago, no estás siendo pintoresco con la nomenclatura: estás conservando el motivo del cambio junto al cambio, y el motivo es la única parte que no se puede reconstruir después. La forma del estado se puede refactorizar, migrar y rediseñar cuantas veces haga falta; la información sobre por qué cambió, si no se capturó en el momento, se perdió para siempre. Por eso la prueba definitiva de un buen catálogo de acciones no es que sea elegante ni que siga una convención, sino esta: si mañana el negocio pide una métrica, una auditoría o una funcionalidad de deshacer que nadie había previsto, ¿está la respuesta ya en tu historial de acciones, o hay que salir a instrumentar la aplicación otra vez desde cero?
- Cuenta cuántos reducers de tu base de código empiezan por la palabra que indica asignación y calcula qué porcentaje del total representan.
- Usa la aplicación dos minutos con las DevTools abiertas e intenta reconstruir lo que hiciste leyendo solo la lista de acciones; anota dónde se pierde el relato.
- Localiza tres grupos de despachos que aparezcan siempre consecutivos y fusiónalos en una sola acción nombrada en pasado.
- Busca un hecho que deba afectar a varios slices —cierre de sesión, cambio de organización— y comprueba si hoy se implementa con varios setters coordinados desde un componente.
- Reescribe ese caso como un único evento escuchado por todos los slices implicados, usando
extraReducersen los que no lo declaran. - Identifica el estado de tu aplicación que genuinamente no tiene dominio y decide, para cada dato, si merece un setter honesto o si simplemente no debía estar en el store.