React Query vs SWR: Which to Use in 2026

Photo by Unsplash

Photo by Unsplash
Both React Query (now TanStack Query) and SWR solve the same problem: managing remote state in React without the prop drilling and useEffect spaghetti that results from manual fetch calls. I've used both on production projects. SWR powers a simpler public-facing Next.js site; React Query runs the dashboard for an ERP internal tool with complex mutations, optimistic updates, and offline support. My take: SWR is a better default for simple apps, React Query is worth its size for complex ones.
SWR (Stale-While-Revalidate) is from Vercel and optimized for the Vercel/Next.js ecosystem. Its philosophy is minimal API surface — one useSWR hook covers most use cases. React Query from TanStack is more opinionated about server state management, ships with DevTools, and has first-class support for mutations, pagination, infinite queries, and offline mode. The bundle size difference (~4KB for SWR vs ~13.5KB for React Query) matters on content sites but not dashboards.
Both libraries deduplicate requests — if three components on the same page call useSWR('/api/user') or useQuery(['user']), only one HTTP request fires. Both revalidate on window focus and reconnect. Both support background refetching, custom fetcher functions, and global configuration. Both integrate well with Next.js App Router's server components.
SWR wins for content-heavy sites where you mainly need to fetch and display data with occasional simple mutations. Its API is smaller to learn, it integrates perfectly with the Vercel ecosystem, and for simple CRUD without complex cache invalidation needs, the ergonomics are cleaner. If your data requirements are 'show a list, show a detail, maybe do a simple update', SWR is plenty and costs you 9KB less JavaScript.
Feature Comparison: React Query vs SWR
┌─────────────────────────┬──────────────┬──────────────┐
│ Feature │ React Query │ SWR │
├─────────────────────────┼──────────────┼──────────────┤
│ Bundle size (min+gzip) │ ~13.5 kB │ ~4.2 kB │
│ Mutations │ ✅ Built-in │ ⚠️ Manual │
│ Optimistic updates │ ✅ Easy │ ✅ Possible │
│ Infinite scroll │ ✅ Built-in │ ✅ Built-in │
│ Offline support │ ✅ Yes │ ⚠️ Partial │
│ DevTools │ ✅ Official │ ❌ None │
│ Server State (SSR) │ ✅ Full │ ✅ Full │
│ Background refetch │ ✅ Yes │ ✅ Yes │
│ Request deduplication │ ✅ Yes │ ✅ Yes │
│ Prefetching │ ✅ Yes │ ⚠️ Manual │
│ Learning curve │ Medium │ Low │
│ Best for │ Complex apps │ Simple fetch │
└─────────────────────────┴──────────────┴──────────────┘In Next.js App Router, prefetch server-side data using QueryClient.prefetchQuery() in your Server Component, then pass the dehydrated state to HydrationBoundary in the Client Component. This gives you zero-loading-state on first render while still getting all React Query's client-side cache management. SWR has an equivalent fallback prop for the same pattern.
SWR mutations require manual cache updates via the mutate() function — you tell SWR what the new cache value should be. React Query's useMutation hook has an onMutate / onError / onSettled lifecycle that makes optimistic updates, rollback on error, and cache invalidation a structured pattern rather than ad-hoc logic. For ERP dashboards where users edit records and need immediate UI feedback with rollback on network failure, React Query's mutation model is significantly cleaner.
Both libraries support infinite scrolling with useInfiniteQuery (React Query) and useSWRInfinite (SWR). React Query's implementation is more ergonomic — the pages array and fetchNextPage() function handle most pagination patterns without custom logic. SWR requires more manual wiring but gives you more control over the data merging strategy. For a paginated table vs an infinite scroll feed, both work well.
// ─── React Query: full mutation with optimistic update ───
import { useMutation, useQueryClient } from "@tanstack/react-query"
function useUpdateProduct() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: UpdateProductDto) =>
fetch(`/api/products/${data.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}).then((r) => r.json()),
// Optimistic update
onMutate: async (newProduct) => {
await queryClient.cancelQueries({ queryKey: ["products", newProduct.id] })
const prev = queryClient.getQueryData(["products", newProduct.id])
queryClient.setQueryData(["products", newProduct.id], newProduct)
return { prev }
},
onError: (_err, newProduct, ctx) => {
queryClient.setQueryData(["products", newProduct.id], ctx?.prev)
},
onSettled: (_data, _err, vars) => {
queryClient.invalidateQueries({ queryKey: ["products", vars.id] })
queryClient.invalidateQueries({ queryKey: ["products"] })
},
})
}
// ─── SWR: fetching + manual mutation ───
import useSWR, { mutate } from "swr"
const fetcher = (url: string) => fetch(url).then((r) => r.json())
function useProducts() {
const { data, error, isLoading } = useSWR("/api/products", fetcher, {
revalidateOnFocus: true,
dedupingInterval: 5000,
})
const updateProduct = async (id: string, payload: Partial<Product>) => {
// Optimistic update with SWR
await mutate(
"/api/products",
async (current: Product[]) => {
const updated = await fetch(`/api/products/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).then((r) => r.json())
return current.map((p) => (p.id === id ? updated : p))
},
{ optimisticData: (current) =>
current?.map((p) => (p.id === id ? { ...p, ...payload } : p)) }
)
}
return { data, error, isLoading, updateProduct }
}The key insight both libraries share: server state is fundamentally different from client state. Client state (UI toggles, form inputs, modal open/closed) belongs in useState or a state manager like Zustand. Server state (database records, API responses) belongs in React Query or SWR. Mixing them — storing fetched data in useState — leads to stale data, race conditions, and synchronization bugs.
Using both libraries in the same codebase doubles the bundle cost and creates two separate cache layers that don't know about each other. Pick one at project start and stick to it. If you inherit a codebase with both, migrate everything to React Query (it's more capable) and remove SWR. Keeping both 'temporarily' almost always becomes permanent.
In Next.js App Router, Server Components handle the initial fetch — no React Query or SWR needed for the first paint. React Query and SWR come in for client-side interactions after hydration: refreshing data on user actions, polling for live updates, and mutations. Wrap your Client Components in a QueryClientProvider (React Query) or SWRConfig (SWR) in your root layout. Use the dehydration pattern to pass server-fetched data into the client cache.
For a new Next.js project: start with SWR. It covers 80% of use cases with a smaller bundle and simpler mental model. Migrate to React Query if you need DevTools to debug cache state, are doing optimistic mutations with rollback, have complex dependent queries, or need offline-first behavior. For ERP dashboards, admin panels, and data-heavy applications: start with React Query. The investment in learning the mutation lifecycle pays off within the first sprint.