TanStack Query vs SWR vs RTK Query: Which React Data Fetching Library Should I Choose?
Problem
When I started building React apps, I kept writing the same boilerplate code for data fetching:
const [data, setData] = useState(null)const [loading, setLoading] = useState(false)const [error, setError] = useState(null)
useEffect(() => { setLoading(true) fetch('/api/todos') .then(res => res.json()) .then(setData) .catch(setError) .finally(() => setLoading(false))}, [])This pattern caused several issues:
- Race conditions when users navigated quickly
- Duplicate requests for the same data
- No caching - every component refetched data
- Stale data when users returned to a page
- Loading and error state boilerplate everywhere
I knew there had to be a better way. After researching, I found three popular solutions: TanStack Query, SWR, and RTK Query. But which one should I pick?
What These Libraries Do
All three libraries solve the same core problem: managing async state in React. They handle:
- Caching responses automatically
- Deduplicating identical requests
- Background refetching (on window focus, reconnect, interval)
- Loading and error states
- Race condition prevention
The differences are in their APIs, features, and ecosystem integration.
TanStack Query (formerly React Query)
TanStack Query is the most popular choice according to Reddit discussions (91+ upvotes in a recent thread). It started as React Query but now supports Vue, Svelte, and Solid too.
What I Like
Automatic caching with query keys:
import { useQuery } from '@tanstack/react-query'
function useTodos() { return useQuery({ queryKey: ['todos'], queryFn: () => fetch('/api/todos').then(res => res.json()) })}Any component calling useTodos() shares the same cache. No duplicate requests.
Optimistic updates built-in:
const mutation = useMutation({ mutationFn: (todo) => fetch(`/api/todos/${todo.id}`, { method: 'PATCH', body: JSON.stringify({ completed: todo.completed }) }), onMutate: async (newTodo) => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ['todos'] }) // Snapshot previous value const previousTodos = queryClient.getQueryData(['todos']) // Optimistically update queryClient.setQueryData(['todos'], (old) => old?.map((t) => t.id === newTodo.id ? newTodo : t) ) return { previousTodos } }, onError: (err, newTodo, context) => { // Rollback on error queryClient.setQueryData(['todos'], context.previousTodos) }, onSettled: () => { // Refetch after mutation queryClient.invalidateQueries({ queryKey: ['todos'] }) }})The UI updates immediately, then rolls back if the server fails.
DevTools for debugging:
The TanStack Query DevTools show all queries, their status, and cache data. Essential for debugging.
What’s Challenging
- More concepts to learn (query keys, query client, invalidation strategies)
- Bundle size is larger (~13KB minified + gzipped)
- Overkill for simple apps
Best For
- Complex apps with heavy data fetching
- Teams wanting maximum control over caching
- Projects needing optimistic updates
- Multi-framework teams (share knowledge across React, Vue, etc.)
SWR
SWR (stale-while-revalidate) is Vercel’s lightweight solution. It focuses on simplicity.
What I Like
Minimal API:
import useSWR from 'swr'
const fetcher = (url) => fetch(url).then(res => res.json())
function useTodos() { const { data, error, isLoading } = useSWR('/api/todos', fetcher) return { data, error, isLoading }}Three lines. Done.
Small bundle size:
SWR is about 4KB minified + gzipped. That’s one-third of TanStack Query.
Keep previous data for smooth transitions:
function Search() { const [query, setQuery] = useState('')
const { data, isLoading } = useSWR(`/api/search?q=${query}`, fetcher, { keepPreviousData: true })
return ( <div> <input value={query} onChange={(e) => setQuery(e.target.value)} /> <div className={isLoading ? 'loading' : ''}> {data?.results.map(item => <Result key={item.id} {...item} />)} </div> </div> )}When query changes, the old results stay visible until new data arrives.
What’s Challenging
- Less control over caching (automatic is great until you need manual control)
- Optimistic updates require more manual work
- React only (no Vue, Svelte versions)
Best For
- Simpler applications
- Teams wanting minimal setup
- Projects where bundle size matters
- Vercel deployments (plays nicely with Next.js)
RTK Query
RTK Query is built into Redux Toolkit. If you’re already using Redux, it’s a natural choice.
What I Like
Seamless Redux integration:
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const todoApi = createApi({ reducerPath: 'todoApi', baseQuery: fetchBaseQuery({ baseUrl: '/api/' }), tagTypes: ['Todo'], endpoints: (build) => ({ getTodos: build.query({ query: () => 'todos', providesTags: ['Todo'] }), updateTodo: build.mutation({ query: ({ id, ...patch }) => ({ url: `todos/${id}`, method: 'PATCH', body: patch }), invalidatesTags: ['Todo'] }) })})
export const { useGetTodosQuery, useUpdateTodoMutation } = todoApiTag-based cache invalidation:
When updateTodo runs, it automatically invalidates the Todo tag. All queries providing that tag refetch. No manual cache management.
Redux DevTools integration:
All API calls appear in Redux DevTools with full action history.
What’s Challenging
- Requires Redux Toolkit setup
- More boilerplate for simple cases
- Tag system takes time to understand
- Bundle size includes Redux (~9KB for RTK Query + Redux overhead)
Best For
- Projects already using Redux Toolkit
- Large enterprise applications
- Teams wanting Redux DevTools integration
- Apps with complex client state beyond server data
Comparison Table
| Feature | TanStack Query | SWR | RTK Query |
|---|---|---|---|
| Bundle Size | ~13KB | ~4KB | ~9KB (+ Redux) |
| Learning Curve | Medium | Low | Medium-High |
| Cache Control | Granular (query keys) | Automatic | Tag-based |
| Optimistic Updates | Built-in | Manual | Manual |
| Redux Integration | No | No | Native |
| DevTools | Excellent (dedicated) | Basic | Redux DevTools |
| TypeScript | Excellent | Good | Excellent |
| Framework Support | React, Vue, Svelte, Solid | React only | React only |
| GraphQL Support | Yes | Yes | Yes |
How I Chose
Scenario 1: New Project, No Redux
I started with SWR because:
- Smallest bundle
- Simplest API
- Quick to set up
When my app grew complex (optimistic updates, fine-grained cache control), I migrated to TanStack Query. The migration was straightforward - similar concepts, different syntax.
Scenario 2: Existing Redux App
I used RTK Query because:
- Already had Redux Toolkit set up
- Wanted unified state management
- Tag-based invalidation fit my mental model
Scenario 3: Complex App, Heavy Data Fetching
I chose TanStack Query because:
- Best optimistic update support
- Most granular cache control
- Best DevTools for debugging
- Community support (most StackOverflow answers)
Common Mistakes I Made
Over-invalidating in TanStack Query
// BAD: Refetches ALL todos after any changequeryClient.invalidateQueries({ queryKey: ['todos'] })// GOOD: Update cache directly, no refetchqueryClient.setQueryData(['todos'], (old) => old.map(t => t.id === updatedId ? updatedTodo : t))Not Understanding SWR Revalidation
SWR refetches on:
- Window focus (user switches tabs back)
- Network reconnection
- Interval (if configured)
- Manual trigger
This surprised me when I saw extra network requests. Now I configure revalidateOnFocus: false for data that rarely changes.
Complex Tag Systems in RTK Query
I initially created too many tag types. Now I use a simple pattern:
tagTypes: ['Todo', 'User', 'Project']One tag per entity type. Simple and sufficient.
Decision Framework
Already using Redux? │ ┌──────┴──────┐ Yes No │ │ ▼ ▼ RTK Query Simple app? │ ┌──────┴──────┐ Yes No │ │ ▼ ▼ SWR TanStack QueryChoose TanStack Query if:
- Complex caching requirements
- Need built-in optimistic updates
- Considering multiple frameworks
- Want maximum community support
Choose SWR if:
- Simpler application needs
- Prefer minimal API
- Smaller bundle size is critical
- Deploying to Vercel
Choose RTK Query if:
- Already using Redux Toolkit
- Need global state + server state together
- Building enterprise application
- Want tag-based invalidation
Migration Tips
From useEffect to Any Library
- Identify all
useEffect+fetchpatterns - Replace with single hook call
- Remove manual loading/error state
- Delete duplicate request handling code
Result: ~20 lines of boilerplate becomes ~3 lines.
From SWR to TanStack Query
The APIs are similar:
| SWR | TanStack Query |
|---|---|
useSWR(key, fetcher) | useQuery({ queryKey: [key], queryFn: fetcher }) |
useSWRMutation | useMutation |
mutate(key) | queryClient.invalidateQueries({ queryKey: [key] }) |
From Manual Redux to RTK Query
- Keep existing Redux for client state
- Add RTK Query alongside
- Gradually migrate async thunks to RTK Query endpoints
- Remove manual loading/error state from slices
Summary
In this post, I compared TanStack Query, SWR, and RTK Query for React data fetching. The key point is: there’s no “best” library - only the right one for your situation.
For most new projects, I start with TanStack Query. It has the best balance of features, documentation, and community support. If bundle size is critical or the app is simple, SWR is excellent. If Redux is already in the stack, RTK Query integrates seamlessly.
All three libraries solve the same fundamental problem: making async data handling in React actually pleasant. Any of them is better than manual useEffect fetching.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 TanStack Query Official Documentation
- 👨💻 SWR Official Documentation
- 👨💻 RTK Query Official Documentation
- 👨💻 React Official Docs - Fetching Data
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments