React teams usually outgrow raw fetch() calls for the same reason: the hard part is not making a request, it is managing cache state, refetching, loading transitions, retries, background refreshes, and keeping the UI consistent when data changes.
That is where SWR, React Query and RTK Query come in. All three libraries solve server-state problems, but they optimize for different team constraints. One is deliberately minimal, one is feature-rich, and one fits best when Redux Toolkit is already part of the app architecture.
If you choose based only on popularity, you will probably over-install. The better question is simpler: how much control do you need, how much existing state infrastructure do you already have, and how opinionated do you want the data layer to be?
The shared problem they solve
Server state is different from local UI state because it lives on the server, can become stale at any time, and needs explicit rules for caching, refetching, invalidation, retries, and background synchronization.
1 Shared remote data
The same resource often feeds several screens or components, so one-off fetch logic becomes hard to coordinate.
2 Staleness rules
Data can be fresh, stale, invalidated, or being refreshed in the background, and the UI has to respond correctly to each case.
3 Async side effects
Focus changes, reconnects, mutations, polling, and route transitions all create refetch behavior that becomes tedious to hand-roll.
SWR: the lightweight option
SWR is built around the stale-while-revalidate model: return cached data immediately, then refresh it in the background. That makes it feel fast and intentionally small.
import useSWR from 'swr';
const fetcher = (url: string) => fetch(url).then((res) => res.json());
export function UserCard() {
const { data, error, isLoading } = useSWR('/api/user', fetcher);
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Request failed.</p>;
return <p>Signed in as {data.name}</p>;
}
SWR handles caching, deduplication, focus revalidation, interval polling, and optimistic update helpers without asking the team to adopt a heavy client abstraction. That makes it a strong fit for read-heavy interfaces such as dashboards, profile screens, settings pages, and internal tools.
React Query: the most configurable toolkit
React Query, now distributed as @tanstack/react-query, is usually the best choice when server-state workflows become more operationally complex. It gives you cache lifetimes, invalidation, pagination primitives, dependent queries, optimistic updates, and a richer mutation lifecycle.
import {
QueryClient,
QueryClientProvider,
useQuery,
} from '@tanstack/react-query';
const queryClient = new QueryClient();
function ProjectsList() {
const { data, isPending, error } = useQuery({
queryKey: ['projects'],
queryFn: async () => {
const response = await fetch('/api/projects');
if (!response.ok) {
throw new Error('Failed to load projects');
}
return response.json();
},
staleTime: 60_000,
});
if (isPending) return <p>Loading projects...</p>;
if (error) return <p>Unable to load projects.</p>;
return data.map((project) => <div key={project.id}>{project.name}</div>);
}
export function App() {
return (
<QueryClientProvider client={queryClient}>
<ProjectsList />
</QueryClientProvider>
);
}
If SWR feels like a clean convenience layer, React Query feels like a full server-state toolkit. That is its advantage, but it also means more API surface and more decisions for the team.
RTK Query: the Redux-native choice
RTK Query is the data fetching layer built into Redux Toolkit. Instead of attaching fetch logic to individual hooks, you define endpoints once inside an API slice and consume generated hooks throughout the app.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Post'],
endpoints: (builder) => ({
getPosts: builder.query({
query: () => '/posts',
providesTags: ['Post'],
}),
}),
});
export const { useGetPostsQuery } = api;
export function Posts() {
const { data = [], isLoading } = useGetPostsQuery();
if (isLoading) return <p>Loading posts...</p>;
return (
<ul>
{data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
RTK Query is compelling when Redux already anchors the application. It keeps data fetching, cache invalidation, and the rest of the state layer inside one shared architecture. If Redux is not already present, adding it only to fetch data is harder to justify.
Comparison at a glance
| Dimension | SWR | React Query | RTK Query |
|---|---|---|---|
| Mental model | Small hook plus cache key | Query client with rich policies | Central API slice in Redux |
| Best fit | Read-heavy apps with modest complexity | Complex async workflows and mutations | Redux Toolkit applications |
| Mutation ergonomics | Good but lighter-weight | Very strong lifecycle and invalidation support | Strong via endpoints and tag invalidation |
| Boilerplate | Low | Moderate | Moderate upfront, lower per endpoint later |
| Redux dependency | None | None | Yes |
Mutations are often the deciding factor
Most teams can make any of these libraries work for simple reads. The decision usually becomes obvious once writes, invalidation, and optimistic updates enter the picture.
import { mutate } from 'swr';
async function renameUser(name: string) {
await fetch('/api/user', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
await mutate('/api/user');
}
import { useMutation, useQueryClient } from '@tanstack/react-query';
function RenameButton() {
const queryClient = useQueryClient();
const renameMutation = useMutation({
mutationFn: async (name: string) => {
const response = await fetch('/api/user', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!response.ok) {
throw new Error('Rename failed');
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user'] });
},
});
return <button onClick={() => renameMutation.mutate('Mauro')}>Rename user</button>;
}
updatePost: builder.mutation({
query: (body) => ({
url: '/posts/' + body.id,
method: 'PATCH',
body,
}),
invalidatesTags: ['Post'],
});
SWR keeps writes lightweight, React Query gives you the richest lifecycle around them, and RTK Query makes invalidation rules scale well by centralizing them near endpoint definitions.
Choose by team context, not hype
Bottom line
If you want the shortest practical guidance, start with SWR for simple to medium complexity apps, move to React Query when the async workflow becomes operationally complex, and choose RTK Query when Redux Toolkit is already a core architectural decision.
There is no universal winner because the libraries are not optimizing for the same target. The right choice is the one that makes your app's server-state workflow feel smaller, not bigger.