Skip to main content

TanStack Query v5 vs SWR v3 vs RTK Query 2026

·PkgPulse Team

TanStack Query v5 vs SWR v3 vs RTK Query 2026

TL;DR

Server state management has matured into a distinct category from client state. TanStack Query v5 (formerly React Query) is the most feature-complete option — devtools, mutation lifecycle, optimistic updates, prefetching, and SSR support with fine-grained control. SWR v3 by Vercel is the minimalist choice — small bundle, simple API, convention-over-configuration, especially elegant with Next.js. RTK Query is the right choice if you're already using Redux Toolkit — it colocates API definitions with your Redux store and handles loading/error states via slice patterns. In 2026, TanStack Query v5 wins for most use cases outside the Redux and Vercel/SWR ecosystems.

Key Takeaways

  • TanStack Query v5: 42M weekly downloads, 43K GitHub stars, v5 introduced simplified mutation API, removed callbacks from useQuery, TypeScript-first
  • SWR v3: 30M weekly downloads, 30K GitHub stars, 4KB gzipped, first-class Next.js integration, simple mental model
  • RTK Query: ships with Redux Toolkit (15M weekly downloads), zero additional dependencies if you use Redux, endpoints-as-config pattern
  • Bundle size: SWR ~4KB < TanStack Query ~13KB < RTK Query ~varies (Redux Toolkit base ~12KB)
  • Mutation API: TanStack Query's useMutation is the most comprehensive; SWR's mutate is simpler but less ergonomic for forms
  • Framework support: TanStack Query supports React, Vue, Solid, Svelte, Angular; SWR and RTK Query are React-focused

The Server State Problem

Before React Query (now TanStack Query) popularized the concept, most React apps mixed server state and client state in a single Redux store. Server state is fundamentally different: it's owned by a remote server, can be stale, needs cache invalidation, benefits from background refresh, and can fail. Treating it like client state (user preferences, UI toggles) creates complicated loading/error handling and cache invalidation logic.

All three libraries in this comparison are server state libraries — they manage the lifecycle of data fetched from an API with caching, deduplication, and background refresh baked in.


TanStack Query v5

TanStack Query v5 (formerly React Query) released in late 2023 with a significant API overhaul focused on TypeScript ergonomics and simplification.

Key v5 Changes From v4

// v4 (deprecated patterns):
const query = useQuery(['posts', id], () => fetchPost(id), {
  onSuccess: (data) => console.log(data),  // ❌ removed in v5
  onError: (err) => console.error(err),    // ❌ removed in v5
})

// v5: unified queryKey + queryFn object syntax
const query = useQuery({
  queryKey: ['posts', id],
  queryFn: () => fetchPost(id),
})
// Side effects → use useEffect watching data/error
useEffect(() => {
  if (query.data) console.log(query.data)
}, [query.data])

The removal of onSuccess/onError callbacks from useQuery was controversial but forced cleaner side-effect patterns.

Basic Data Fetching

import { useQuery, useQueryClient } from '@tanstack/react-query'

interface Post {
  id: number
  title: string
  body: string
  userId: number
}

function PostDetail({ postId }: { postId: number }) {
  const { data, isLoading, isError, error } = useQuery<Post>({
    queryKey: ['posts', postId],
    queryFn: async () => {
      const res = await fetch(`/api/posts/${postId}`)
      if (!res.ok) throw new Error('Failed to fetch post')
      return res.json()
    },
    staleTime: 60_000,      // Consider fresh for 60 seconds
    gcTime: 5 * 60_000,     // Keep in cache for 5 minutes (gcTime replaces cacheTime in v5)
  })

  if (isLoading) return <Skeleton />
  if (isError) return <ErrorMessage error={error} />
  return <Article post={data} />
}

Mutations with Optimistic Updates

TanStack Query's mutation API is the most expressive:

const queryClient = useQueryClient()

const updatePost = useMutation({
  mutationFn: (updated: Partial<Post>) =>
    fetch(`/api/posts/${postId}`, {
      method: 'PATCH',
      body: JSON.stringify(updated),
    }).then(r => r.json()),

  // Optimistic update
  onMutate: async (updated) => {
    await queryClient.cancelQueries({ queryKey: ['posts', postId] })
    const previous = queryClient.getQueryData<Post>(['posts', postId])

    queryClient.setQueryData<Post>(['posts', postId], (old) => ({
      ...old!,
      ...updated,
    }))

    return { previous }  // rollback context
  },

  onError: (err, updated, context) => {
    // Roll back on error
    queryClient.setQueryData(['posts', postId], context?.previous)
  },

  onSettled: () => {
    // Always refetch after mutation to sync with server
    queryClient.invalidateQueries({ queryKey: ['posts', postId] })
  },
})

Prefetching and SSR

// app/posts/[id]/page.tsx (Next.js App Router)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'

export default async function PostPage({ params }: { params: { id: string } }) {
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery({
    queryKey: ['posts', params.id],
    queryFn: () => fetchPost(params.id),
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <PostDetail postId={params.id} />
    </HydrationBoundary>
  )
}

SWR v3

SWR (stale-while-revalidate) is Vercel's data fetching library — minimal, fast, and deeply integrated with Next.js. v3 refined the TypeScript experience and added middleware support.

Basic Usage

import useSWR from 'swr'

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

function Profile({ userId }: { userId: string }) {
  const { data, error, isLoading } = useSWR<User>(
    `/api/users/${userId}`,
    fetcher,
    {
      refreshInterval: 30_000,    // poll every 30s
      revalidateOnFocus: true,    // refresh when tab regains focus
      dedupingInterval: 5_000,    // deduplicate same-key requests within 5s
    }
  )

  if (isLoading) return <Spinner />
  if (error) return <Error />
  return <UserCard user={data} />
}

Mutations and Cache Updates

SWR's mutation approach is simpler but more manual:

import useSWR, { mutate } from 'swr'

function EditProfile() {
  const { data: user } = useSWR<User>('/api/user', fetcher)

  const updateName = async (newName: string) => {
    // Optimistic update — update cache immediately, revalidate after
    await mutate(
      '/api/user',
      async (current: User) => {
        await fetch('/api/user', {
          method: 'PATCH',
          body: JSON.stringify({ name: newName }),
        })
        return { ...current, name: newName }
      },
      { optimisticData: { ...user!, name: newName } }
    )
  }

  return (
    <input
      defaultValue={user?.name}
      onBlur={(e) => updateName(e.target.value)}
    />
  )
}

SWR's mutate is elegant for simple updates but becomes verbose for complex optimistic UI patterns.

Middleware

SWR v3 added a proper middleware system:

// Logger middleware
const logger = (useSWRNext: SWRHook) => (key, fetcher, config) => {
  const extendedFetcher = (...args: unknown[]) => {
    console.log('SWR request:', key)
    return fetcher!(...args)
  }
  return useSWRNext(key, extendedFetcher, config)
}

// Error retry middleware with backoff
const retry = (useSWRNext: SWRHook) => (key, fetcher, config) => {
  const retryFetcher = async (...args: unknown[]) => {
    try {
      return await fetcher!(...args)
    } catch (err) {
      // Implement exponential backoff
      await new Promise(resolve => setTimeout(resolve, 1000))
      return fetcher!(...args)
    }
  }
  return useSWRNext(key, retryFetcher, config)
}

// Apply globally
const { data } = useSWR('/api/data', fetcher, { use: [logger, retry] })

RTK Query

RTK Query ships inside Redux Toolkit and is the right choice when your app already uses Redux for client state. It colocates API endpoint definitions with your store configuration.

Setup

// store/api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export const postsApi = createApi({
  reducerPath: 'postsApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Post', 'User'],

  endpoints: (builder) => ({
    getPosts: builder.query<Post[], void>({
      query: () => '/posts',
      providesTags: ['Post'],
    }),
    getPost: builder.query<Post, number>({
      query: (id) => `/posts/${id}`,
      providesTags: (result, error, id) => [{ type: 'Post', id }],
    }),
    updatePost: builder.mutation<Post, Partial<Post> & { id: number }>({
      query: ({ id, ...patch }) => ({
        url: `/posts/${id}`,
        method: 'PATCH',
        body: patch,
      }),
      invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
    }),
  }),
})

export const { useGetPostsQuery, useGetPostQuery, useUpdatePostMutation } = postsApi

Usage in Components

function PostList() {
  const { data: posts, isLoading } = useGetPostsQuery()
  const [updatePost, { isLoading: isUpdating }] = useUpdatePostMutation()

  const handleUpdate = async (id: number, title: string) => {
    try {
      await updatePost({ id, title }).unwrap()
      // Success — RTK Query auto-invalidates 'Post' tag, re-fetches list
    } catch (error) {
      console.error('Update failed', error)
    }
  }

  if (isLoading) return <Loading />
  return (
    <ul>
      {posts?.map((post) => (
        <PostItem key={post.id} post={post} onUpdate={handleUpdate} />
      ))}
    </ul>
  )
}

The invalidatesTags pattern is RTK Query's cache invalidation mechanism — when updatePost succeeds, any query that providesTags: ['Post'] is automatically refetched.


Feature Comparison

FeatureTanStack Query v5SWR v3RTK Query
Bundle size (gzip)~13KB~4KB~12KB (with RTK)
Weekly downloads42M30Mpart of RTK (15M)
Framework supportReact, Vue, Solid, SvelteReact onlyReact only
Devtools✅ Excellent⚠️ Basic✅ Redux DevTools
Mutation API✅ Comprehensive⚠️ Simple✅ Good
Optimistic updates✅ First-class⚠️ Manual✅ Built-in
Infinite scrolluseInfiniteQueryuseSWRInfinite⚠️ Manual
Dependent queriesenabled flag✅ null key patternskip flag
Background refetch
SSR/Hydrationdehydrate/HydrationBoundarySWRConfig fallback✅ via Redux hydration
PrefetchingprefetchQuery⚠️ preload (limited)initiate
TypeScript✅ First-class✅ Good✅ First-class

When to Choose Each

Choose TanStack Query v5 if:

  • You're starting a new React (or Vue/Solid/Svelte) project
  • You need complex mutation patterns with optimistic UI
  • Devtools are important for debugging
  • You want the richest query lifecycle control

Choose SWR v3 if:

  • Bundle size is critical (mobile, edge)
  • Your app is primarily Next.js on the Vercel platform
  • Your data fetching is relatively simple (mostly reads)
  • You prefer Vercel's conventions and ecosystem

Choose RTK Query if:

  • You already use Redux Toolkit for client state
  • You want a single store for both server and client state
  • Your team knows Redux patterns well
  • The endpoints-as-config pattern aligns with your architecture

Infinite Loading and Pagination

All three libraries handle infinite scroll, but with different ergonomics:

TanStack Query — useInfiniteQuery

const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
  queryKey: ['posts'],
  queryFn: ({ pageParam = 0 }) =>
    fetch(`/api/posts?cursor=${pageParam}&limit=20`).then(r => r.json()),
  getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  initialPageParam: 0,
})

// data.pages is an array of page results
const allPosts = data?.pages.flatMap((page) => page.items) ?? []

SWR — useSWRInfinite

import useSWRInfinite from 'swr/infinite'

const getKey = (pageIndex: number, previousPageData: PageData | null) => {
  if (previousPageData && !previousPageData.nextCursor) return null  // end
  return `/api/posts?cursor=${previousPageData?.nextCursor ?? ''}&limit=20`
}

const { data, size, setSize, isLoading } = useSWRInfinite<PageData>(
  getKey,
  fetcher
)

const allPosts = data?.flatMap((page) => page.items) ?? []
const loadMore = () => setSize(size + 1)

RTK Query — Infinite Loading Pattern

RTK Query doesn't have a built-in infinite query — you typically use cursor/page state in a parent component and merge results:

// Manual approach for RTK Query infinite scroll
function InfinitePostList() {
  const [cursor, setCursor] = useState<string | undefined>()
  const [allPosts, setAllPosts] = useState<Post[]>([])

  const { data } = useGetPostsQuery({ cursor, limit: 20 })

  useEffect(() => {
    if (data?.items) setAllPosts((prev) => [...prev, ...data.items])
  }, [data])

  return (
    <>
      {allPosts.map((post) => <PostCard key={post.id} post={post} />)}
      {data?.nextCursor && (
        <button onClick={() => setCursor(data.nextCursor)}>Load More</button>
      )}
    </>
  )
}

TanStack Query's useInfiniteQuery is the most ergonomic; SWR's useSWRInfinite is close; RTK Query requires manual state management for this pattern.


Methodology

  • Download data from npm registry API, March 2026
  • TanStack Query v5 docs: tanstack.com/query/v5
  • SWR v3 docs: swr.vercel.app
  • RTK Query docs: redux-toolkit.js.org/rtk-query
  • Bundle sizes via bundlephobia.com, March 2026

Compare these packages on PkgPulse: TanStack Query vs SWR.

Related: Zustand vs Jotai vs Nanostores Micro State Management 2026 · Best React Form Libraries 2026

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.