El modelo mental completo: acción, reducer, store y suscripción
Primera lección del nivel de síntesis: reconstruir desde cero el anillo de cuatro piezas que sostiene todo el track —la acción como hecho consumado, el reducer como decisión pura, el store como custodio de la verdad y la suscripción como única vía de enterarse— y demostrar que ese anillo cabe en veinte líneas sin mencionar ningún framework. Sobre ese esqueleto se colocan después las tres capas que el track fue añadiendo nivel a nivel: los efectos en la periferia impura, con el criterio de a qué capa pertenece cada uno; los selectores como frontera de lectura simétrica a la frontera de escritura; y la cache de estado del servidor como el reconocimiento explícito de que hay datos cuya verdad no es tuya. Termina con un mapa de una sola hoja capaz de responder dónde vive cualquier pieza de estado de cualquier aplicación.
Veintidós niveles atrás empezamos con un chat que contaba mal los mensajes no leídos porque el mismo dato vivía en dos sitios. Desde entonces has visto reducers, middleware, generadores, átomos, proxies, adaptadores de entidades, tags de cache, viajes en el tiempo y cuatro ecosistemas distintos. Esta lección hace la operación inversa a todo eso: comprime el track entero en un único modelo mental que puedas dibujar de memoria en una servilleta y del que se deduzcan, por construcción, todas las piezas que estudiaste por separado. La prueba de que el modelo es bueno no es que sea bonito, sino que sea generativo: si lo tienes bien, no necesitas recordar dónde va un thunk, porque el sitio del thunk se sigue de la forma del anillo. Si tienes que recordarlo, todavía tienes un catálogo y no un modelo.
- Reconstruir el anillo de cuatro piezas y justificar por qué su dirección es única y no puede invertirse.
- Situar cualquier efecto en la periferia impura decidiendo a qué capa concreta pertenece.
- Distinguir la frontera de lectura de la de escritura y colocar selectores y memoización en su lado correcto.
- Usar el mapa de una hoja para responder dónde vive cualquier pieza de estado de una aplicación real.
El anillo: cuatro piezas y una sola dirección
El modelo completo tiene cuatro piezas y una restricción. Las piezas son la acción, un valor serializable que describe un hecho que ya ocurrió; el reducer, una función pura que recibe el estado anterior y la acción y devuelve el estado siguiente; el store, el objeto que custodia la referencia actual y expone las tres únicas operaciones legítimas; y la suscripción, el mecanismo por el que quien lee se entera de que hay una referencia nueva. La restricción es que estas cuatro piezas forman un ciclo orientado: la vista solo puede entrar por la acción, y solo puede salir por la suscripción. Ningún atajo entre dos puntos del anillo está permitido, y esa prohibición es todo lo que separa esta arquitectura del caos que la motivó.
// El ciclo entero cabe en veinte lineas y no menciona ningun framework.
type Accion = { type: string; payload?: unknown }
type Reducer<S> = (estado: S, accion: Accion) => S
function crearStore<S>(reducer: Reducer<S>, inicial: S) {
let estado = inicial
const oyentes = new Set<() => void>()
return {
getState: () => estado,
dispatch(accion: Accion) {
estado = reducer(estado, accion) // 1. decision pura
oyentes.forEach((f) => f()) // 2. notificacion a todos
return accion
},
subscribe(f: () => void) {
oyentes.add(f)
return () => oyentes.delete(f) // 3. limpieza obligatoria
},
}
}
Conviene detenerse en por qué cada pieza tiene la forma que tiene, porque ahí está la mitad del valor del modelo. La acción es un valor y no una llamada porque un valor se puede registrar, serializar, reproducir, enviar por la red y comparar; una llamada solo se puede ejecutar. El reducer es puro porque solo así la misma secuencia de acciones produce siempre el mismo estado, que es la condición de posibilidad del viaje en el tiempo, de las pruebas sin dobles y de la depuración a partir de un informe de error. El store guarda una referencia y no un objeto mutable porque comparar referencias es la operación más barata de la máquina, y toda la detección de cambios se apoya en ella. La suscripción existe porque quien lee no debe preguntar en bucle, y porque el desgarro visual —dos partes de la pantalla mostrando estados distintos del mismo dato— solo se evita si todos se enteran del mismo cambio en el mismo instante.
flowchart LR V[vista] -->|dispatch| A[accion como valor] A --> M[middleware] M --> R[reducer puro] R --> S[store con referencia nueva] S --> SEL[selector memoizado] SEL --> V M -->|red y tiempo| X[mundo exterior] X -->|accion de respuesta| A style R fill:#a6e3a1,color:#11111b style X fill:#f38ba8,color:#11111b
La dirección única no es una preferencia estética sino una propiedad con consecuencias medibles. En un sistema con flujo bidireccional, el número de caminos por los que un dato puede cambiar crece de forma cuadrática con el número de componentes que lo tocan, y depurar consiste en explorar ese grafo hacia atrás sin saber por dónde empezar. En un sistema con flujo único, ese número es constante: todos los cambios pasan por el mismo punto, y por eso ese punto se puede instrumentar una sola vez y contarlo todo. El viaje en el tiempo, el registro de acciones, la persistencia, el deshacer y la sincronización entre pestañas son cinco funcionalidades distintas que en esta arquitectura son la misma funcionalidad: escuchar el único paso por el que pasa todo.
La periferia impura: dónde encaja cada efecto
El anillo, tal como está, no sabe hacer nada interesante: no habla con la red, no mide el tiempo y no genera identificadores. Toda esa impureza vive fuera del reducer, en una periferia que el track recorrió en tres niveles distintos y que ahora conviene ver como un continuo ordenado por una sola variable: cuánta memoria del pasado necesita el efecto para decidir qué hacer.
Thunk: sin memoria
Una función que recibe dispatch y getState y ejecuta una secuencia. Sabe qué está pasando ahora y nada más. Cubre el noventa por ciento de los casos.
Listener: memoria de una acción
Reacciona a acciones ya despachadas y puede cancelar su propia ejecución anterior. Añade antirrebote, sondeo y coordinación sin salir de Redux Toolkit.
Saga: memoria de un proceso
Un generador que recuerda en qué punto de un flujo largo está. Necesario cuando el efecto es una máquina de estados con carreras y cancelaciones.
Cache: memoria del servidor
RTK Query o TanStack Query. No es un efecto tuyo sino la gestión de un dato ajeno, con su ciclo de vida propio.
// La misma tarea en dos capas: el criterio no es el gusto, es la memoria requerida.
export const cargar = createAsyncThunk('items/cargar', async (id: string, api) => {
const estado = api.getState() as RootState // memoria: solo el ahora
if (estado.items.entidades[id]) return null // corte barato
return await pedirItem(id, { signal: api.signal }) // cancelacion delegada
})
listener.startListening({
actionCreator: escribioEnLaBusqueda,
effect: async (accion, api) => {
api.cancelActiveListeners() // memoria: mi ejecucion anterior
await api.delay(300) // antirrebote sin temporizadores sueltos
api.dispatch(buscar(accion.payload))
},
})
La regla que ordena la elección es económica y no doctrinal: usa la capa con menos memoria que resuelva el problema, porque cada gramo de memoria adicional es un gramo de estado implícito que alguien tendrá que reconstruir al leer el código. Un thunk que solo pide y despacha es legible sin contexto; una saga que coordina tres flujos exige entender el proceso entero antes de tocar una línea. Esa asimetría explica por qué la comunidad se desplazó de las sagas hacia los thunks y los listeners sin que las sagas dejaran de ser correctas: no perdieron una discusión técnica, perdieron una discusión sobre coste de lectura.
Hay una comprobación de una línea que detecta casi todos los errores de colocación de efectos: si el reducer contiene una llamada a la red, una lectura del reloj, un número aleatorio, un acceso al almacenamiento local o una mutación de algo que llegó por parámetro, está mal, sin excepciones y sin matices. La consecuencia práctica es que el momento en que generas el identificador o lees la hora no es un detalle de estilo: si lo haces dentro del reducer, has roto la reproducibilidad de toda la aplicación por una línea de conveniencia; si lo haces al construir la acción, ese valor queda congelado dentro de un hecho y el reducer sigue siendo una función matemática. La misma acción, reproducida un año después, produce el mismo estado.
La frontera de lectura: selectores, derivación y memoización
Si dispatch es la frontera de escritura, el selector es su simétrico exacto en el lado de la lectura, y verlos como un par ordena buena parte del track. La frontera de escritura protege al store de que cualquiera lo modifique; la frontera de lectura protege a la vista de que cualquier cambio de forma del store la rompa. Sin la primera, no sabes quién cambió el dato; sin la segunda, no puedes cambiar la forma del estado sin tocar cincuenta componentes. Las dos fronteras juntas son lo que hace que una base de código grande siga siendo modificable a los tres años.
// La frontera de lectura tiene tres niveles, y solo el tercero necesita cache.
const selectFiltro = (s: RootState) => s.tareas.filtro // acceso directo
const selectIds = (s: RootState) => s.tareas.ids // acceso directo
const selectVisibles = createSelector( // derivacion memoizada
[selectIds, selectEntidades, selectFiltro],
(ids, entidades, filtro) => ids.map((id) => entidades[id]).filter((t) => coincide(t, filtro)),
)
Hay además una asimetría entre ambas fronteras que conviene tener presente porque explica dónde duele cada una. La frontera de escritura es estrecha por diseño y su coste se paga al escribirla: hay que nombrar la acción, declararla y despacharla, y ese peaje compra trazabilidad. La frontera de lectura es ancha por naturaleza —hay muchos más sitios que leen que sitios que escriben— y su coste se paga al mantenerla, porque cada selector es un punto de acoplamiento entre la forma del estado y la forma de la pantalla. De ahí la recomendación que el track repitió en varios niveles: exportar selectores desde el módulo dueño del dato y no acceder al estado por camino literal desde los componentes. Un cambio de forma se absorbe entonces en un archivo en lugar de en cincuenta.
El principio que gobierna esta capa es que en el store solo se guarda lo mínimo del que todo lo demás se pueda deducir. Si guardas la lista filtrada junto a la lista completa y el filtro, tienes tres datos donde había dos, y el tercero se desincronizará: no es una posibilidad, es una certeza con fecha. Derivar es la contrapartida de normalizar, y por eso los dos aparecen siempre juntos: normalizas para que cada hecho esté una sola vez, y derivas para reconstruir todas las vistas que ese hecho alimenta. La memoización entra solo cuando la derivación cuesta, y por una razón secundaria pero decisiva en React: una derivación no memoizada devuelve un objeto nuevo en cada ejecución, y un objeto nuevo dispara un render aunque su contenido sea idéntico.
El primero es el selector que devuelve un objeto o un array recién construido sin memoizar, que convierte cada despacho de la aplicación en un render de ese componente aunque el dato no haya cambiado. El segundo es el selector demasiado ancho —seleccionar un slice entero para leer un campo—, que suscribe el componente a cambios que no le importan. El tercero, más sutil, es el selector memoizado compartido entre varias instancias de un mismo componente con argumentos distintos: la cache de tamaño uno se invalida en cada alternancia y la memoización deja de servir, lo que exige una fábrica de selectores por instancia. Los tres se diagnostican igual: contando renders, no leyendo código.
El estado que no es tuyo, y el mapa de una hoja
Queda la pieza que el modelo original de 2014 no contemplaba y que hoy es la mitad del trabajo: casi todo lo que una aplicación muestra no le pertenece. Los datos del servidor son una copia local de una verdad remota que otros pueden cambiar sin avisarte, y tratarlos como estado propio es el error de diseño más caro de la última década. Una copia necesita cosas que un estado propio no necesita —caducidad, revalidación, deduplicación de peticiones simultáneas, reintentos, invalidación por etiquetas, actualizaciones optimistas con reversión— y ninguna de esas cosas se escribe bien a mano en reducers.
Servidor
Verdad remota. Es una cache, no un estado. Su herramienta tiene ciclo de vida propio, no un slice escrito a mano.
URL
Todo lo que deba sobrevivir a un recargado o poder compartirse por enlace: filtros, página, pestaña, búsqueda.
Cliente compartido
Lo que nadie cambia a tus espaldas y varias partes leen: sesión, tema, carrito, borradores.
Local
Lo que un solo componente usa y muere con él. El estado más barato y el más infravalorado.
La cache encaja en el anillo sin romperlo, y verlo aclara por qué RTK Query no es un cuerpo extraño dentro de Redux. Sus peticiones despachan acciones como cualquier otra cosa, sus resultados aterrizan en un slice normal y su ciclo de vida se observa en las mismas herramientas de desarrollo. Lo que aporta no es una arquitectura distinta sino un conjunto de decisiones ya tomadas —cuándo revalidar, cuándo deduplicar, qué caduca con qué— que de otro modo tendrías que escribir a mano y mantener para siempre. Elegir entre esa capa y una cache externa como TanStack Query es una decisión de dependencia y de estilo, no de modelo mental: ambas ocupan exactamente el mismo lugar en el mapa.
Con esas cuatro clases, el modelo mental queda completo y cabe entero en una hoja: el anillo en el centro, la periferia impura alrededor, la frontera de lectura a la salida y la clasificación del dato como primer paso de todo. Ante cualquier pieza de estado nueva, el orden de las preguntas no varía nunca: primero de quién es la verdad, después si la comparte alguien, después si se deduce de otra cosa, y solo al final qué herramienta la aloja. Invertir ese orden —empezar por la herramienta— es la causa raíz de la mayoría de las arquitecturas de estado que envejecen mal, porque una decisión de librería tomada antes de la clasificación arrastra durante años a datos que nunca debieron vivir ahí.
Hay una tentación razonable al terminar un track como este, que es archivar lo aprendido bajo la etiqueta de arquitectura de interfaces y suponer que su alcance termina donde termina el navegador; conviene resistirla, porque lo que has estado estudiando durante veintidós niveles no es un patrón de React sino la solución convergente a un problema que aparece siempre que un número indeterminado de actores modifica un estado compartido y alguien, más tarde, necesita entender por qué ese estado es el que es. La solución tiene siempre la misma forma porque las restricciones son siempre las mismas: si quieres poder responder por qué, los cambios tienen que ser valores y no llamadas, porque solo un valor se puede guardar y volver a mirar; si quieres poder reproducir, la decisión tiene que ser pura, porque una función que consulta el mundo no se puede volver a ejecutar con garantías; si quieres poder detectar el cambio barato, la transición tiene que producir una referencia nueva en lugar de mutar la vieja; y si quieres evitar que dos lectores vean cosas distintas, la notificación tiene que ser única y posterior a la transición. Esas cuatro exigencias no dejan mucho espacio de diseño, y por eso el mismo anillo aparece —sin que nadie se copiara— en los sistemas de eventos de los backends distribuidos, en los registros de replicación de las bases de datos, en las arquitecturas de deshacer de los editores gráficos, en los sistemas de control de versiones, en Bloc dentro de Flutter y en la arquitectura de composición de Swift. El corolario práctico, y la razón de que esta sea la primera lección del nivel de síntesis y no la última del primero, es que si has entendido el anillo como una propiedad estructural y no como una API, ya no estás aprendiendo Redux: estás en posesión de una plantilla que reconocerás en el siguiente sistema, en el siguiente lenguaje y en la siguiente década, y que sabrás construir a mano en veinte líneas el día que el ecosistema no te dé ninguna.
- En una sola hoja, sin mirar el código, dibuja el anillo de la aplicación en la que trabajas: acción, decisión pura, custodio de la verdad y notificación.
- Marca en el dibujo cada punto donde tu aplicación real toma un atajo entre dos partes del anillo. Nombra el atajo y estima cuántos archivos lo practican.
- Coloca cada efecto que tengas —red, tiempo, azar, almacenamiento— en la capa mínima que lo resuelva y anota cuáles están hoy en una capa con más memoria de la necesaria.
- Clasifica diez piezas de estado reales en servidor, URL, cliente compartido y local. Anota cuántas están hoy en una clase que no les corresponde.
- Busca en tu código un selector que devuelva un objeto nuevo sin memoizar y comprueba con un contador de renders cuántos renders innecesarios provoca por interacción.
- Escribe en cinco líneas la regla que aplicarías desde mañana para colocar cualquier estado nuevo. Si menciona una librería antes que una clase de dato, reescríbela.