Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →
22 minLesson 23 of 33
Data Fetching & Caching

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 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

SWRTanStack Query
Bundle size~4kb~13kb
MutationsBasicFull-featured with state
DevtoolsNoneExcellent devtools
Infinite queriesGoodExcellent
Learning curveLowMedium
Best forSimple fetchingComplex 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

Get Notes Free →
!