TanStack Query in React: Getting Server State Out of Redux So You Stop Syncing It by Hand
Redux is good at one problem: coordinating client state across components that don't share a direct parent. Table filters, whether a modal is open, the current theme, the logged-in user. That state is born on the client, changes because of client actions, and nobody else holds a "more current" copy of it. The trouble starts when the same store also begins holding data that comes from the server: the list of orders, the user's profile, the product catalog. That state isn't born on the client, it can change without the client doing anything, and at any moment there's a copy on the server that may no longer match what's sitting in memory. Treating it with the same tools as client state is where the synchronization bugs start.
What the pattern looks like before it hurts
The usual way to bring server data into Redux is a slice with an async thunk and three flags per resource:
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;
});
},
});
This works for the first use case: a component mounts, dispatches fetchOrders, and renders the list. The trouble shows up on the second use case, when another screen needs the same resource. If that second screen also dispatches fetchOrders on mount, there's a duplicate network request that neither side knows the other is making. If instead the team decides to reuse the already-loaded state to avoid the duplicate call, someone now has to hand-write the logic for when that state has gone "stale" and needs refreshing: how long since the last fetch, whether the user switched back to the tab, whether a mutation somewhere else in the app touched that same resource.
That last part is the one that actually hurts. If a mutation creates a new order, someone has to remember to dispatch an action that patches orders.data with the newly created order, or to re-trigger fetchOrders to bring back the updated list. That synchronization lives as explicit code, split between the mutation's reducer and the query's reducer, and it depends on whoever writes the mutation remembering that another slice needs to hear about it. On a project with a handful of resources this holds up. On one with twenty or thirty different endpoints, each with its own slice, thunk, and three flags, the amount of code that exists purely to keep the cache coherent easily outgrows the code that solves the actual business problem.
What TanStack Query changes underneath
TanStack Query doesn't replace Redux as a client-state manager; it replaces the part of Redux that was being used, awkwardly, as a server-state manager. The design difference is that the cache isn't tied to a slice someone has to declare ahead of time — it's tied to a key:
function useOrders(customerId) {
return useQuery({
queryKey: ['orders', customerId],
queryFn: () => api.get(`/customers/${customerId}/orders`).then((r) => r.data),
});
}
Two components that call useOrders('c_123') anywhere in the tree share the same cache entry identified by that queryKey, without either one knowing the other exists. If both mount around the same time, TanStack Query deduplicates the in-flight request instead of firing two identical network calls. That solves, without writing a single line of coordination code, the first problem that previously had to be handled by hand through a shared slice.
The second problem — knowing when data has gone stale — becomes declarative instead of logic scattered across the codebase:
useQuery({
queryKey: ['orders', customerId],
queryFn: fetchOrders,
staleTime: 30_000,
});
While the query is considered "fresh" (within staleTime), mounting the same component elsewhere in the tree doesn't trigger a new request — it's served from cache. Once that window passes, the next time the component mounts, or the window regains focus, or the network connection comes back, TanStack Query refetches in the background and updates the UI on its own once the response lands, without blocking whatever was already on screen.
Post-mutation synchronization, which was the most fragile part of the Redux pattern, gets solved by invalidating the key instead of dispatching an action that patches state by hand:
function useCreateOrder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (order) => api.post('/orders', order),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['orders', variables.customerId] });
},
});
}
invalidateQueries marks that cache entry as stale and triggers a refetch if any mounted component happens to be using it right now. Whoever writes the mutation doesn't need to know the exact shape of the data /customers/:id/orders returns, nor touch any reducer that belongs to a different feature — they just declare which key stopped being valid. The responsibility for fetching fresh data ends up living in the same place as the responsibility for fetching it the first time.
What didn't work: migrating state that was actually client state
The mistake we made adopting TanStack Query was pushing it too far. On a screen with a filterable orders table, we also moved the selected filter and a detail modal's open/closed state into useQuery, using the filter as part of the queryKey and a queryFn that didn't actually call any server — it just returned the value synchronously. The intent was "all screen state through one mechanism," but the result was worse on two concrete fronts: updating the filter now depended on queryClient.setQueryData, an API meant for writing network results into cache, not for handling a <select>'s onChange; and the detail modal, having no real reason to revalidate or refetch in the background, picked up TanStack Query behavior — retries, refetch on window focus — that made no sense for a boolean that only changes when the user clicks something. We reverted that part: the filter and the modal state went back to a plain useState, and useQuery was left holding only the data that genuinely comes from a network request.
The rule we ended up with
The question we now use to decide where a piece of state lives isn't "is it global or local," which is the question that led to dumping everything into Redux out of habit. It's: does this value exist independently on the server, and can it go stale without the client doing anything? If the answer is yes, it's server state, and it goes into TanStack Query, with a queryKey that describes the resource and an explicit invalidation on every mutation that affects it. If the answer is no — it's a filter, a modal, a session token the client itself decided to hold onto — it's still client state, and Redux, Zustand, or a plain useState handle it fine without needing any of the caching machinery TanStack Query brings for the other problem. Redux didn't disappear from the projects where we made this change; it just stopped carrying data that never belonged there.