Our Pick TanStack Query — TanStack Query's richer feature set — mutations with optimistic updates, offline support, comprehensive DevTools, and powerful cache management — makes it the right default for most apps. SWR wins for simple read-heavy apps that value smaller bundle size.
TanStack Query vs SWR

import ComparisonTable from ’../../components/ComparisonTable.astro’;

Both TanStack Query and SWR solve the same core problem: managing server state in React without manual loading/error/cache boilerplate. They share a similar mental model but differ significantly in power and complexity.

Quick Verdict

Choose TanStack Query if: You need mutations with optimistic updates, complex cache invalidation, offline support, or rich DevTools.

Choose SWR if: You’re building a simple read-heavy app, want minimal bundle size, or value an extremely simple API.


Feature Comparison

<ComparisonTable headers={[“Feature”, “TanStack Query”, “SWR”]} rows={[ [“Bundle size”, “~13KB gzipped”, “~4KB gzipped”], [“Mutations”, “Full (useMutation)”, “Manual (fetch + mutate)”], [“Optimistic updates”, “Built-in”, “Manual”], [“DevTools”, “Dedicated DevTools UI”, “No official DevTools”], [“Offline support”, “Built-in”, “Limited”], [“Infinite queries”, “useInfiniteQuery”, “useSWRInfinite”], [“Suspense”, “Supported”, “Supported”], [“TypeScript”, “Excellent”, “Good”], [“Cache invalidation”, “Fine-grained control”, “Key-based revalidation”], [“Prefetching”, “queryClient.prefetchQuery”, “useSWR preload”], [“Stale time”, “Configurable per query”, “Configurable per hook”], [“Background refetch”, “Yes”, “Yes (focus + interval)”], ]} />


Basic Data Fetching

Both libraries follow the same pattern — key + fetcher:

TanStack Query:

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

interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

// Basic query
function UserProfile({ userId }: { userId: number }) {
  const { data, isPending, isError, error } = useQuery({
    queryKey: ['users', userId],
    queryFn: async () => {
      const res = await fetch(`/api/users/${userId}`);
      if (!res.ok) throw new Error('Failed to fetch user');
      return res.json() as Promise<User>;
    },
    staleTime: 5 * 60 * 1000, // 5 minutes
  });

  if (isPending) return <UserSkeleton />;
  if (isError) return <ErrorMessage error={error} />;

  return <UserCard user={data} />;
}

SWR:

import useSWR from 'swr';

const fetcher = async (url: string) => {
  const res = await fetch(url);
  if (!res.ok) throw new Error('Failed to fetch');
  return res.json();
};

function UserProfile({ userId }: { userId: number }) {
  const { data, error, isLoading } = useSWR<User>(
    `/api/users/${userId}`,
    fetcher,
    { dedupingInterval: 5 * 60 * 1000 }
  );

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

  return <UserCard user={data!} />;
}

Both look similar for basic fetching. The differences emerge with mutations and cache management.


Mutations

This is where TanStack Query significantly outpaces SWR:

TanStack Query — useMutation with optimistic updates:

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

interface UpdateUserInput {
  name: string;
  email: string;
}

function EditUserForm({ userId }: { userId: number }) {
  const queryClient = useQueryClient();

  const updateUser = useMutation({
    mutationFn: async (input: UpdateUserInput) => {
      const res = await fetch(`/api/users/${userId}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(input),
      });
      return res.json() as Promise<User>;
    },
    
    // Optimistic update
    onMutate: async (newData) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['users', userId] });
      
      // Snapshot current value
      const previousUser = queryClient.getQueryData<User>(['users', userId]);
      
      // Optimistically update cache
      queryClient.setQueryData<User>(['users', userId], (old) => ({
        ...old!,
        ...newData,
      }));
      
      return { previousUser };
    },
    
    // Rollback on error
    onError: (error, variables, context) => {
      queryClient.setQueryData(['users', userId], context?.previousUser);
    },
    
    // Always refetch after
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['users', userId] });
    },
  });

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    updateUser.mutate({
      name: formData.get('name') as string,
      email: formData.get('email') as string,
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" />
      <input name="email" />
      <button disabled={updateUser.isPending}>
        {updateUser.isPending ? 'Saving...' : 'Save'}
      </button>
      {updateUser.isError && <p>Error: {updateUser.error.message}</p>}
    </form>
  );
}

SWR — mutations require manual implementation:

import useSWR, { useSWRConfig } from 'swr';

function EditUserForm({ userId }: { userId: number }) {
  const { mutate } = useSWRConfig();
  const { data: user } = useSWR<User>(`/api/users/${userId}`, fetcher);
  const [isPending, setIsPending] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setIsPending(true);
    setError(null);
    
    const formData = new FormData(e.currentTarget);
    const newData = {
      name: formData.get('name') as string,
      email: formData.get('email') as string,
    };

    try {
      // Optimistic update (manual)
      mutate(
        `/api/users/${userId}`,
        async () => {
          const res = await fetch(`/api/users/${userId}`, {
            method: 'PATCH',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(newData),
          });
          return res.json();
        },
        {
          optimisticData: { ...user, ...newData },
          rollbackOnError: true,
        }
      );
    } catch (err) {
      setError(err as Error);
    } finally {
      setIsPending(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" defaultValue={user?.name} />
      <input name="email" defaultValue={user?.email} />
      <button disabled={isPending}>
        {isPending ? 'Saving...' : 'Save'}
      </button>
      {error && <p>Error: {error.message}</p>}
    </form>
  );
}

SWR requires manual state management for loading and errors — TanStack Query’s useMutation handles these automatically.


Infinite Queries

TanStack Query — useInfiniteQuery:

import { useInfiniteQuery } from '@tanstack/react-query';

interface PostsPage {
  posts: Post[];
  nextCursor: string | null;
}

function InfinitePostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: async ({ pageParam }) => {
      const url = pageParam
        ? `/api/posts?cursor=${pageParam}`
        : '/api/posts';
      const res = await fetch(url);
      return res.json() as Promise<PostsPage>;
    },
    initialPageParam: null as string | null,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  });

  const posts = data?.pages.flatMap((page) => page.posts) ?? [];

  return (
    <div>
      {posts.map((post) => <PostCard key={post.id} post={post} />)}
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? 'Loading...' : 'Load more'}
      </button>
    </div>
  );
}

SWR — useSWRInfinite:

import useSWRInfinite from 'swr/infinite';

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

function InfinitePostList() {
  const { data, size, setSize, isLoading } = useSWRInfinite<PostsPage>(
    getKey,
    fetcher
  );

  const posts = data?.flatMap((page) => page.posts) ?? [];
  const hasNextPage = data?.[data.length - 1]?.nextCursor != null;

  return (
    <div>
      {posts.map((post) => <PostCard key={post.id} post={post} />)}
      <button
        onClick={() => setSize(size + 1)}
        disabled={!hasNextPage || isLoading}
      >
        {isLoading ? 'Loading...' : 'Load more'}
      </button>
    </div>
  );
}

Cache Invalidation

TanStack Query — granular cache control:

const queryClient = useQueryClient();

// Invalidate specific query
queryClient.invalidateQueries({ queryKey: ['users', userId] });

// Invalidate all user queries
queryClient.invalidateQueries({ queryKey: ['users'] });

// Update cache directly (no refetch)
queryClient.setQueryData<User[]>(['users'], (old) =>
  old?.map(user => user.id === userId ? { ...user, name: 'New Name' } : user)
);

// Prefetch before navigation
await queryClient.prefetchQuery({
  queryKey: ['users', userId],
  queryFn: () => fetchUser(userId),
});

// Remove from cache
queryClient.removeQueries({ queryKey: ['users', userId] });

SWR — key-based revalidation:

import { mutate } from 'swr';

// Revalidate specific key
mutate('/api/users/123');

// Revalidate all matching keys (regex)
mutate((key) => typeof key === 'string' && key.startsWith('/api/users'), undefined, { revalidate: true });

// Update cache directly
mutate('/api/users/123', updatedUser, { revalidate: false });

// Global mutate
const { mutate: globalMutate } = useSWRConfig();
globalMutate('/api/users');

SWR’s key-based approach is simpler but less expressive. TanStack Query’s hierarchical invalidation (['users'] invalidates all user queries) is more powerful.


Provider Setup

TanStack Query:

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

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000, // 1 minute
      retry: 2,
      refetchOnWindowFocus: true,
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Router />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

SWR:

import { SWRConfig } from 'swr';

// Provider optional — can configure globally
function App() {
  return (
    <SWRConfig
      value={{
        fetcher: (url: string) => fetch(url).then(r => r.json()),
        revalidateOnFocus: true,
        shouldRetryOnError: true,
        errorRetryCount: 2,
        dedupingInterval: 60 * 1000,
      }}
    >
      <Router />
    </SWRConfig>
  );
}

SWR’s provider is optional — a global fetcher can be configured without wrapping the app.


When to Choose Each

Choose TanStack Query:

  • Apps with significant mutation requirements (forms, CRUD)
  • Need optimistic updates with automatic rollback
  • Want visual DevTools to debug cache state
  • Complex cache invalidation patterns (invalidating related queries after mutation)
  • Offline-capable applications
  • Large teams where consistent patterns matter

Choose SWR:

  • Read-heavy dashboards with minimal writes
  • Bundle size is a constraint (4KB vs 13KB)
  • Simple API — one hook, one data source
  • Next.js apps (SWR is built by Vercel, integrates naturally)
  • Prototype or small-to-medium app

Bottom Line

TanStack Query is the more capable library and the right choice for most production applications — the mutation handling, DevTools, and cache management alone justify the slightly larger bundle size. SWR excels in simplicity for read-heavy apps where bundle size matters and mutations are minimal. If you’re unsure, TanStack Query’s richer API means you won’t hit its limits as your app grows.