React Query vs SWR: Data Fetching, Caching, and Mutation Patterns
Choosing between React Query (TanStack Query) and SWR shapes how your entire data layer works.
Here's a practical comparison based on real production use.
The Core Difference
Both libraries handle server state, but their philosophies differ:
- SWR: Minimalist, built by Vercel, optimized for simplicity
- React Query: Feature-rich, built for complex applications, more explicit
Basic Data Fetching
SWR:
import useSWR from 'swr'
const fetcher = (url: string) => fetch(url).then(r => r.json())
function UserProfile({ userId }: { userId: string }) {
const { data, error, isLoading } = useSWR(`/api/users/${userId}`, fetcher)
if (isLoading) return <Skeleton />
if (error) return <Error message={error.message} />
return <div>{data.name}</div>
}
React Query:
import { useQuery } from '@tanstack/react-query'
function UserProfile({ userId }: { userId: string }) {
const { data, error, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
})
if (isLoading) return <Skeleton />
if (error) return <Error message={error.message} />
return <div>{data.name}</div>
}
Both look similar for basic fetching. The differences emerge at scale.
Mutations
SWR with optimistic updates:
import useSWR, { mutate } from 'swr'
function UpdateUserForm({ userId }: { userId: string }) {
const { data, mutate } = useSWR(`/api/users/${userId}`, fetcher)
const updateUser = async (newName: string) => {
// Optimistic update
mutate({ ...data, name: newName }, false)
try {
await fetch(`/api/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify({ name: newName }),
})
mutate() // Revalidate from server
} catch (err) {
mutate(data) // Rollback on error
}
}
return <button onClick={() => updateUser('New Name')}>Update</button>
}
React Query mutations:
import { useMutation, useQueryClient } from '@tanstack/react-query'
function UpdateUserForm({ userId }: { userId: string }) {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (newName: string) =>
fetch(`/api/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify({ name: newName }),
}).then(r => r.json()),
onMutate: async (newName) => {
await queryClient.cancelQueries({ queryKey: ['user', userId] })
const previous = queryClient.getQueryData(['user', userId])
queryClient.setQueryData(['user', userId], old => ({ ...old, name: newName }))
return { previous }
},
onError: (err, newName, context) => {
queryClient.setQueryData(['user', userId], context?.previous)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['user', userId] })
},
})
return <button onClick={() => mutation.mutate('New Name')}>Update</button>
}
React Query's mutation API is more verbose but gives you better control over the entire lifecycle.
Dependent Queries
SWR:
function UserPosts({ userId }: { userId: string }) {
const { data: user } = useSWR(`/api/users/${userId}`, fetcher)
const { data: posts } = useSWR(
user ? `/api/posts?author=${user.username}` : null,
fetcher
)
return posts ? <PostList posts={posts} /> : <Skeleton />
}
React Query:
function UserPosts({ userId }: { userId: string }) {
const userQuery = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
const postsQuery = useQuery({
queryKey: ['posts', userQuery.data?.username],
queryFn: () => fetchPosts(userQuery.data!.username),
enabled: !!userQuery.data?.username,
})
return postsQuery.data ? <PostList posts={postsQuery.data} /> : <Skeleton />
}
Infinite Queries (Pagination)
React Query has first-class support for infinite queries:
function PostFeed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 0 }) => fetchPosts({ cursor: pageParam }),
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
return (
<div>
{data?.pages.map(page =>
page.posts.map(post => <PostCard key={post.id} post={post} />)
)}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
</div>
)
}
SWR supports this too with useSWRInfinite, but React Query's API is more ergonomic.
Cache Management
React Query gives you more control:
// Prefetch on hover
const queryClient = useQueryClient()
function PostLink({ postId }: { postId: string }) {
const prefetch = () => {
queryClient.prefetchQuery({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId),
staleTime: 60_000,
})
}
return (
<Link to={`/posts/${postId}`} onMouseEnter={prefetch}>
Read more
</Link>
)
}
// Invalidate related queries after mutation
queryClient.invalidateQueries({ queryKey: ['posts'] })
queryClient.invalidateQueries({ queryKey: ['user', userId, 'stats'] })
When to Choose Each
Choose SWR when:
- You're building a Next.js app (same team, great integration)
- Your data fetching is relatively simple
- You want minimal bundle size
- You prefer convention over configuration
Choose React Query when:
- You have complex mutation flows with optimistic updates
- You need fine-grained cache control
- You're building a large app with many data dependencies
- You want the DevTools for debugging
The Setup
SWR (zero config):
// Just install and use — no provider needed
import useSWR from 'swr'
React Query (provider required):
// app/providers.tsx
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
retry: 1,
},
},
})
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
My Take
For new projects: start with SWR if you're on Next.js and your mutations are simple.
Switch to React Query when you find yourself fighting SWR's conventions.
For existing projects with complex data flows: React Query's explicit API is worth the added boilerplate.
Both are excellent. The gap has narrowed over the years. You won't go wrong with either.
Building an AI SaaS and need the full stack pre-wired? The AI SaaS Starter Kit ships with React Query configured, all API routes typed end-to-end, and Stripe billing ready to go. $99 one-time.
Top comments (0)