Paso uno: sacar el estado del servidor
El primer movimiento de toda migración no es cambiar de librería de estado sino extraer del store la clase de estado que nunca debió vivir en él. Esta lección muestra cómo trasladar el estado del servidor a una caché de datos —TanStack Query si quieres salir del ecosistema, RTK Query si prefieres quedarte— endpoint por endpoint y sin big bang, qué desaparece exactamente al hacerlo (thunks, banderas de carga, reductores de éxito y error, entidades normalizadas a mano) y por qué este paso resuelve por sí solo la mayor parte del dolor que motivaba la migración, hasta el punto de que muchos equipos descubren aquí que ya no necesitan el paso dos.
La auditoría de la lección anterior termina casi siempre con el mismo veredicto incómodo: la mayor parte de lo que tu store guarda entró por la red y caduca sola. Ese hallazgo determina el orden de la migración, porque de nada sirve mover un mal modelo de un contenedor a otro. El primer movimiento no es cambiar de librería de estado sino sacar del store una clase entera de estado que nunca le correspondió, y entregarla a una herramienta especializada en el problema real —obsolescencia, revalidación, deduplicación e invalidación— que ningún reductor puro puede resolver. Lo notable de este paso es su rendimiento desproporcionado: elimina la mayoría del código, la mayoría de los bugs de sincronización y la mayoría de la ceremonia, todo antes de haber tocado una sola línea de estado de cliente. Muchos equipos que llegan aquí convencidos de que iban a abandonar Redux descubren que, terminado el paso uno, ya no queda dolor que justifique el paso dos.
- Entender por qué el estado del servidor sale primero y qué desaparece exactamente cuando sale.
- Elegir destino con criterio:
TanStack Querypara salir del ecosistema,RTK Querypara quedarse en él. - Ejecutar la extracción endpoint por endpoint, con los dos sistemas conviviendo sin conflicto.
- Reconocer las señales de que el paso uno bastó y el paso dos ya no compra nada.
Qué desaparece cuando el servidor se marcha
Conviene mirar de cerca la maquinaria que un slice de datos remotos necesita en Redux, porque la magnitud de lo que se borra es el argumento entero de esta lección. Un solo recurso exige un thunk que gestione su ciclo de vida, tres reductores para los tres estados de esa promesa, dos banderas de interfaz y una estructura normalizada que mantener a mano en cada mutación posterior. Multiplicado por cada endpoint de la aplicación, ese patrón es la mayor parte del volumen del store.
// el patron completo por recurso, repetido tantas veces como endpoints tengas
const traerPedidos = createAsyncThunk('pedidos/traer', async () => api.pedidos())
const slice = createSlice({
name: 'pedidos',
initialState: { porId: {}, ids: [], cargando: false, error: null },
reducers: {},
extraReducers: (b) => {
b.addCase(traerPedidos.pending, (s) => { s.cargando = true; s.error = null })
b.addCase(traerPedidos.fulfilled, (s, a) => { s.cargando = false; guardar(s, a.payload) })
b.addCase(traerPedidos.rejected, (s, a) => { s.cargando = false; s.error = a.error.message })
},
})
Nada de ese código sobrevive a la extracción, y su sustituto no es un equivalente más corto sino una declaración de intención. La caché asume el ciclo de vida completo, las banderas dejan de existir como estado porque pasan a ser propiedades derivadas de la consulta, y la normalización manual desaparece porque la identidad del dato la lleva la clave y no una tabla que tú mantienes:
// el sustituto no es codigo mas corto: es la ausencia de un ciclo de vida propio
function usePedidos() {
return useQuery({ queryKey: ['pedidos'], queryFn: api.pedidos, staleTime: 30_000 })
}
Thunks y reductores
El ciclo pendiente, cumplido y rechazado deja de ser código tuyo. La caché lo modela una vez para todos los recursos.
Banderas de carga
cargando y error dejan de ser estado almacenado y pasan a ser propiedades derivadas de cada consulta, imposibles de desincronizar.
Normalización manual
La tabla por identificador que mantenías a mano la sustituye la clave de consulta, que además deduplica peticiones idénticas de componentes lejanos.
Refresco y reintentos
Revalidación al recuperar el foco, reintentos con espera creciente y actualización en segundo plano vienen de fábrica, no se implementan.
Hay un beneficio menos visible y más valioso que el recuento de líneas: desaparece toda una familia de bugs. Los datos que viven en un store no saben que han caducado, así que la aplicación muestra información obsoleta hasta que alguien recarga; y cuando dos pantallas guardan copias del mismo recurso, se desincronizan en cuanto una de ellas escribe. Ninguno de esos dos fallos es un descuido de implementación que un equipo cuidadoso pueda evitar: son consecuencias estructurales de guardar una verdad ajena en un contenedor que no modela la obsolescencia.
Elegir destino sin dogmatismo
La decisión entre TanStack Query y RTK Query se presenta a menudo como una batalla de calidad, y no lo es: ambas resuelven el mismo problema con modelos comparables. La pregunta correcta no es cuál es mejor sino a dónde quiere ir el equipo después.
Si el objetivo final es abandonar Redux, TanStack Query es el destino coherente, porque desacopla la capa de datos del store desde el primer día y deja el paso dos como una decisión libre en vez de un compromiso heredado. Si el objetivo es quedarse en Redux y solo dejar de sufrir, RTK Query es más barato: vive dentro del store que ya tienes, comparte las DevTools, se configura con el mismo configureStore y no añade una dependencia conceptual nueva al equipo. Elegir TanStack Query cuando vas a quedarte en Redux añade una segunda cosmovisión sin comprarte nada; elegir RTK Query cuando vas a salir te obliga a migrar dos veces. La característica que decide no está en las tablas comparativas: está en el plan a doce meses.
El aterrizaje en RTK Query es deliberadamente familiar para quien ya vive en el ecosistema, y su sistema de etiquetas sustituye la invalidación manual que antes escribías a mano tras cada mutación:
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Pedido'],
endpoints: (b) => ({
pedidos: b.query({ query: () => 'pedidos', providesTags: ['Pedido'] }),
crearPedido: b.mutation({ query: (body) => ({ url: 'pedidos', method: 'POST', body }), invalidatesTags: ['Pedido'] }),
}),
})
Lo que este fragmento hace desaparecer merece nombrarse: la costumbre de despachar, tras cada mutación, una acción de recarga del recurso afectado. Esa recarga manual era una de las fuentes de bug más persistentes de los stores grandes, porque olvidar una sola invalidación deja la interfaz mintiendo sin que ningún test lo note. Declarar la relación una vez, con etiquetas, convierte un deber de disciplina en una propiedad del sistema.
Hay una consideración de equipo que la comparación técnica suele omitir. Introducir TanStack Query en un store de Redux añade una segunda forma de pensar sobre los datos que conviven durante meses, y esa dualidad tiene un coste de comunicación real: cada persona que se incorpora aprende dos modelos, y cada revisión de código exige recordar cuál gobierna el recurso que se está tocando. RTK Query evita ese coste a cambio de atarte al ecosistema. Ninguna de las dos opciones es gratis, y elegir sabiendo cuál factura estás firmando es más útil que buscar la que no tenga ninguna.
La coreografía: endpoint por endpoint
La extracción no se hace de una vez, y quien lo intenta suele abandonar a mitad. El procedimiento seguro es incremental por recurso, con los dos sistemas conviviendo durante semanas sin ninguna incompatibilidad, porque no compiten por la misma responsabilidad: la caché nueva gobierna los recursos ya migrados y el store viejo conserva intactos los que aún no lo están.
flowchart LR V[componente] --> A[recursos ya migrados] V --> B[recursos aun en redux] A --> Q[cache de datos] B --> S[store de redux] Q --> RED[servidor] S --> RED style Q fill:#a6e3a1,color:#11111b style S fill:#f38ba8,color:#11111b
Esa convivencia sin fricción no es una casualidad afortunada sino una consecuencia de que ambas herramientas hablan con el servidor y no entre ellas. Un componente puede leer en la misma función una consulta de la caché nueva y un selector del store antiguo sin que ninguna de las dos capas necesite saber de la otra, porque no comparten estado: comparten origen. Esa independencia es lo que permite estirar la migración durante el tiempo que haga falta sin que la aplicación pase nunca por un estado inconsistente.
El orden de los recursos no es indiferente. Empieza por el más aislado —uno que solo lea una pantalla y que no escriba nadie— para pagar el coste de aprendizaje sin riesgo, y sigue por el más doloroso: aquel cuyo desfase produce los bugs que llevas meses parcheando. Deja para el final los recursos que muchas pantallas comparten, porque son los que más se benefician de que el equipo ya domine la deduplicación por clave.
Durante la extracción, cada recurso debe tener un único dueño en todo momento. La forma de fracasar es dejar un recurso a medias: leído por la caché en la pantalla nueva y por el store en la vieja, con una acción que todavía lo escribe en Redux. En ese estado las dos copias divergen y el equipo concluye que la herramienta nueva es poco fiable, cuando el problema es que el recurso tiene dos dueños. La disciplina es simple y no admite atajos: un recurso se migra entero en un mismo cambio —todas sus lecturas, todas sus escrituras y el borrado de su slice— o no se migra. Migrar la mitad de un recurso es estrictamente peor que no migrarlo.
Lo que la caché no se lleva
Sería un error concluir que todo lo que rodeaba a un recurso remoto se marcha con él. Tres cosas se quedan, y confundirlas con estado del servidor es la forma más común de estropear el paso uno. La primera es el borrador de edición: cuando un formulario carga un perfil y el usuario escribe en él, el valor tecleado no es una copia del servidor sino estado de cliente con vida propia hasta que se envía. La segunda es la selección: qué fila está marcada, qué elemento se abrió en un panel lateral. La tercera son los parámetros de consulta —filtros, ordenación, página— que determinan qué se pide, y que pertenecen a la URL o al cliente aunque su efecto sea traer datos remotos.
// el parametro es del cliente; el resultado es de la cache. No se mezclan.
function usePedidosFiltrados(estado: EstadoPedido) {
return useQuery({
queryKey: ['pedidos', estado],
queryFn: () => api.pedidos(estado),
})
}
La relación entre ambas capas es limpia precisamente porque el parámetro forma parte de la clave: cambiar el filtro no obliga a invalidar nada ni a despachar una recarga, porque una clave distinta es sencillamente otra entrada de la caché, con su propio ciclo de vida y su propio resultado ya memorizado si se visitó antes. El patrón antiguo —guardar el filtro en el store, despachar un thunk al cambiarlo y sobrescribir la lista— desaparece entero, y con él la clase de bug donde el filtro y los datos mostrados no coinciden porque una respuesta lenta llegó después de otra rápida.
La violación más frecuente durante el paso uno no es dejar datos en Redux sino sacarlos de la caché para meterlos en un useState mediante un efecto que se dispara al llegar la respuesta. Ese patrón reproduce en miniatura exactamente el defecto que se estaba corrigiendo: crea una segunda copia que no sabe revalidar, que no se entera de las invalidaciones y que queda obsoleta en silencio. La excepción legítima es el borrador de edición, y se distingue por una prueba sencilla: si el usuario ha escrito en él, es suyo y es estado de cliente; si solo lo ha leído, es de la caché y debe leerse de ella cada vez.
La estructura de este nivel invita a leer la extracción del estado del servidor como la primera etapa de un camino que lleva inevitablemente a cambiar de librería, y esa lectura invierte la relación real entre las dos cosas. El estado del servidor no se saca para preparar la salida de Redux; se saca porque estaba en el sitio equivocado, y eso sería igual de cierto si pensaras quedarte en Redux para siempre. Lo interesante es lo que ocurre después. Cuando la clase de estado más voluminosa, más difícil y más propensa a fallar abandona el store, lo que queda dentro suele ser tan modesto —una sesión, un tema, dos banderas de interfaz, un identificador seleccionado— que la pregunta que abrió el nivel cambia de sentido. Ya no es si Redux es la herramienta adecuada para tu aplicación, sino si merece la pena reescribir doscientas líneas inofensivas para ahorrar unos kilobytes. Muchos equipos descubren en ese punto que el sufrimiento que atribuían a Redux era en realidad el sufrimiento de sostener una caché con un reductor, y que resuelto eso, la ceremonia restante es tan pequeña que no molesta a nadie. Por eso el paso uno se ejecuta antes de decidir nada sobre el paso dos, y no al revés: es el único movimiento cuya conveniencia no depende del destino final, y es también el que aporta la información que hace racional elegir ese destino. Quien migra a Zustand sin haber extraído antes el servidor no ha cambiado de arquitectura, ha cambiado la sintaxis con la que escribe el mismo error; y quien extrae el servidor primero descubre, con datos y no con opiniones, cuánta arquitectura le quedaba realmente por cambiar.
- Elige de tu inventario el recurso de servidor más aislado: pocas lecturas, un solo escritor, ninguna dependencia con otros
slices. - Decide destino según tu plan a doce meses y escribe en una frase por qué
TanStack QueryoRTK Query, sin apelar a características sueltas. - Migra ese recurso entero en un solo cambio: todas sus lecturas, todas sus escrituras y el borrado de su
slice, susthunksy sus banderas. - Cuenta las líneas eliminadas y las añadidas. Anota la diferencia, porque es la mejor estimación que tendrás del ahorro total.
- Repite con el recurso que más bugs de desfase te ha costado y comprueba si esos bugs desaparecen sin escribir código específico para ellos.
- Con dos recursos fuera, vuelve al inventario y recalcula qué queda en el
store. Escribe si ese resto justifica todavía una migración de librería.