TechLead

Data Fetching Patterns

Modern data fetching in React — from raw useEffect to TanStack Query, SWR, Suspense, and Server Components.

Data Fetching Patterns

Data fetching is one of the most common tasks in React applications, and the ecosystem has evolved significantly. This guide covers the spectrum from manual fetching with useEffect to production-ready libraries like TanStack Query and SWR, plus modern patterns like Suspense and Server Components.

⚠️ The Evolution

Raw useEffect + fetch is fine for learning, but production apps should use a data fetching library. These handle caching, background refetching, error retries, race conditions, and more — problems that are surprisingly hard to solve correctly by hand.

1. The Basic Pattern: useEffect + fetch

The starting point. You need to handle loading, errors, race conditions, and cleanup yourself.

import { useState, useEffect } from "react";

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const controller = new AbortController();

    async function fetchUser() {
      setLoading(true);
      setError(null);
      try {
        const res = await fetch(`/api/users/${userId}`, {
          signal: controller.signal,
        });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = await res.json();
        setUser(data);
      } catch (err) {
        if (err.name !== "AbortError") {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    }

    fetchUser();

    // Cleanup: abort on unmount or userId change
    return () => controller.abort();
  }, [userId]);

  if (loading) return <Skeleton />;
  if (error) return <ErrorMessage message={error} />;
  return <div>{user.name}</div>;
}

Issues with Manual Fetching

  • No caching — refetches on every mount, even if data hasn't changed
  • No background refetching — stale data stays stale
  • No deduplication — multiple components fetching the same data
  • Boilerplate — loading/error/data state repeated in every component
  • No retry logic — one failure and you're done

2. Building a Reusable Hook

Encapsulate the fetch pattern in a custom hook to reduce boilerplate.

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

  useEffect(() => {
    const controller = new AbortController();

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(setData)
      .catch((err) => {
        if (err.name !== "AbortError") setError(err);
      })
      .finally(() => setLoading(false));

    return () => controller.abort();
  }, [url]);

  return { data, error, loading };
}

// Usage
function Posts() {
  const { data: posts, error, loading } = useFetch("/api/posts");

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
  return posts.map((p) => <h3 key={p.id}>{p.title}</h3>);
}

3. TanStack Query (React Query)

The most popular data fetching library for React. It handles caching, background refetching, pagination, optimistic updates, and much more.

import { QueryClient, QueryClientProvider, useQuery, useMutation } from "@tanstack/react-query";

const queryClient = new QueryClient();

// Wrap your app
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Posts />
    </QueryClientProvider>
  );
}

// Fetching data
function Posts() {
  const {
    data: posts,
    isLoading,
    isError,
    error,
    refetch,
  } = useQuery({
    queryKey: ["posts"],            // Cache key
    queryFn: () =>                  // Fetcher function
      fetch("/api/posts").then((r) => r.json()),
    staleTime: 5 * 60 * 1000,      // 5 min before data is "stale"
    gcTime: 30 * 60 * 1000,        // 30 min before garbage collection
    retry: 3,                       // Retry failed requests
  });

  if (isLoading) return <Skeleton />;
  if (isError) return <p>Error: {error.message}</p>;

  return (
    <div>
      <button onClick={() => refetch()}>Refresh</button>
      {posts.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

Mutations with TanStack Query

function CreatePost() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (newPost) =>
      fetch("/api/posts", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(newPost),
      }).then((r) => r.json()),

    // Optimistic update
    onMutate: async (newPost) => {
      await queryClient.cancelQueries({ queryKey: ["posts"] });
      const previous = queryClient.getQueryData(["posts"]);

      queryClient.setQueryData(["posts"], (old) => [
        ...old,
        { ...newPost, id: Date.now() },
      ]);

      return { previous }; // Context for rollback
    },

    // Rollback on error
    onError: (err, newPost, context) => {
      queryClient.setQueryData(["posts"], context.previous);
    },

    // Refetch after success or error
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ["posts"] });
    },
  });

  const handleSubmit = (data) => {
    mutation.mutate(data);
  };

  return (
    <form onSubmit={handleSubmit}>
      {mutation.isPending && <p>Creating...</p>}
      {mutation.isError && <p>Error: {mutation.error.message}</p>}
      {/* form fields */}
    </form>
  );
}

4. SWR (Stale-While-Revalidate)

Vercel's data fetching library. Lighter than TanStack Query with a simpler API, but fewer advanced features.

import useSWR from "swr";

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

function UserProfile({ userId }) {
  const { data, error, isLoading, mutate } = useSWR(
    `/api/users/${userId}`,
    fetcher,
    {
      revalidateOnFocus: true,     // Refetch when window regains focus
      revalidateOnReconnect: true, // Refetch on network recovery
      dedupingInterval: 2000,      // Dedupe requests within 2s
    }
  );

  if (isLoading) return <Skeleton />;
  if (error) return <p>Error loading user</p>;

  return (
    <div>
      <h2>{data.name}</h2>
      <button onClick={() => mutate()}>Refresh</button>
    </div>
  );
}

// Conditional fetching — pass null key to skip
function MaybeUser({ userId }) {
  const { data } = useSWR(
    userId ? `/api/users/${userId}` : null,
    fetcher
  );
  return data ? <p>{data.name}</p> : <p>No user selected</p>;
}

5. Infinite Scrolling & Pagination

Both TanStack Query and SWR have built-in support for paginated and infinite data.

import { useInfiniteQuery } from "@tanstack/react-query";

function InfinitePostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = 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,
  });

  if (isLoading) return <Skeleton />;

  return (
    <div>
      {data.pages.map((page) =>
        page.posts.map((post) => <PostCard key={post.id} post={post} />)
      )}

      {hasNextPage && (
        <button
          onClick={() => fetchNextPage()}
          disabled={isFetchingNextPage}
        >
          {isFetchingNextPage ? "Loading more..." : "Load More"}
        </button>
      )}
    </div>
  );
}

6. React Suspense for Data Fetching

Suspense lets you declaratively specify loading states. Libraries like TanStack Query integrate with Suspense out of the box.

import { Suspense } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";

// This component suspends while loading
function UserDetails({ userId }) {
  const { data: user } = useSuspenseQuery({
    queryKey: ["user", userId],
    queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
  });

  // No loading check needed — Suspense handles it!
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

// Parent wraps in Suspense
function UserPage({ userId }) {
  return (
    <Suspense fallback={<Skeleton />}>
      <UserDetails userId={userId} />
    </Suspense>
  );
}

// Multiple Suspense boundaries for granular loading
function Dashboard() {
  return (
    <div className="grid grid-cols-2 gap-4">
      <Suspense fallback={<CardSkeleton />}>
        <RevenueChart />
      </Suspense>
      <Suspense fallback={<CardSkeleton />}>
        <RecentOrders />
      </Suspense>
    </div>
  );
}

7. Server Components (Next.js)

With React Server Components (RSC), you can fetch data directly in your component without useEffect or client-side libraries. The data fetching happens on the server.

// app/users/page.tsx — Server Component (default in Next.js App Router)
// No "use client" directive = Server Component

async function UsersPage() {
  // Direct async/await — no hooks, no loading state
  const res = await fetch("https://api.example.com/users", {
    next: { revalidate: 60 }, // ISR: revalidate every 60 seconds
  });
  const users = await res.json();

  return (
    <div>
      <h1>Users</h1>
      {users.map((user) => (
        <UserCard key={user.id} user={user} />
      ))}
    </div>
  );
}

// Works with databases too
import { db } from "@/lib/db";

async function PostsPage() {
  const posts = await db.post.findMany({
    orderBy: { createdAt: "desc" },
    take: 10,
  });

  return posts.map((post) => <PostCard key={post.id} post={post} />);
}

8. Error Handling Patterns

Robust error handling is essential for production data fetching.

// Create a typed API client
class ApiError extends Error {
  constructor(message, status, data) {
    super(message);
    this.status = status;
    this.data = data;
  }
}

async function api(url, options = {}) {
  const res = await fetch(url, {
    headers: { "Content-Type": "application/json", ...options.headers },
    ...options,
  });

  if (!res.ok) {
    const data = await res.json().catch(() => null);
    throw new ApiError(
      data?.message || `HTTP ${res.status}`,
      res.status,
      data
    );
  }

  return res.json();
}

// Use with TanStack Query
function UserProfile({ userId }) {
  const { data, error } = useQuery({
    queryKey: ["user", userId],
    queryFn: () => api(`/api/users/${userId}`),
    retry: (failureCount, error) => {
      // Don't retry 4xx errors
      if (error.status >= 400 && error.status < 500) return false;
      return failureCount < 3;
    },
  });

  if (error) {
    if (error.status === 404) return <NotFound />;
    if (error.status === 403) return <Forbidden />;
    return <GenericError message={error.message} />;
  }

  return <div>{data?.name}</div>;
}

9. Prefetching & Deferred Data

Prefetch data before the user navigates to improve perceived performance.

import { useQueryClient } from "@tanstack/react-query";

function PostList({ posts }) {
  const queryClient = useQueryClient();

  return posts.map((post) => (
    <Link
      key={post.id}
      href={`/posts/${post.id}`}
      // Prefetch on hover
      onMouseEnter={() => {
        queryClient.prefetchQuery({
          queryKey: ["post", post.id],
          queryFn: () => api(`/api/posts/${post.id}`),
          staleTime: 5 * 60 * 1000,
        });
      }}
    >
      {post.title}
    </Link>
  ));
}

✅ Which Library Should You Use?

Scenario Recommendation
Learning / simple app Custom useFetch hook
Client-side SPA TanStack Query — most features, best DX
Next.js / Vercel stack SWR — lightweight, great Next.js integration
Next.js App Router Server Components + TanStack Query for client parts
Complex CRUD / admin TanStack Query with mutations & optimistic updates
Real-time data WebSockets + TanStack Query for initial load

📋 Data Fetching Checklist

  • Always handle loading, error, and success states
  • Abort in-flight requests on unmount or dependency change (AbortController)
  • Deduplicate requests — don't fetch the same data from multiple components
  • Cache responses — avoid refetching data that hasn't changed
  • Show stale data while revalidating — never show a blank screen when you can show cached data
  • Retry on failure — with exponential backoff for 5xx errors
  • Prefetch data — on hover or route transition for instant navigation
  • Type your API responses — use TypeScript interfaces for all API data