SWR & React Query for Client Fetching
SWR & React Query for Client-Side Fetching
Server Components handle most data fetching in Next.js — but not everything. When you need data that depends on user interaction, real-time updates, or client-side state that changes after the page loads, you need client-side fetching. SWR and TanStack Query (React Query) are the tools that make this clean and reliable.
When You Need Client-Side Fetching
Server Components fetch on the server, before the page loads. Use client-side fetching when:
- Data depends on user interaction (search, filters, pagination)
- Data should update in real-time or after actions
- Data depends on client state (auth token, browser storage)
- You need optimistic updates (update UI before server confirms)
SWR — Simple and Fast
SWR (Stale-While-Revalidate) from Vercel is minimal and fast. It fetches data, caches it, and revalidates it in the background:
npm install swr
"use client";
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then(r => r.json());
function UserDashboard() {
const { data: user, error, isLoading } = useSWR("/api/user/me", fetcher);
if (isLoading) return <DashboardSkeleton />;
if (error) return <ErrorMessage message="Failed to load user data" />;
return (
<div>
<h1>Welcome, {user.name}</h1>
<p>{user.email}</p>
</div>
);
}
SWR automatically:
- Caches the response
- Revalidates when the user refocuses the tab
- Deduplicates identical requests
- Retries on failure
SWR with Dynamic Keys
When the URL depends on state:
function SearchResults() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
// Key is null when query is empty — SWR won't fetch
const { data: results, isLoading } = useSWR(
debouncedQuery ? `/api/search?q=${debouncedQuery}` : null,
fetcher
);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
{isLoading && <Spinner />}
{results?.map(r => <ResultCard key={r.id} result={r} />)}
</div>
);
}
SWR Mutation
After a user creates or updates data, tell SWR to refetch:
import useSWR, { useSWRConfig } from "swr";
function CourseList() {
const { mutate } = useSWRConfig();
const { data: courses } = useSWR("/api/courses", fetcher);
async function handleDelete(id: string) {
await fetch(`/api/courses/${id}`, { method: "DELETE" });
// Revalidate the courses list
mutate("/api/courses");
}
return (
<div>
{courses?.map(course => (
<div key={course.id}>
{course.title}
<button onClick={() => handleDelete(course.id)}>Delete</button>
</div>
))}
</div>
);
}
TanStack Query (React Query) — Full-Featured
TanStack Query has more features: mutations with loading states, infinite scroll, prefetching, and sophisticated cache management.
npm install @tanstack/react-query @tanstack/react-query-devtools
Setup
// src/providers/query-provider.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // data is fresh for 1 minute
retry: 2, // retry failed requests twice
},
},
}));
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
// src/app/layout.tsx
import { QueryProvider } from "@/providers/query-provider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<QueryProvider>{children}</QueryProvider>
</body>
</html>
);
}
useQuery — Fetching Data
"use client";
import { useQuery } from "@tanstack/react-query";
async function fetchCourses(category?: string): Promise<Course[]> {
const url = category ? `/api/courses?category=${category}` : "/api/courses";
const res = await fetch(url);
if (!res.ok) throw new Error("Failed to fetch courses");
return res.json();
}
function CourseCatalog() {
const [category, setCategory] = useState("all");
const { data: courses, isLoading, isError, error } = useQuery({
queryKey: ["courses", category], // cache key — changes when category changes
queryFn: () => fetchCourses(category === "all" ? undefined : category),
staleTime: 5 * 60 * 1000, // fresh for 5 minutes
});
return (
<div>
<CategoryFilter value={category} onChange={setCategory} />
{isLoading && <CourseGridSkeleton />}
{isError && <ErrorMessage message={error.message} />}
{courses && <CourseGrid courses={courses} />}
</div>
);
}
useMutation — Creating and Updating Data
import { useMutation, useQueryClient } from "@tanstack/react-query";
interface CreateCourseInput {
title: string;
description: string;
price: number;
}
function CreateCourseForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (input: CreateCourseInput) =>
fetch("/api/courses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
}).then(r => r.json()),
onSuccess: (newCourse) => {
// Invalidate and refetch the courses list
queryClient.invalidateQueries({ queryKey: ["courses"] });
// Or: add the new course to the cache immediately
queryClient.setQueryData(["courses"], (old: Course[] = []) => [
...old,
newCourse,
]);
},
onError: (error) => {
console.error("Failed to create course:", error);
},
});
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
mutation.mutate({
title: formData.get("title") as string,
description: formData.get("description") as string,
price: Number(formData.get("price")),
});
}
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Course title" required />
<textarea name="description" placeholder="Description" />
<input name="price" type="number" min="0" step="0.01" />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Creating..." : "Create Course"}
</button>
{mutation.isError && (
<p className="text-red-600">Failed to create course</p>
)}
{mutation.isSuccess && (
<p className="text-green-600">Course created!</p>
)}
</form>
);
}
Infinite Queries
For "load more" buttons or infinite scroll:
import { useInfiniteQuery } from "@tanstack/react-query";
import { useInView } from "react-intersection-observer";
function InfinitePostList() {
const { ref, inView } = useInView();
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ["posts"],
queryFn: ({ pageParam = 1 }) =>
fetch(`/api/posts?page=${pageParam}&limit=10`).then(r => r.json()),
getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextPage : undefined,
initialPageParam: 1,
});
useEffect(() => {
if (inView && hasNextPage) fetchNextPage();
}, [inView, hasNextPage, fetchNextPage]);
const posts = data?.pages.flatMap(page => page.posts) ?? [];
return (
<div>
{posts.map(post => <PostCard key={post.id} post={post} />)}
{/* Sentinel element — when visible, load more */}
<div ref={ref}>
{isFetchingNextPage && <Spinner />}
</div>
</div>
);
}
SWR vs TanStack Query
| SWR | TanStack Query | |
|---|---|---|
| Bundle size | ~4kb | ~13kb |
| Mutations | Basic | Full-featured with state |
| Devtools | None | Excellent devtools |
| Infinite queries | Good | Excellent |
| Learning curve | Low | Medium |
| Best for | Simple fetching | Complex data requirements |
Start with SWR for simple cases. Reach for TanStack Query when you need sophisticated mutation handling, optimistic updates, or infinite scroll.
Next lesson: Optimistic updates and mutations — making UI feel instant.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises