Skip to content

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:

TodoList.jsx
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:

useTodos.js
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:

useToggleTodo.js
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:

useTodos.js
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:

Search.js
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:

todoApi.js
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 } = todoApi

Tag-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

FeatureTanStack QuerySWRRTK Query
Bundle Size~13KB~4KB~9KB (+ Redux)
Learning CurveMediumLowMedium-High
Cache ControlGranular (query keys)AutomaticTag-based
Optimistic UpdatesBuilt-inManualManual
Redux IntegrationNoNoNative
DevToolsExcellent (dedicated)BasicRedux DevTools
TypeScriptExcellentGoodExcellent
Framework SupportReact, Vue, Svelte, SolidReact onlyReact only
GraphQL SupportYesYesYes

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

Wrong approach
// BAD: Refetches ALL todos after any change
queryClient.invalidateQueries({ queryKey: ['todos'] })
Better approach
// GOOD: Update cache directly, no refetch
queryClient.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.js
tagTypes: ['Todo', 'User', 'Project']

One tag per entity type. Simple and sufficient.

Decision Framework

Decision flowchart
Already using Redux?
┌──────┴──────┐
Yes No
│ │
▼ ▼
RTK Query Simple app?
┌──────┴──────┐
Yes No
│ │
▼ ▼
SWR TanStack Query

Choose 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

  1. Identify all useEffect + fetch patterns
  2. Replace with single hook call
  3. Remove manual loading/error state
  4. Delete duplicate request handling code

Result: ~20 lines of boilerplate becomes ~3 lines.

From SWR to TanStack Query

The APIs are similar:

SWRTanStack Query
useSWR(key, fetcher)useQuery({ queryKey: [key], queryFn: fetcher })
useSWRMutationuseMutation
mutate(key)queryClient.invalidateQueries({ queryKey: [key] })

From Manual Redux to RTK Query

  1. Keep existing Redux for client state
  2. Add RTK Query alongside
  3. Gradually migrate async thunks to RTK Query endpoints
  4. 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments