El store como base de datos: cuándo Redux dejó de ser la herramienta
Un store cuyos slices se llaman usuarios, productos y pedidos, cada uno con su bandera de carga y su campo de error, no es un modelo de estado: es una réplica manual de la base de datos del servidor mantenida a mano. Esta lección demuestra que ese diseño obliga al equipo a reimplementar mal una librería de caché —invalidación, obsolescencia, deduplicación de peticiones en vuelo, reintentos, paginación y recolección—, establece la frontera entre el estado que tu aplicación posee y el que solo copia, y describe el refactor hacia una caché declarativa junto con la observación más incómoda del track: lo que queda en el store después suele ser tan poco que hay que revisar por qué había un store.
Hay un momento en la vida de casi todo proyecto con Redux en el que el store deja de parecerse a un modelo de la aplicación y empieza a parecerse al esquema de la base de datos del servidor. Los slices se llaman usuarios, productos, pedidos y facturas; cada uno tiene su lista, su elemento seleccionado, su bandera de carga, su campo de error y su marca de tiempo de la última descarga; y la mitad del código del proyecto son thunks que piden algo por red y lo escriben ahí. Nadie diseñó eso: es lo que sale de aplicar con consistencia la idea de que el estado de la aplicación vive en el store, cuando esos datos no son estado de la aplicación. Son una copia de un estado que vive en otra máquina, que otras personas están modificando ahora mismo y sobre el que tu aplicación no tiene ninguna autoridad. En cuanto se acepta esa descripción, la conclusión es inevitable: no estás gestionando estado, estás manteniendo una caché, y el conjunto de problemas que eso plantea no es el conjunto que Redux resuelve.
- Diagnosticar el store convertido en réplica del servidor a partir de la forma de sus slices.
- Enumerar los problemas de caché que un equipo acaba reimplementando a mano y por qué salen mal.
- Trazar la frontera entre el estado que la aplicación posee y el que solo mantiene en copia.
- Ejecutar el refactor hacia una caché declarativa y decidir qué debe quedarse en el store.
El diagnóstico está en la forma
El síntoma se lee en la estructura antes que en el volumen, y por eso el diagnóstico es rápido: no hace falta auditar la aplicación entera, basta con mirar la forma de tres o cuatro slices y comprobar si se repite.
Cuando un slice contiene una colección de entidades acompañada de una tripleta de metadatos —cargando, error y última actualización—, ese slice no representa una decisión de tu aplicación: representa el resultado de una petición HTTP y el estado de esa petición. La prueba definitiva es preguntarse quién es el autor del valor. Si tu aplicación es la única que puede cambiarlo, es estado propio: el paso de un asistente, el contenido de un carrito aún no enviado, una preferencia de interfaz. Si el valor puede cambiar sin que tu aplicación haga nada —porque otro usuario lo editó, porque un proceso del servidor lo actualizó, porque otra pestaña del mismo navegador lo tocó—, entonces tu store no contiene el dato: contiene una fotografía del dato tomada en un instante, y esa fotografía empieza a envejecer en el momento exacto en que llega.
El criterio de autoría resiste bien los casos difíciles, que es lo que se le pide a un criterio. Un carrito que solo existe en el navegador es estado propio; el mismo carrito sincronizado con la cuenta del usuario deja de serlo, porque otra sesión puede modificarlo. Un borrador de mensaje es propio mientras no se envía. Una lista de permisos parece propia porque se usa para decidir qué se muestra, y no lo es en absoluto: el servidor puede revocarlos en cualquier momento, y una aplicación que trate su copia como verdad mostrará opciones que ya no existen. La pregunta no es dónde se usa el dato ni quién lo lee, sino quién tiene derecho a cambiarlo.
Esa segunda categoría tiene una propiedad que la primera no tiene y que lo cambia todo: es incorrecta por defecto. No existe un momento en que puedas afirmar que la copia coincide con el original; solo puedes afirmar que coincidía cuando la pediste. Toda la ingeniería que sigue —refrescar al volver a la pestaña, revalidar tras cierto tiempo, invalidar tras una escritura— existe para acotar cuánto puede alejarse esa copia de la verdad, y ninguna de esas técnicas es un detalle de implementación: son la sustancia del problema.
flowchart LR S[servidor: la fuente de verdad] -->|respuesta| C[copia en el store] C --> V[la vista lee la copia] S -.otro usuario edita.-> S S -.proceso automatico.-> S C -.envejece sin avisar.-> C style S fill:#a6e3a1,color:#11111b style C fill:#f9e2af,color:#11111b
Los siete problemas que acabas reimplementando
Cuando un equipo mantiene datos de servidor en slices propios, no elige entre tener una caché o no tenerla: elige entre escribirla o adoptarla. Los problemas aparecen en un orden bastante predecible y cada uno se resuelve con un parche local que funciona hasta que aparece el siguiente.
Peticiones duplicadas
Tres componentes piden la misma lista al montarse y salen tres peticiones idénticas. La deduplicación de peticiones en vuelo hay que escribirla, y hacerlo bien exige un registro de promesas por clave.
Obsolescencia
¿Cuándo hay que volver a pedir? Sin una política de frescura acabas eligiendo entre pedir siempre, que sobrecarga, o pedir una vez, que muestra datos viejos durante toda la sesión.
Invalidación
Tras crear un pedido hay que refrescar la lista de pedidos, el contador del menú y el saldo. Sin un grafo de dependencias declarado, esa relación queda esparcida en thunks.
Recolección
Nada borra los datos que ya nadie mira. El store crece durante toda la sesión y una aplicación larga acaba con megabytes de entidades que ningún componente montado necesita.
Antes de enumerar los que faltan conviene señalar la propiedad que comparten todos: ninguno de estos problemas tiene una solución local. Cada uno exige una decisión coherente en toda la aplicación —una política de frescura, un grafo de invalidación, un registro global de peticiones en vuelo— y sin embargo se descubren de uno en uno, en pantallas distintas, resueltos por personas distintas con parches que no se conocen entre sí. El resultado es una caché sin diseño unificado, en la que dos recursos se comportan de forma diferente sin que nadie lo haya decidido, y donde arreglar el comportamiento de uno no arregla el del otro. La caché escrita por accidente no es peor por estar mal programada: es peor porque nunca fue programada como un conjunto.
Los tres restantes no caben en tarjetas pero pesan igual. La paginación y el desplazamiento infinito exigen decidir cómo se combinan páginas sucesivas, qué ocurre cuando una entidad cambia de página y cómo se invalida un conjunto parcial. Los reintentos con espera creciente y la distinción entre error recuperable e irrecuperable acaban copiados y pegados en cada thunk con variaciones sutiles. Y la actualización optimista con reversión —aplicar el cambio antes de que el servidor responda y deshacerlo si falla— requiere guardar el estado previo, restaurarlo en el orden correcto cuando hay varias operaciones concurrentes y reconciliar con lo que el servidor devuelva; escrita a mano, es una de las fuentes de bugs más productivas que existen.
Hay una medida que zanja la discusión sin apelar a preferencias. Recorre tus slices y clasifica cada línea en dos montones: lógica que expresa una regla de tu producto y lógica que gestiona el ciclo de vida de una petición. Cuenta el segundo montón, que incluye los pending, fulfilled y rejected de cada thunk, las banderas de carga, los campos de error, los reintentos y los refrescos. Si supera la mitad del código de estado, tu equipo está manteniendo una librería de caché escrita por accidente, sin pruebas propias, sin documentación y sin nadie asignado a su mantenimiento. Ese código no te diferencia de ningún competidor y sin embargo es donde más tiempo consumes: la peor combinación posible en una decisión de construir frente a adoptar.
La frontera y el refactor
Vale la pena anticipar la objeción del caso simple, porque es razonable y tiene respuesta. En una aplicación con tres pantallas y dos peticiones, escribir el ciclo de vida a mano es perfectamente sensato y añadir una capa de caché sería desproporcionado. El argumento de esta lección no es que toda petición necesite una librería, sino que hay un umbral —difuso pero reconocible— a partir del cual el trabajo acumulado de mantener a mano supera con holgura el coste de adoptar, y que la mayoría de los equipos lo cruzan sin darse cuenta porque cada paso individual era barato. La señal de que ya lo cruzaste es haber escrito dos veces el mismo parche para dos recursos distintos.
La frontera se traza con una sola pregunta, la de la propiedad del dato. El estado propio de una aplicación es aquel del que ella es la autoridad: interfaz —paneles, pestañas, diálogos, modo oscuro—, sesión y permisos derivados de ella, borradores y procesos de varios pasos que aún no se han confirmado, y selecciones y filtros que no encajen mejor en la URL. Todo lo demás, es decir, todo lo que llega por red y podría cambiar sin ti, es caché, y su sitio es una capa diseñada para cachear: RTK Query si ya vives en Redux Toolkit, o una librería de consultas dedicada si no. Ambas resuelven de fábrica los siete problemas anteriores, con la ventaja decisiva de que sus errores están corregidos en versiones que puedes instalar.
// Antes: un slice que es una tabla, con su ciclo de vida a mano.
const pedidosSlice = createSlice({
name: 'pedidos',
initialState: { lista: [], cargando: false, error: null, ultimaCarga: 0 },
extraReducers: (b) => {
b.addCase(cargarPedidos.pending, (e) => { e.cargando = true })
b.addCase(cargarPedidos.fulfilled, (e, a) => {
e.cargando = false
e.lista = a.payload
e.ultimaCarga = Date.now()
})
b.addCase(cargarPedidos.rejected, (e, a) => {
e.cargando = false
e.error = a.error.message ?? null
})
},
})
// Despues: la cache declara la dependencia y el resto es suyo.
const api = createApi({
baseQuery,
tagTypes: ['Pedido'],
endpoints: (b) => ({
pedidos: b.query({
query: () => '/pedidos',
providesTags: ['Pedido'],
}),
crearPedido: b.mutation({
query: (cuerpo) => ({ url: '/pedidos', method: 'POST', body: cuerpo }),
invalidatesTags: ['Pedido'],
}),
}),
})
Hay una objeción legítima a este movimiento que merece respuesta antes que desprecio: si los datos del servidor salen del store, se pierde la propiedad de tener una única fotografía del estado de la aplicación, y con ella parte del valor de las herramientas de inspección. Es cierto a medias. La caché de una librería de consultas es inspeccionable, se integra con las mismas herramientas cuando vive dentro de Redux Toolkit y expone su propio historial de peticiones, estados y reintentos, que para depurar un problema de datos es bastante más informativo que un slice con una bandera booleana. Lo que sí se pierde de verdad es la capacidad de reproducir una sesión completa despachando acciones grabadas, y eso importa en los productos que la usaban en serio. Reconocer esa pérdida y decidir conscientemente si compensa es un análisis honesto; ignorar el coste de la alternativa no lo es.
El refactor conviene hacerlo por recurso y no por capas, migrando un endpoint completo antes de tocar el siguiente, porque así ambos mundos coexisten sin que nadie tenga que sincronizarlos. El orden que menos duele empieza por el recurso de solo lectura con más consumidores: se declara la consulta, se sustituyen los selectores por el hook generado, se borra el slice y se comprueba que nadie lo importaba. Después se migran las escrituras, que es donde se recupera la mayor cantidad de código, porque cada invalidación manual esparcida en thunks se convierte en una etiqueta declarada junto a la mutación. Solo al final se abordan los casos con actualización optimista, que son los que más lógica propia tenían y también los que más se simplifican.
El caso híbrido y el límite de la caché
Trazada la frontera, aparece la zona donde de verdad se juega el diseño: los datos que pertenecen al servidor pero que el usuario edita durante un rato antes de devolverlos. Un formulario de perfil precargado, un documento que se está redactando, una tabla con celdas modificables. La respuesta correcta no es elegir un lado sino reconocer que ahí conviven dos cosas distintas: la copia del servidor, que es caché y sigue siendo suya, y el borrador del usuario, que es estado propio de tu aplicación desde el instante en que la primera tecla lo separa del original. Mezclarlos en un solo objeto produce los bugs más difíciles de este territorio: el refresco en segundo plano que sobrescribe lo que el usuario estaba escribiendo, el descarte que no sabe a qué valor volver, y la comparación entre modificado y original que no puede hacerse porque el original ya no existe.
La consecuencia de diseño es directa. La caché guarda el valor del servidor y nadie lo toca; el borrador vive donde vivan los estados propios de vida corta —normalmente local, o en un slice si el proceso abarca varias pantallas—; y la reconciliación se hace de forma explícita al guardar, no por acumulación accidental. Con esa separación, la actualización optimista deja de ser un truco y se vuelve una operación descriptible: aplicas el borrador sobre la caché mientras la petición viaja, y si falla, restauras el valor del servidor, que nunca dejó de estar disponible porque nunca lo sobrescribiste.
// La cache conserva el valor del servidor; el borrador es estado propio.
const { data: perfil } = useObtenerPerfilQuery(id)
const [borrador, setBorrador] = useState(null)
const valor = borrador ?? perfil
// Guardar reconcilia de forma explicita; descartar vuelve al original
// porque el original nunca fue sobrescrito por el borrador.
const guardar = () => actualizarPerfil({ id, ...borrador })
const descartar = () => setBorrador(null)
Conviene también nombrar el límite superior de este razonamiento, porque existe y algunos productos lo cruzan. Una caché de consultas asume que el servidor está mayoritariamente disponible y que la aplicación es una ventana sobre él. Cuando eso deja de ser cierto —trabajo sin conexión, edición colaborativa en tiempo real, aplicaciones que deben funcionar durante horas sin red—, ninguna de las dos herramientas de este nivel es la respuesta: el problema pasa a ser de replicación y resolución de conflictos, y su solución vive en motores de sincronización, bases de datos locales y estructuras de datos convergentes. Reconocer ese cruce a tiempo evita el error más caro de todos, que es intentar resolver un problema de sistemas distribuidos añadiendo campos a un slice.
Y aquí llega la observación incómoda que da sentido a todo el nivel. Cuando terminas de sacar del store todo lo que era caché, todo lo que era derivado y todo lo que era local, lo que queda es sorprendentemente poco: una sesión, unas cuantas preferencias, algún borrador de proceso largo. En ese punto la pregunta honesta ya no es cómo organizar mejor Redux, sino si ese remanente justifica la infraestructura completa o se sostiene con un store ligero. No hay una respuesta universal —un equipo grande con auditoría, deshacer y flujos complejos tiene razones que otro no tiene—, pero sí hay una respuesta deshonesta, y es no hacerse la pregunta porque el store ya estaba ahí.
Toda la confusión que produce este error nace de una categoría que el vocabulario del frontend tardó una década en distinguir, y hasta que no se distingue no hay forma de razonar bien: la palabra estado se usó indistintamente para dos cosas que no comparten casi ninguna propiedad. Una es el estado que tu aplicación posee, del que es autoridad única, que solo cambia cuando ella decide cambiarlo y que por tanto siempre es correcto por construcción: no puede estar desactualizado porque no hay nada respecto a lo cual retrasarse. La otra es el estado del que tu aplicación solo tiene una copia, cuya autoridad está en otra máquina, que puede cambiar mientras tu usuario mira la pantalla y que por tanto es incorrecto por defecto: lo mejor que puedes garantizar es una cota sobre su antigüedad. Redux, Flux y toda la familia unidireccional se diseñaron con exquisito cuidado para el primer problema, y sus decisiones fundacionales lo demuestran: reducers síncronos y puros porque una decisión propia se resuelve en el acto, historial completo porque la secuencia de decisiones es reconstruible, inmutabilidad porque cada decisión produce un estado que merece conservarse. Ninguna de esas decisiones ayuda con el segundo problema, y algunas estorban, porque la naturaleza de una caché es ser asíncrona, parcial, invalidable y desechable. Meter datos de servidor en un store de decisiones no es un error de organización sino un error de categoría: aplicas la maquinaria del determinismo a un dominio cuya característica definitoria es que no lo controlas. Por eso la solución nunca es organizar mejor los slices, poner nombres más claros o normalizar con más rigor; es reconocer que había dos problemas donde creías tener uno, y darle a cada uno la herramienta que se construyó para él. Quien interioriza esta distinción deja de preguntarse dónde va cada dato y empieza a preguntarse de quién es, que es la pregunta que sí tiene una respuesta objetiva.
- Clasifica cada slice de tu store en estado propio o copia del servidor aplicando la pregunta de quién puede cambiar el valor sin que tu aplicación intervenga.
- Cuenta las líneas dedicadas a ciclo de vida de peticiones frente a las dedicadas a reglas de tu producto, y calcula la proporción.
- Revisa cuáles de los siete problemas de caché has resuelto ya a mano y cuáles siguen sin resolver; los segundos son bugs latentes, no ausencias.
- Migra el recurso de solo lectura con más consumidores a una consulta declarativa y borra su slice por completo.
- Convierte una invalidación manual esparcida en thunks en etiquetas declaradas junto a la mutación, y comprueba cuántas líneas desaparecen.
- Cuando termines, escribe la lista de lo que queda en el store y decide de forma explícita y argumentada si esa lista justifica la infraestructura que la sostiene.