DEV Community

Atlas Whoff
Atlas Whoff

Posted on

React Query vs SWR: Data Fetching, Caching, and Mutation Patterns for Production Apps

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>
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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 />
}
Enter fullscreen mode Exit fullscreen mode

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 />
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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'] })
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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)