perkun.eu Services Portfolio Blog About Contact PL
← Blog

9/22/2025

React Query vs SWR — server state management in 2025

TL;DR: React Query (TanStack Query) = full toolkit (mutations, devtools, optimistic updates). SWR = simpler, smaller bundle. For most projects React Query wins on functionality.

If you’re still writing useState + useEffect for fetching data from an API, you’re spending time solving problems that have already been solved. Server state management is a separate discipline from local UI state — data you fetch from the backend has different requirements than form state.

The problem they solve

Classic implementation:

const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
  fetch('/api/users')
    .then(r => r.json())
    .then(setData)
    .catch(setError)
    .finally(() => setLoading(false));
}, []);

Problems with this code: no cache (every component mount = a new request), no deduplication (10 components on the page = 10 identical requests), race condition when a component unmounts between request and response (setState on unmounted component), no automatic refresh when returning to a tab, no retry on network error.

React Query and SWR solve all of these problems out of the box.

SWR — 5-minute quickstart

SWR (stale-while-revalidate) from Vercel:

import useSWR from 'swr';

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

function UserProfile({ userId }) {
  const { data, error, isLoading } = useSWR(
    `/api/users/${userId}`,
    fetcher
  );

  if (isLoading) return <Spinner />;
  if (error) return <Error message={error.message} />;
  
  return <div>{data.name}</div>;
}

SWR automatically: deduplicates requests with the same key, revalidates on window focus (returned to tab = data refreshed), handles race conditions, retries on network errors (exponential backoff). The entire SWR bundle is ~4kb gzipped.

React Query — more power

React Query (TanStack Query) has a more feature-rich API:

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

function TaskList() {
  const queryClient = useQueryClient();
  
  const { data: tasks } = useQuery({
    queryKey: ['tasks'],
    queryFn: () => fetch('/api/tasks').then(r => r.json()),
    staleTime: 1000 * 60 * 5, // data is "fresh" for 5 minutes
  });
  
  const addTask = useMutation({
    mutationFn: (newTask) => fetch('/api/tasks', {
      method: 'POST',
      body: JSON.stringify(newTask),
    }),
    onSuccess: () => {
      // After adding a task — refresh the list
      queryClient.invalidateQueries({ queryKey: ['tasks'] });
    },
  });
  
  return (
    <>
      {tasks?.map(t => <Task key={t.id} task={t} />)}
      <button onClick={() => addTask.mutate({ title: 'New task' })}>
        {addTask.isPending ? 'Adding...' : 'Add task'}
      </button>
    </>
  );
}

invalidateQueries after a mutation is a pattern that in SWR you’d need to implement manually via mutate(). In React Query it’s built-in and works for all components subscribing to that query key.

Devtools

React Query DevTools is an argument that can by itself determine the choice in a team project:

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

// In root component
<QueryClientProvider client={queryClient}>
  <App />
  <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>

In the devtools panel you see in real time: all active queries with their state (fresh/stale/fetching/error), time of last fetch, observer count (how many components are subscribed), cache contents, request timing. When debugging “why isn’t the data refreshing” this is a killer feature.

SWR has no official devtools.

When SWR

SWR is the right choice when: the project is small and simple (mainly fetching, few mutations), you’re using Next.js and want natural integration with the Vercel ecosystem, bundle size is critical (SWR 4kb vs React Query ~13kb gzipped), the team prefers a minimal API without ceremony.

When React Query

React Query wins when: you have complex mutations with optimistic updates (update UI immediately, roll back if server returns error), dependent queries (useQuery({ enabled: !!userId }) — second query only runs when you have userId from the first), you need fine-grained control over staleness and background refetching, a large team that will benefit from devtools during debugging.

Summary

For new React projects — start with React Query. The rich API and DevTools will pay for themselves the first time you encounter a complex mutation scenario or dependent queries. Leave SWR for Next.js projects where simplicity and a small bundle are the priority, or when the only use case is simple read-only data fetching.