Skip to main content
← All Articles · · 12 min read

TanStack Query vs SWR vs RTK Query: React Data Fetching 2026

A deep comparison of TanStack Query, SWR, and RTK Query for React server state management in 2026 — caching, background refetch, optimistic updates, bundle size, and TypeScript support.

reactdata-fetchingtanstack-queryswrrtk-querytypescriptstate-management

Server state is fundamentally different from UI state. It’s async, it has a lifetime, it can go stale, and multiple components often need the same data. Managing it manually with useEffect + useState quickly turns into a mess of race conditions, stale closures, and loading spinners that never go away.

That’s exactly the problem TanStack Query, SWR, and RTK Query were built to solve. Each library takes a different approach. This guide breaks down how they compare so you can pick the right one for your codebase — not just copy a Stack Overflow answer.


The Core Problem They Solve

Before comparing, let’s be clear about what “server state” means in practice:

  • Data that lives on a server, not in your app
  • Multiple components needing the same data simultaneously
  • Data that can become stale and needs refetching
  • Loading, error, and success states that need handling
  • Caching to avoid redundant network requests
  • Background updates to keep data fresh without freezing the UI

All three libraries solve this. Where they differ is in API design philosophy, bundle size, built-in features, and ecosystem integration.


TanStack Query (formerly React Query)

TanStack Query v5 (released late 2023) is the gold standard for server state management. It’s framework-agnostic — works with React, Vue, Solid, Svelte, and Angular — but its React integration is the most mature.

Basic Setup

import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <UserProfile />
    </QueryClientProvider>
  );
}

function UserProfile() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', 1],
    queryFn: () => fetch('/api/user/1').then(res => res.json()),
  });

  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;

  return <div>{data.name}</div>;
}

Caching Strategy

TanStack Query uses a key-based cache. Any query with the same queryKey shares cache state. You control freshness with staleTime (how long data is considered fresh) and gcTime (previously cacheTime — how long unused cache entries stay in memory).

useQuery({
  queryKey: ['posts', { page, filter }],
  queryFn: () => fetchPosts(page, filter),
  staleTime: 5 * 60 * 1000,   // 5 minutes — won't refetch unless stale
  gcTime: 10 * 60 * 1000,     // 10 minutes — keep in cache even when unused
});

Background Refetch

By default, TanStack Query refetches on:

  • Window focus (user tabs back)
  • Network reconnect
  • Component mount (if data is stale)

Each of these is configurable per-query or globally on the QueryClient.

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      refetchOnWindowFocus: false,
      refetchOnMount: true,
      retry: 2,
    },
  },
});

Mutations and Optimistic Updates

Mutations are first-class in TanStack Query. The useMutation hook handles optimistic updates cleanly with onMutate and onError rollback:

const queryClient = useQueryClient();

const mutation = useMutation({
  mutationFn: (newPost: Post) =>
    fetch('/api/posts', { method: 'POST', body: JSON.stringify(newPost) }),
  onMutate: async (newPost) => {
    // Cancel any outgoing refetches
    await queryClient.cancelQueries({ queryKey: ['posts'] });

    // Snapshot the previous value
    const previousPosts = queryClient.getQueryData(['posts']);

    // Optimistically update to the new value
    queryClient.setQueryData(['posts'], (old: Post[]) => [...old, newPost]);

    return { previousPosts };
  },
  onError: (err, newPost, context) => {
    // Roll back on error
    queryClient.setQueryData(['posts'], context?.previousPosts);
  },
  onSettled: () => {
    // Always refetch after error or success
    queryClient.invalidateQueries({ queryKey: ['posts'] });
  },
});

Infinite Queries

TanStack Query has first-class support for infinite scroll and pagination:

const {
  data,
  fetchNextPage,
  hasNextPage,
  isFetchingNextPage,
} = useInfiniteQuery({
  queryKey: ['posts'],
  queryFn: ({ pageParam }) => fetchPostsPage(pageParam),
  initialPageParam: 0,
  getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
});

SWR (Stale-While-Revalidate)

SWR is Vercel’s take on server state. It’s named after the HTTP stale-while-revalidate cache directive — return cached data immediately, then refetch in the background. It’s lighter than TanStack Query and has a simpler mental model, but fewer built-in features.

Basic Setup

import useSWR from 'swr';

const fetcher = (url: string) => fetch(url).then(res => res.json());

function UserProfile() {
  const { data, error, isLoading } = useSWR('/api/user/1', fetcher);

  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage />;

  return <div>{data.name}</div>;
}

SWR uses the URL (or any serializable key) directly as the cache key. No QueryClient wrapper required — global config is optional.

Caching Strategy

SWR’s cache is simpler. The key concept is dedupingInterval — how long to suppress duplicate requests for the same key from multiple components:

const { data } = useSWR('/api/user/1', fetcher, {
  dedupingInterval: 2000,         // dedupe requests within 2 seconds
  revalidateOnFocus: true,        // refetch when window regains focus
  revalidateOnReconnect: true,    // refetch on network reconnect
  refreshInterval: 30000,         // poll every 30 seconds
});

Mutations with useSWRMutation

SWR v2 added useSWRMutation for explicit mutations:

import useSWRMutation from 'swr/mutation';

async function updateUser(url: string, { arg }: { arg: Partial<User> }) {
  return fetch(url, {
    method: 'PATCH',
    body: JSON.stringify(arg),
  }).then(res => res.json());
}

function EditProfile() {
  const { trigger, isMutating } = useSWRMutation('/api/user/1', updateUser);

  return (
    <button
      onClick={() => trigger({ name: 'New Name' })}
      disabled={isMutating}
    >
      Update
    </button>
  );
}

Optimistic updates use mutate with an optimistic data function:

const { mutate } = useSWR('/api/posts', fetcher);

// Optimistic update
await mutate(
  fetch('/api/posts', { method: 'POST', body: JSON.stringify(newPost) }),
  {
    optimisticData: (currentData) => [...currentData, newPost],
    rollbackOnError: true,
    revalidate: true,
  }
);

Infinite with useSWRInfinite

import useSWRInfinite from 'swr/infinite';

const getKey = (pageIndex: number, previousPageData: Post[]) => {
  if (previousPageData && !previousPageData.length) return null; // reached end
  return `/api/posts?page=${pageIndex}`;
};

const { data, size, setSize } = useSWRInfinite(getKey, fetcher);

RTK Query (Redux Toolkit Query)

RTK Query is Redux Toolkit’s built-in data fetching solution. If you’re already using Redux in your app, it integrates seamlessly. If you’re not using Redux, it’s a heavier choice for data fetching alone.

Basic Setup

RTK Query is defined via createApi:

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['User', 'Post'],
  endpoints: (builder) => ({
    getUser: builder.query<User, number>({
      query: (id) => `/users/${id}`,
      providesTags: ['User'],
    }),
    getPosts: builder.query<Post[], void>({
      query: () => '/posts',
      providesTags: ['Post'],
    }),
    createPost: builder.mutation<Post, Partial<Post>>({
      query: (body) => ({
        url: '/posts',
        method: 'POST',
        body,
      }),
      invalidatesTags: ['Post'],
    }),
  }),
});

export const { useGetUserQuery, useGetPostsQuery, useCreatePostMutation } = api;

You wire it into your Redux store:

import { configureStore } from '@reduxjs/toolkit';
import { api } from './api';

export const store = configureStore({
  reducer: {
    [api.reducerPath]: api.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(api.middleware),
});

Caching and Cache Invalidation

RTK Query uses tag-based cache invalidation. Mutations that invalidatesTags trigger automatic refetch of queries that providesTags with matching tags:

// This query provides 'Post' tags
const { data: posts } = useGetPostsQuery();

// This mutation invalidates 'Post' — triggers re-fetch above
const [createPost] = useCreatePostMutation();

Tag invalidation is explicit and predictable — but also verbose. You must think about cache relationships up front when defining your API slice.

Optimistic Updates

RTK Query supports optimistic updates via onQueryStarted:

createPost: builder.mutation<Post, Partial<Post>>({
  query: (body) => ({ url: '/posts', method: 'POST', body }),
  async onQueryStarted(newPost, { dispatch, queryFulfilled }) {
    const patchResult = dispatch(
      api.util.updateQueryData('getPosts', undefined, (draft) => {
        draft.push({ id: Date.now(), ...newPost } as Post);
      })
    );
    try {
      await queryFulfilled;
    } catch {
      patchResult.undo(); // Roll back on error
    }
  },
}),

TypeScript Support

RTK Query’s type inference is outstanding. Because endpoints are defined once in createApi, the auto-generated hooks (useGetUserQuery, useCreatePostMutation) are fully typed — input parameters, return types, and error types.


Side-by-Side Comparison

FeatureTanStack Query v5SWR v2RTK Query
Bundle size~13 KB (gzip)~4 KB (gzip)~9 KB (gzip, part of RTK)
Framework supportReact, Vue, Solid, Svelte, AngularReact onlyReact only
Caching modelKey-based, configurable TTLURL-based, stale-while-revalidateTag-based invalidation
Background refetchWindow focus, reconnect, pollingWindow focus, reconnect, pollingOn mutation invalidation
Optimistic updatesonMutate + onError rollbackoptimisticData optiononQueryStarted + undo
Infinite queriesuseInfiniteQuery (built-in)useSWRInfinite (built-in)Manual implementation needed
DevtoolsReact Query Devtools (excellent)None officialRedux DevTools (excellent)
TypeScriptVery goodGoodExcellent (generated types)
SSR supportdehydrate/hydratefallback propgetRunningQueriesThunk
Learning curveMediumLowHigh (requires Redux)
Best forMost React appsLightweight/Next.jsApps already using Redux

Bundle Size in Practice

Bundle size comparisons can be misleading. SWR is the smallest, but:

  • If you need useSWRInfinite and useSWRMutation, you’re adding more
  • TanStack Query’s ~13 KB includes devtools in dev mode; production is smaller
  • RTK Query’s footprint depends on how much of Redux Toolkit you’re already using — if you have Redux, you’re not adding 9 KB, you’re adding almost nothing

For a Next.js project without Redux, SWR is the lightest path. For a feature-rich app where you’ll need infinite queries, complex cache coordination, and devtools, TanStack Query justifies its size.


TypeScript Deep Dive

All three libraries have strong TypeScript support, but with different approaches.

TanStack Query infers types from your queryFn return type:

// Type of `data` is inferred as `User | undefined`
const { data } = useQuery({
  queryKey: ['user', id],
  queryFn: (): Promise<User> => fetchUser(id),
});

SWR uses a generic type parameter:

const { data } = useSWR<User>('/api/user/1', fetcher);
// data: User | undefined

RTK Query generates fully typed hooks from your endpoint definitions — the strongest type-safety of the three, at the cost of upfront verbosity.


Real-World Use Cases

Use TanStack Query when:

  • You’re starting a new React app without existing Redux
  • You need rich devtools to debug caching behavior
  • You have complex cache dependencies or need fine-grained staleTime control
  • You might migrate to another framework (Vue, Solid) in the future
  • You need solid infinite query support

Use SWR when:

  • You’re building a Next.js app and want the simplest possible setup
  • Your data fetching needs are straightforward (no complex mutations)
  • Bundle size is a priority
  • You want the stale-while-revalidate pattern with minimal configuration

Use RTK Query when:

  • You already use Redux Toolkit in your app
  • Your team is familiar with Redux patterns
  • You want all state (local + server) in one store for debugging
  • Tag-based cache invalidation maps well to your backend’s data model

Server-Side Rendering

TanStack Query SSR (Next.js App Router)

// app/users/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';

export default async function UsersPage() {
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <UsersList />
    </HydrationBoundary>
  );
}

SWR SSR

// Prefetch in getServerSideProps (Pages Router)
export async function getServerSideProps() {
  const users = await fetchUsers();
  return {
    props: {
      fallback: {
        '/api/users': users,
      },
    },
  };
}

function App({ fallback }) {
  return (
    <SWRConfig value={{ fallback }}>
      <UsersList />
    </SWRConfig>
  );
}

RTK Query SSR

RTK Query SSR is more involved and typically uses makeStore + getRunningQueriesThunk to await all queries before serializing state.


Migration: From useEffect to Any of These

If you’re coming from raw useEffect data fetching, here’s what changes:

Before (common useEffect pattern):

function UserProfile({ id }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    fetchUser(id)
      .then(data => { if (!cancelled) setUser(data); })
      .catch(err => { if (!cancelled) setError(err); })
      .finally(() => { if (!cancelled) setLoading(false); });
    return () => { cancelled = true; };
  }, [id]);

  // ...
}

After (TanStack Query):

function UserProfile({ id }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', id],
    queryFn: () => fetchUser(id),
  });
  // ...
}

You eliminate the race condition, the cancellation logic, the manual loading/error state, and the cache duplication — all in a few lines.


Which One in 2026?

TanStack Query is the clear default for new projects. The v5 API cleaned up several rough edges from v4, and the ecosystem (devtools, server components support) is the most mature.

SWR is still a solid choice for projects where simplicity and bundle size are the top concerns. Vercel maintains it actively and Next.js integration is first-class.

RTK Query is the right choice only when you’re already invested in Redux. Don’t add Redux to your app just to use RTK Query — TanStack Query does the job without the ceremony.

If you’re starting fresh: reach for TanStack Query. If you’re in a Next.js project and want the smallest footprint: SWR. If you’re in a Redux shop: RTK Query saves you from writing duplicate state logic.


Quick Start Template

# TanStack Query
npm install @tanstack/react-query @tanstack/react-query-devtools

# SWR
npm install swr

# RTK Query (comes with Redux Toolkit)
npm install @reduxjs/toolkit react-redux

Try the live examples in the React hooks playground or compare caching behavior in the performance profiler.

Free Download

50 CLI Commands That Save Hours Every Week

The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.

  • Git mastery (10 cmds)
  • File & search wizardry
  • Docker & DevOps tricks
  • Process & network tools
  • Productivity boosters
  • macOS & Linux install guide

No spam. 1 email/week max. Unsubscribe anytime.

Found this guide useful? Check out our free developer tools.