React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

swrfetchutilityclient-componentscachingrevalidationcustom-hook

Centralized Fetch Utility with SWR

Build a single fetch utility that every client component references — consistent error handling, auth headers, base URL, and caching in one place.

Recipe

Quick-reference recipe card -- copy-paste ready.

// lib/fetcher.ts
const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "https://api.example.com";
 
export async function fetcher<T>(path: string): Promise<T> {
  const res = await fetch(`${BASE_URL}${path}`, {
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${getToken()}`,
    },
  });
 
  if (!res.ok) {
    const error = new Error("Fetch failed") as Error & { status: number };
    error.status = res.status;
    throw error;
  }
 
  return res.json() as Promise<T>;
}
 
function getToken(): string {
  if (typeof window === "undefined") return "";
  return localStorage.getItem("token") ?? "";
}
// hooks/use-api.ts
"use client";
import useSWR from "swr";
import { fetcher } from "@/lib/fetcher";
 
export function useApi<T>(path: string | null) {
  return useSWR<T>(path, fetcher);
}
// Any client component
const { data, error, isLoading } = useApi<Post[]>("/posts");

When to reach for this: You have multiple client components that fetch from the same API and you want one place to manage base URL, auth, headers, and error shape.

Working Example

Step 1: The Fetcher

The fetcher is a plain async function — no React, no hooks. SWR calls it with the key (the path) as the first argument.

// lib/fetcher.ts
const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "";
 
export class ApiError extends Error {
  constructor(
    message: string,
    public status: number,
    public data?: unknown
  ) {
    super(message);
    this.name = "ApiError";
  }
}
 
export async function fetcher<T>(path: string): Promise<T> {
  const headers: HeadersInit = {
    "Content-Type": "application/json",
  };
 
  // Attach auth token if available
  if (typeof window !== "undefined") {
    const token = localStorage.getItem("token");
    if (token) headers.Authorization = `Bearer ${token}`;
  }
 
  const res = await fetch(`${BASE_URL}${path}`, { headers });
 
  if (!res.ok) {
    const body = await res.json().catch(() => null);
    throw new ApiError(
      body?.message ?? `Request failed: ${res.status}`,
      res.status,
      body
    );
  }
 
  return res.json() as Promise<T>;
}

Key decisions:

  • Custom ApiError class carries status and response data so consumers can branch on 401 vs 404 vs 500.
  • typeof window guard keeps the fetcher safe if accidentally called during SSR.
  • Base URL from env var means zero code changes between dev/staging/prod.

Step 2: The SWR Provider

Wrap your app in SWRConfig to set global defaults — every useSWR call inherits them.

// components/swr-provider.tsx
"use client";
import { SWRConfig } from "swr";
import { fetcher } from "@/lib/fetcher";
 
export function SWRProvider({ children }: { children: React.ReactNode }) {
  return (
    <SWRConfig
      value={{
        fetcher,
        revalidateOnFocus: true,
        revalidateOnReconnect: true,
        errorRetryCount: 3,
        dedupingInterval: 2000,
      }}
    >
      {children}
    </SWRConfig>
  );
}
// app/layout.tsx
import { SWRProvider } from "@/components/swr-provider";
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <SWRProvider>{children}</SWRProvider>
      </body>
    </html>
  );
}

Why a provider? Without it, you pass fetcher to every useSWR call. The provider sets it once and every hook inherits it.

Step 3: The Hook

A thin typed wrapper around useSWR. Pass null as the key to skip fetching (conditional fetch).

// hooks/use-api.ts
"use client";
import useSWR, { type SWRConfiguration } from "swr";
import type { ApiError } from "@/lib/fetcher";
 
export function useApi<T>(
  path: string | null,
  options?: SWRConfiguration<T, ApiError>
) {
  const { data, error, isLoading, isValidating, mutate } = useSWR<T, ApiError>(
    path,
    options
  );
 
  return { data, error, isLoading, isValidating, mutate };
}

Step 4: Use It Everywhere

Every component uses the same hook. The fetcher handles auth, base URL, and error shaping behind the scenes.

// components/posts-list.tsx
"use client";
import { useApi } from "@/hooks/use-api";
 
type Post = { id: number; title: string; body: string };
 
export function PostsList() {
  const { data: posts, error, isLoading } = useApi<Post[]>("/posts");
 
  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
 
  return (
    <ul>
      {posts?.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}
// components/user-profile.tsx
"use client";
import { useApi } from "@/hooks/use-api";
 
type User = { id: number; name: string; email: string };
 
export function UserProfile({ userId }: { userId: number }) {
  const { data: user, isLoading } = useApi<User>(`/users/${userId}`);
 
  if (isLoading) return <p>Loading profile...</p>;
  return <h2>{user?.name}</h2>;
}
// Conditional fetching -- skip until ready
function CommentSection({ postId }: { postId: number | null }) {
  const { data: comments } = useApi<Comment[]>(
    postId ? `/posts/${postId}/comments` : null
  );
  // ...
}

Deep Dive

Why Centralize?

Without a centralized fetcher, every component duplicates:

  • Base URL construction
  • Auth header attachment
  • Error parsing and status code handling
  • Content-Type headers

One change (e.g., switching from localStorage tokens to cookies) means editing every fetch call. With the utility, you change one file.

SWR's Deduplication

SWR deduplicates requests by key. If three components on the same page call useApi<User>("/me"), SWR fires one network request and shares the result. This is why the key matters — it's both the cache key and the fetcher argument.

Component A: useApi("/me") ──┐
Component B: useApi("/me") ──┼── ONE fetch("/me") ── shared result
Component C: useApi("/me") ──┘

Mutation After Writes

After a POST/PUT/DELETE, tell SWR to refetch by calling mutate:

"use client";
import { useApi } from "@/hooks/use-api";
import { fetcher } from "@/lib/fetcher";
import { mutate } from "swr";
 
export function CreatePostForm() {
  async function handleSubmit(formData: FormData) {
    await fetch("/api/posts", {
      method: "POST",
      body: JSON.stringify({ title: formData.get("title") }),
      headers: { "Content-Type": "application/json" },
    });
 
    // Revalidate the posts list across all components
    mutate("/posts");
  }
 
  return (
    <form action={handleSubmit}>
      <input name="title" />
      <button type="submit">Create</button>
    </form>
  );
}

Optimistic Updates

SWR supports optimistic updates — update the cache immediately, then revalidate in the background:

async function toggleLike(postId: number) {
  mutate(
    `/posts/${postId}`,
    (current: Post | undefined) =>
      current ? { ...current, liked: !current.liked } : current,
    { revalidate: true }
  );
 
  await fetch(`/api/posts/${postId}/like`, { method: "POST" });
}

Polling and Real-Time

// Poll every 5 seconds
const { data } = useApi<Notification[]>("/notifications", {
  refreshInterval: 5000,
});

Error Retry with Backoff

SWR retries failed requests with exponential backoff by default. Customize it globally in the provider or per-hook:

const { data } = useApi<T>("/path", {
  errorRetryCount: 5,
  onErrorRetry(error, key, config, revalidate, { retryCount }) {
    if (error.status === 404) return; // don't retry 404s
    setTimeout(() => revalidate({ retryCount }), 2 ** retryCount * 1000);
  },
});

Gotchas

  1. SWR is client-only. useSWR uses React state internally. You cannot call it in a Server Component. For server-side data, use fetch directly in the Server Component and pass the data as props to the SWR-powered client component for revalidation.

  2. Keys must be stable strings. useSWR compares keys by identity. Building keys with template literals is fine (/posts/${id}), but don't pass objects as keys — they create new references every render and break deduplication.

  3. null key skips fetching, undefined does not. To conditionally skip a fetch, pass null explicitly. Passing undefined will serialize to "undefined" and fire a real request.

  4. The fetcher receives the key as its argument. If your key is "/posts", the fetcher gets "/posts". If you need to pass extra arguments, use the array key form: useSWR(["/posts", userId], ([path, id]) => fetcher(path)).

  5. Don't destructure mutate from the global import and the hook return. The global mutate from swr requires a key argument. The mutate returned from useSWR is already bound to that hook's key. Mixing them up causes silent bugs.

  6. Auth token changes require cache clearing. If the user logs out and logs back in as a different user, stale cached data from the previous session may display. Call mutate(() => true, undefined, { revalidate: true }) on auth change to clear all SWR cache.

  7. Provider must be a Client Component. SWRConfig uses React context. The component that renders it needs "use client". The layout that imports it can remain a Server Component.

Alternatives

ApproachWhen to use
TanStack QueryNeed mutations with built-in optimistic updates, infinite queries, or framework-agnostic support
Server Components + fetchData is static or only needed at render time — no client-side caching needed
use() + SuspenseUnwrapping server-passed promises in client components
Custom useFetch hookLightweight projects that don't want the SWR dependency