TanStack Query en React: sacar el estado de servidor de Redux para dejar de sincronizarlo a mano
Redux resuelve bien un problema: coordinar estado de cliente entre componentes que no comparten un padre directo. Filtros de una tabla, un modal abierto, el tema de la interfaz, el usuario autenticado. Ese estado nace en el cliente, cambia por acciones del cliente, y nadie más tiene una copia "más actualizada" de él. El problema aparece cuando la misma store empieza a guardar también los datos que vienen del servidor: la lista de pedidos, el perfil del usuario, el catálogo de productos. Ese estado no nace en el cliente, puede cambiar sin que el cliente haga nada, y en cualquier momento hay una copia en el servidor que puede no coincidir con la que tenemos en memoria. Tratarlo con las mismas herramientas que el estado de cliente es donde empiezan los bugs de sincronización.
Cómo se ve el patrón antes de que duela
La forma habitual de traer datos de servidor a Redux es un slice con un thunk asíncrono y tres banderas por recurso:
const ordersSlice = createSlice({
name: 'orders',
initialState: { data: [], loading: false, error: null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchOrders.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchOrders.fulfilled, (state, action) => {
state.loading = false;
state.data = action.payload;
})
.addCase(fetchOrders.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message;
});
},
});
Esto funciona para el primer caso de uso: un componente que monta, dispara fetchOrders, y muestra la lista. El problema aparece en el segundo caso de uso, cuando otra pantalla necesita el mismo recurso. Si esa segunda pantalla también dispara fetchOrders al montar, hay una petición de red duplicada que ninguna de las dos partes sabe que la otra está haciendo. Si en cambio se decide reusar el estado ya cargado para no duplicar la petición, hay que empezar a escribir a mano la lógica de cuándo ese estado ya está "viejo" y hay que refrescarlo: cuánto tiempo pasó desde el último fetch, si el usuario volvió a la pestaña, si una mutación en otra parte de la app cambió ese mismo recurso.
Esa última parte es la que de verdad duele. Si una mutación crea un pedido nuevo, alguien tiene que acordarse de despachar una acción que actualice orders.data con el pedido recién creado, o de volver a disparar fetchOrders para traer la lista actualizada. Esa sincronización vive como código explícito, repartido entre el reducer de la mutación y el de la consulta, y depende de que quien escriba la mutación se acuerde de que existe otro slice que necesita enterarse. En un proyecto con pocos recursos esto se sostiene. En uno con veinte o treinta endpoints distintos, cada uno con su slice, su thunk y sus tres banderas, la cantidad de código que existe solo para mantener la caché coherente supera con facilidad al código que resuelve el problema de negocio real.
Lo que TanStack Query cambia de fondo
TanStack Query no reemplaza a Redux como gestor de estado de cliente; reemplaza la parte de Redux que se usaba, con esfuerzo, como gestor de estado de servidor. La diferencia de diseño es que la caché no vive atada a un slice que alguien tiene que declarar por adelantado, sino a una clave:
function useOrders(customerId) {
return useQuery({
queryKey: ['orders', customerId],
queryFn: () => api.get(`/customers/${customerId}/orders`).then((r) => r.data),
});
}
Dos componentes que llaman a useOrders('c_123') en cualquier parte del árbol comparten la misma entrada de caché identificada por ese queryKey, sin que ninguno de los dos sepa de la existencia del otro. Si ambos montan casi al mismo tiempo, TanStack Query deduplica la petición en curso en lugar de disparar dos llamadas de red idénticas. Eso resuelve, sin escribir una línea de coordinación, el primer problema que antes había que resolver a mano con un slice compartido.
El segundo problema -saber cuándo los datos están viejos- se vuelve declarativo en vez de una lógica esparcida por el código:
useQuery({
queryKey: ['orders', customerId],
queryFn: fetchOrders,
staleTime: 30_000,
});
Mientras la consulta se considera "fresca" (dentro de staleTime), montar el mismo componente en otra parte del árbol no dispara una nueva petición: se sirve desde caché. Pasado ese tiempo, la próxima vez que el componente se monta, o la ventana recupera el foco, o la conexión de red vuelve, TanStack Query refresca en segundo plano y actualiza la UI sola cuando llega la respuesta, sin bloquear lo que ya se estaba mostrando.
La sincronización tras una mutación, que era el punto más frágil del patrón con Redux, se resuelve invalidando la clave en vez de despachar una acción que actualice el estado a mano:
function useCreateOrder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (order) => api.post('/orders', order),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['orders', variables.customerId] });
},
});
}
invalidateQueries marca esa entrada de caché como vieja y dispara un refetch si hay algún componente montado que la esté usando en ese momento. Quien escribe la mutación no necesita saber la forma exacta de los datos que devuelve /customers/:id/orders, ni tocar ningún reducer ajeno: solo declara qué clave dejó de ser válida. La responsabilidad de traer los datos actualizados vuelve a vivir en el mismo lugar que la responsabilidad de pedirlos la primera vez.
Lo que no funcionó: migrar también el estado que sí es de cliente
El error que cometimos al adoptar TanStack Query fue llevarlo demasiado lejos. En una pantalla con una tabla de pedidos filtrable, movimos también el filtro seleccionado y el estado de un modal de detalle a useQuery, usando el filtro como parte del queryKey y una queryFn que en realidad no llamaba a ningún servidor, solo devolvía el valor sincrónicamente. La intención era "todo el estado de la pantalla en un solo mecanismo", pero el resultado fue peor en dos frentes concretos: actualizar el filtro pasó a depender de queryClient.setQueryData, una API pensada para escribir resultados de red en caché, no para manejar el onChange de un <select>; y el modal de detalle, al no tener ninguna razón real para revalidarse ni refrescarse en segundo plano, ganaba comportamiento de TanStack Query -reintentos, refetch al recuperar el foco de la ventana- que no tenía sentido para un booleano que solo cambia cuando el usuario hace clic. Revertimos esa parte: el filtro y el estado del modal volvieron a un useState simple, y useQuery se quedó únicamente con los datos que de verdad vienen de una petición de red.
El criterio que quedó
La pregunta que usamos ahora para decidir dónde vive un pedazo de estado no es "¿es global o local?", que es la pregunta que llevaba a meter todo en Redux por costumbre. Es: ¿este valor existe de forma independiente en el servidor, y puede volverse viejo sin que el cliente haga nada? Si la respuesta es sí, es estado de servidor y va a TanStack Query, con una queryKey que describe el recurso y una invalidación explícita en cada mutación que lo afecta. Si la respuesta es no -es un filtro, un modal, un token de sesión que el propio cliente decidió guardar- sigue siendo estado de cliente, y Redux, Zustand o un useState simple lo resuelven sin necesitar ninguno de los mecanismos de caché que TanStack Query trae para el otro problema. Redux no desapareció de los proyectos donde hicimos este cambio; dejó de cargar datos que nunca debieron estar ahí.