Once your React app is making more than a handful of API calls, the question of which data-fetching library to standardise on becomes hard to put off. Two libraries dominate that decision. RTK Query, bundled into Redux Toolkit, and TanStack Query, the framework-agnostic successor to React Query. They solve the same core problems: caching, deduplication, background refetching, loading and error states. Where they differ is ergonomics, framework breadth, and how much of your existing stack they assume.
TL;DR
If your app already uses Redux Toolkit, pick RTK Query. You'll get data fetching that plugs into your store, middleware, and DevTools without adding a second cache or a second mental model. If you don't have Redux, work across multiple frameworks, or want the lighter overall footprint, pick TanStack Query. Both libraries are good. The right choice depends almost entirely on what's already in your stack.
Comparison at a glance
| Dimension | RTK Query | TanStack Query |
|---|---|---|
| Caching model | Tag-based (providesTags / invalidatesTags) | Query-key-based (queryKey + invalidateQueries) |
| Query API | createApi + generated hooks | useQuery / useMutation per call site |
| Codegen | OpenAPI and GraphQL generators ship with Redux Toolkit | No first-party codegen; community plugins exist |
| Redux integration | Native. Uses the same store, middleware, and DevTools | None by default; can be used alongside Redux |
| Framework support | React only | React, Vue, Solid, Svelte, Angular, Lit, Preact (source) |
| Gzipped bundle | ~13.6 KB (Redux Toolkit 2.12.0 on Bundlephobia) | ~13.6 KB (@tanstack/react-query 5.100.11 on Bundlephobia) |
| GitHub stars (May 2026) | 11.2k on the redux-toolkit repo | 49.5k on the TanStack/query repo |
| TypeScript story | Good. Endpoint generics flow through to hooks | Good. Query and mutation generics inferred from queryFn |
| DevTools | Redux DevTools (shows every request as a Redux action) | Dedicated React Query DevTools panel |
| Learning curve | Steeper if you don't already know Redux | Lower entry barrier; concepts are local to data fetching |
The two libraries cluster closely on bundle size and TypeScript quality, then diverge sharply on framework support and Redux integration. That's where the real decision lives.
A fair head-to-head: list + create mutation
To make the trade-offs concrete, here is the same use case in both libraries. We fetch a list of users, then create a new user with a mutation that invalidates the list so the UI updates automatically.
The RTK Query version
We start with a base API slice that holds shared configuration. Every feature in the app injects its endpoints into this base.
src/api/base.api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const baseApi = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Users'],
endpoints: () => ({}),
})
Notice the empty endpoints object. We leave it lean so each feature can add its own endpoints from its own file. The tagTypes are declared centrally because RTK Query needs to know about every tag upfront for cache invalidation to wire up correctly.
Now to add the users feature:
src/features/users/users.api.ts
import { baseApi } from '@/api/base.api'
export type User = { id: string; name: string; email: string }
const usersApi = baseApi.injectEndpoints({
endpoints: builder => ({
getUsers: builder.query<User[], void>({
query: () => '/users',
providesTags: result =>
result
? [
...result.map(({ id }) => ({ type: 'Users' as const, id })),
{ type: 'Users' as const, id: 'LIST' },
]
: [{ type: 'Users' as const, id: 'LIST' }],
}),
createUser: builder.mutation<User, Omit<User, 'id'>>({
query: body => ({ url: '/users', method: 'POST', body }),
invalidatesTags: [{ type: 'Users', id: 'LIST' }],
}),
}),
})
export const { useGetUsersQuery, useCreateUserMutation } = usersApi
And the component that consumes both hooks:
src/features/users/UsersList.tsx
import { useGetUsersQuery, useCreateUserMutation } from './users.api'
const UsersList = () => {
const { data: users, isLoading, error } = useGetUsersQuery()
const [createUser, { isLoading: isCreating }] = useCreateUserMutation()
if (isLoading) return <p>Loading...</p>
if (error) return <p>Could not load users.</p>
return (
<div>
<ul>
{users?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
<button
onClick={() => createUser({ name: 'Ada', email: 'ada@example.com' })}
disabled={isCreating}
>
Add user
</button>
</div>
)
}
The component dispatches no actions. The generated hooks handle the fetch, the cache, and the loading state. The refetch after the mutation is wired automatically by the invalidatesTags on createUser and the providesTags on getUsers. When the button is clicked, the mutation runs, the Users / LIST tag invalidates, and the list query refetches without a single line of orchestration in the component.
The TanStack Query version
TanStack Query doesn't ask you to declare endpoints centrally. You write the fetch function where you need it, then call useQuery or useMutation from a component or a hook.
src/features/users/users.api.ts
export type User = { id: string; name: string; email: string }
export const fetchUsers = async (): Promise<User[]> => {
const res = await fetch('/api/users')
if (!res.ok) throw new Error('Failed to fetch users')
return res.json()
}
export const createUser = async (
body: Omit<User, 'id'>,
): Promise<User> => {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) throw new Error('Failed to create user')
return res.json()
}
And the component:
src/features/users/UsersList.tsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { fetchUsers, createUser, User } from './users.api'
const UsersList = () => {
const queryClient = useQueryClient()
const { data: users, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
})
const { mutate, isPending } = useMutation({
mutationFn: createUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
if (isLoading) return <p>Loading...</p>
if (error) return <p>Could not load users.</p>
return (
<div>
<ul>
{users?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
<button
onClick={() => mutate({ name: 'Ada', email: 'ada@example.com' })}
disabled={isPending}
>
Add user
</button>
</div>
)
}
Two things stand out side by side. The TanStack Query version has less ceremony. No base slice, no endpoint registration, no tagTypes declaration. The trade-off is that cache invalidation is explicit. You name the query key, and on mutation success you tell the query client which key to invalidate. RTK Query trades a little upfront setup for the invalidation being declarative on the endpoint itself.
Neither version is more correct than the other. They surface different priorities.
When to pick each
The choice tracks fairly cleanly to a few real-world scenarios.
Pick RTK Query when
You're already using Redux Toolkit. If your store, slices, and middleware are set up, RTK Query is the path of least resistance. It uses the same store, the same middleware pipeline, and the same DevTools. You don't add a second cache or a second mental model.
Your team values declarative cache invalidation. Tag-based invalidation puts the relationship between queries and mutations on the endpoint definition itself, not on the component that triggers the mutation. A new developer reading the slice can see which queries refetch when updateUser runs. In TanStack Query, that linkage lives in the onSuccess of every mutation that touches it.
You need OpenAPI or GraphQL codegen without extra setup. Redux Toolkit ships official generators for both. They produce a slice with typed endpoints from an OpenAPI spec or a GraphQL schema, which means the entire API surface is typed and refetched automatically when the spec changes.
Pick TanStack Query when
You're not already on Redux. Pulling Redux Toolkit in only to use RTK Query means adopting a state-management library, a middleware chain, and a set of conventions you may not otherwise need. TanStack Query has no opinions about your client state. It manages server state in its own cache and leaves everything else alone.
You work across multiple frameworks. TanStack Query officially supports React, Vue, Solid, Svelte, Angular, Lit, and Preact (source). If your company runs a React product alongside a Vue admin panel, sharing the data layer means standardising on TanStack Query. RTK Query is React-only.
You want fine-grained control over query keys. Query keys in TanStack Query are arrays, and you can structure them however you like: ['users'], ['users', userId], ['users', { status: 'active', page: 2 }]. Partial-match invalidation falls out of that structure naturally. You call invalidateQueries({ queryKey: ['users'] }) and every key starting with users refetches.
Pick either when it doesn't really matter
For a small app with one or two API resources and no Redux footprint, both libraries do the job, and the deciding factor is usually familiarity. I would not waste a week comparing the two when the app has six endpoints. Pick whichever your team has used before and move on.
Caching model: where the real differences live
The surface APIs look similar. The caching internals differ in ways that affect how you reason about invalidation as the app grows.
How RTK Query caches and invalidates
RTK Query stores every query result keyed by the endpoint name and the serialised query argument. A query for getUserById(42) lives at a different cache entry to getUserById(43). The library deduplicates concurrent requests. If two components call useGetUserByIdQuery(42) in the same render, only one network request goes out, and both components subscribe to the same cached result.
Invalidation runs through tags. A query says "I provide this data" via providesTags. A mutation says "I make this data stale" via invalidatesTags. When the tags overlap, the affected query refetches automatically.
getUsers: builder.query<User[], void>({
query: () => '/users',
providesTags: result =>
result
? [
...result.map(({ id }) => ({ type: 'Users' as const, id })),
{ type: 'Users' as const, id: 'LIST' },
]
: [{ type: 'Users' as const, id: 'LIST' }],
}),
updateUser: builder.mutation<User, { id: string; name: string }>({
query: ({ id, ...patch }) => ({
url: `/users/${id}`,
method: 'PATCH',
body: patch,
}),
invalidatesTags: (result, error, { id }) => [{ type: 'Users', id }],
}),
When updateUser fires, only the cache entry for that specific user invalidates. The list query, which provides a LIST tag plus per-ID tags, refetches the affected entry but doesn't necessarily refetch the whole list. That precision matters when a list view shares the network with individual detail views and you don't want every edit to trigger a full list refetch.
The trade-off is that you have to register tag types upfront in createApi, and every mutation has to declare what it invalidates. The verbosity is real. The pay-off is that cache relationships live in the slice file. No hunting through component code to find out what refetches when.
How TanStack Query caches and invalidates
TanStack Query stores results keyed by the query key, an array you provide on every useQuery call. The key is hashed deterministically, so two calls with ['users', { status: 'active' }] resolve to the same cache entry. Components that share a key share a cached result, and the library deduplicates network requests the same way RTK Query does.
Invalidation is imperative. You call queryClient.invalidateQueries({ queryKey: ['users'] }), and every query whose key starts with ['users'] is marked stale and refetched on next use.
const { mutate } = useMutation({
mutationFn: updateUser,
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['users', id] })
queryClient.invalidateQueries({ queryKey: ['users', 'list'] })
},
})
The benefit is that prefix matching gives you flexible invalidation patterns for free. Want to refetch everything users-related? invalidateQueries({ queryKey: ['users'] }) does it. Only a specific user? invalidateQueries({ queryKey: ['users', 42] }). Every list view across the app? Use a key prefix that matches them. The library doesn't constrain how you structure keys.
The downside, in my experience, is discipline. Without a convention for how keys are structured, you can end up with ['user', id] in one place and ['users', id] in another, and the prefix-match invalidation silently fails. Teams that adopt TanStack Query at scale almost always end up writing a query key factory, a helper that builds keys consistently across the codebase.
Stale-while-revalidate and refetch behaviour
Both libraries follow stale-while-revalidate semantics by default. They serve the cached value immediately, then refetch in the background if the data is considered stale. The defaults differ.
TanStack Query treats data as stale immediately (staleTime: 0 by default), which means it refetches on every component mount, window focus, and reconnect. That's aggressive, and for most apps it's the right default, because users expect data to be fresh when they come back to the tab. You can tune it per query with staleTime and gcTime (formerly cacheTime).
RTK Query treats data as fresh forever by default. Queries don't refetch on mount unless you set refetchOnMountOrArgChange on the endpoint or the hook call. Background refetching is opt-in via refetchOnFocus and refetchOnReconnect, which are off by default. The conservative defaults mean fewer surprise network calls, but you'll set these flags on most apps that need TanStack-Query-style freshness.
The two defaults frame the trade-off differently. TanStack Query optimises for "the user always sees fresh data, even if that costs a request." RTK Query optimises for "no request runs unless I asked for it."
Migration considerations
Teams thinking about moving from one library to the other usually have one of two motivations. They're already on Redux Toolkit and want the smaller mental model TanStack Query offers, or they're already on TanStack Query and want RTK Query's tag-based invalidation and codegen.
Both directions are feasible. Both are work.
The good news is that neither library is all-or-nothing. RTK Query endpoints are scoped per slice. TanStack Query hooks are scoped per component. You can migrate one feature at a time, leave the rest alone, and ship the partial migration without breaking anything. I've done exactly that on apps where one section of the product moved to TanStack Query while the rest stayed on RTK Query for months. The two caches coexist fine because they don't share state.
The reshaping work is mostly in cache invalidation. RTK Query's tag system maps onto TanStack Query query keys, but not cleanly. A tag like { type: 'Users', id: 'LIST' } becomes a query key like ['users', 'list']. The relationship that was declarative on the endpoint becomes imperative on the mutation. You'll touch every mutation's onSuccess to call invalidateQueries instead of relying on invalidatesTags. Plan for it.
Error shapes also change. RTK Query's FetchBaseQueryError does not have a one-to-one TanStack Query equivalent. TanStack Query takes whatever your queryFn throws, which means you'll standardise on a single error type in your fetch wrappers as part of the migration.
Optimistic updates work in both libraries, but the APIs differ. RTK Query uses onQueryStarted with updateQueryData and a patchResult.undo() rollback. TanStack Query uses onMutate, onError, and onSettled with explicit cache snapshotting via queryClient.setQueryData. Neither is harder than the other. They're different. If your app leans heavily on optimistic UI, expect to rewrite each one during the migration.
DevTools and the integration story
Both libraries have dedicated DevTools, but the experience differs.
RTK Query piggybacks on Redux DevTools. Every query and mutation appears as a Redux action in the action log. If you're already debugging via Redux DevTools, your data fetching is in the same pane as your reducers, selectors, and dispatched actions. The continuity is genuinely useful when tracing why a component re-rendered.
TanStack Query ships its own panel. The <ReactQueryDevtools> component shows every query, its status (fresh, fetching, stale, inactive), and its data, and lets you trigger refetches and invalidations manually. It is focused exclusively on data fetching, with no Redux noise. For an app that doesn't use Redux at all, the dedicated panel is cleaner than wedging server-state debugging into a general-purpose state DevTools.
Which one wins depends on what you already use. If Redux DevTools is open in your browser anyway, RTK Query has the better integration story. If you don't use Redux, the standalone TanStack Query DevTools is more focused and arguably easier to learn.
TypeScript story
Both libraries do well in TypeScript, and the differences are mostly stylistic.
RTK Query's hooks are generated from the endpoint definitions. When you write builder.query<User[], void>, the generated useGetUsersQuery hook returns { data: User[] | undefined, isLoading: boolean, error: FetchBaseQueryError | undefined, ... } automatically. You never write the hook's return type yourself. The endpoint generics flow through.
TanStack Query's hooks infer their types from the queryFn return type. If queryFn returns Promise<User[]>, then useQuery({ queryKey: ['users'], queryFn }) gives you data: User[] | undefined. You can also pass explicit generics, like useQuery<User[], Error>(...), when inference isn't enough. Either way the type story is clean.
The one practical difference is that RTK Query's skipToken is type-safe in a way that's hard to replicate in TanStack Query. Passing skipToken to a hook tells the type system "this query is disabled," and TypeScript enforces the condition at the call site. TanStack Query achieves the same outcome through the enabled option, but the type system doesn't enforce it the same way. You still pass the argument, and the library skips the fetch at runtime.
Performance and bundle size
Bundle size, measured at the time of writing on Bundlephobia, is a wash:
@reduxjs/toolkitv2.12.0: 37 KB minified, 13.6 KB gzipped (source)@tanstack/react-queryv5.100.11: 46.2 KB minified, 13.6 KB gzipped (source)
The numbers look identical for a reason. Both libraries are mature, tree-shakeable, and have been through several rounds of size optimisation. What matters is the total footprint. If you adopt RTK Query, you also pull in the Redux store and middleware, which add to the bundle. If you adopt TanStack Query, you only pay for the query library and a QueryClientProvider at the root.
For apps that already use Redux, the marginal cost of adding RTK Query is close to nothing. For apps that don't, TanStack Query is the lighter total footprint. The decision shouldn't hinge on bundle size. Both are well within acceptable budgets for any production app.
Runtime performance is similarly close. Both libraries deduplicate concurrent requests for the same key. Both batch re-renders. Both let you opt into structural sharing so unchanged data doesn't trigger downstream renders. There is no real-world performance gap that would tip the decision either way.
Ecosystem and tooling
This is where the libraries diverge most visibly outside of the API design itself.
Codegen. RTK Query ships official OpenAPI and GraphQL code generators. Point the generator at a spec, and you get a fully-typed slice with every endpoint. For teams with a backend that publishes an OpenAPI document, that is a meaningful productivity multiplier. TanStack Query has no official codegen, though community plugins exist (orval, openapi-codegen) and produce hooks-shaped output. The difference is "ships with the library" versus "you wire it up yourself".
Server state libraries. TanStack Query has a wide set of adjacent libraries: @tanstack/react-router, @tanstack/react-table, @tanstack/react-virtual, and so on. They aren't required to use TanStack Query, but they share design philosophy and integrate cleanly. RTK Query lives inside Redux Toolkit, so the ecosystem is the Redux ecosystem: reselect, redux-saga, redux-thunk, and the various middleware libraries.
Persistence. TanStack Query has well-supported persistence plugins (persistQueryClient) for storing the cache in localStorage, IndexedDB, or a custom storage adapter. Useful for offline-first apps. RTK Query does not ship persistence by default. You'd use redux-persist with the slice, which works but takes more wiring.
Suspense and concurrent rendering. Both libraries support React's Suspense and useTransition hooks. TanStack Query's support is more mature and better documented at this point. RTK Query has caught up in recent versions, but the TanStack ecosystem has been on this longer.
Adoption signals
A practical signal that's hard to fake is weekly download counts and active development.
- Redux Toolkit sits at around 20 million weekly downloads on npm, with the latest release being 2.12.0 (npm).
- TanStack Query's React adapter alone sees roughly 56 million weekly downloads on npm (npm).
- GitHub stars: TanStack/query is around 49.5k, redux-toolkit is around 11.2k as of May 2026 (GitHub API).
TanStack Query has additional adapters (Vue, Svelte, Solid, Angular) with their own download counts on top of this React figure, so the cross-framework total is higher still. The gap reflects TanStack Query reaching further than the Redux ecosystem. Both are healthy projects with active maintenance.
So which one?
If you take nothing else from this article, take this. The choice between RTK Query and TanStack Query is rarely about the libraries themselves. It's about what's already in your stack and what your team is comfortable maintaining.
I would recommend RTK Query for any project that already uses Redux Toolkit. The integration is genuinely free (same store, same middleware, same DevTools), and the tag-based invalidation is a pleasant pattern once you've internalised it. I would recommend TanStack Query for any project that doesn't have Redux, especially if there's any chance the data layer will need to be shared with a Vue, Svelte, or Angular surface down the line. The framework-agnostic story alone justifies the choice.
What I would not recommend is mixing both in the same React application. You'll end up with two caches, two invalidation models, two sets of DevTools, and two ways for a junior developer on the team to fetch users. Pick one per app and stay consistent.
If you want to see TanStack Query used outside of HTTP requests (Browser APIs, IndexedDB, Web Workers), that is covered in TanStack Query is not just for API requests.


