AiTechWorlds
AiTechWorlds
SWR and TanStack Query are client-side data-fetching libraries that cache, deduplicate, and automatically refresh requests so components don't each manage their own fetch logic.
Think of a shared office assistant who remembers what everyone last asked for and quietly checks for updates in the background, rather than each employee calling the same vendor separately every few minutes. Server Components handle most data fetching in Next.js, but not everything — when data depends on user interaction, real-time updates, or client-side state that changes after the page loads, you need client-side fetching, and these two libraries are the tools that make it clean and reliable.
Server Components fetch on the server, before the page loads. Use client-side fetching when:
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:
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>
);
}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 has more features: mutations with loading states, infinite scroll, prefetching, and sophisticated cache management.
npm install @tanstack/react-query @tanstack/react-query-devtools// 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>
);
}"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>
);
}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>
);
}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 | 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