← All posts
·14 min read

Claude Code with SWR: Cache-First Data Fetching

Claude CodeSWRReactData Fetching
Claude Code with SWR: cache-first data fetching

Why SWR without CLAUDE.md ends up half-replaced by useEffect

SWR gives React a cache-first data layer: a useSWR hook returns cached data immediately, revalidates in the background, and shares the result across every component that asks for the same key. The value is in the cache and the key. Two components that request the same key get the same data from one network call, and a mutation that updates the cache updates every consumer at once. The failure mode is that none of this happens if the keys are unstable, the fetcher is ad-hoc, or the developer keeps reaching for the useEffect-and-useState fetching pattern alongside SWR, which fragments the cache and defeats the point.

A Claude Code session that writes data fetching without project rules reaches for the manual pattern it knows best: a useEffect that fires a fetch, a useState to hold the result, a second useState for loading, a third for the error. This works, but it caches nothing, dedupes nothing, and shares nothing between components, so two components showing the same data fire two requests. When Claude does use SWR, it often builds the key inline as a template string with values in an inconsistent order, so the same logical query produces different cache keys and the cache never hits. It writes a different inline fetcher for every call. It mutates by refetching the whole list instead of updating the cache optimistically. Each of these undermines the cache, and together they describe a data layer that has SWR installed but does not get its benefits.

This guide covers the CLAUDE.md configuration that locks Claude Code into SWR patterns that actually use the cache: a stable, structured key strategy, one typed shared fetcher, optimistic mutations that update the cache directly, deliberate revalidation config, and the discipline that keeps useEffect fetching out of the codebase entirely. For the React component patterns that consume this data, Claude Code with React covers the rendering layer, and for the Next.js integration with server components, Claude Code with Next.js covers the framework side. For the typed API contract the fetcher depends on, Claude Code with TypeScript covers the shared types.

The SWR CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For an SWR-based data layer it needs to declare the key strategy, the shared fetcher, the mutation patterns, the revalidation defaults, and the hard rules that block the manual-fetch habits Claude reaches for most often.

# SWR rules

## Stack
- swr 2.x for client data fetching
- One shared fetcher in src/lib/fetcher.ts, typed, NEVER inline fetchers
- Keys built by functions in src/lib/keys.ts, NEVER inline template strings
- SWRConfig provider at the app root sets global defaults

## Data fetching (MANDATORY)
- ALL client data fetching goes through useSWR or useSWRInfinite
- NEVER fetch with useEffect + useState for data that belongs in the cache
- Loading and error come from the SWR return value, NEVER separate useState
- Conditional fetching via a null key, NEVER an early return before the hook

## Cache keys (MANDATORY)
- Keys built by a key function: keys.order(id), keys.orders(filters)
- Array keys for parameterized queries: ["orders", filters]
- Keys are stable: same logical query always produces the same key
- NEVER build a key as an inline template string with interpolated values
- Filter objects passed as keys MUST have a stable property order

## Fetcher (MANDATORY)
- One typed fetcher: throws on non-2xx, parses JSON, returns typed data
- The fetcher attaches auth headers, NEVER per-call header duplication
- Errors carry status and body so error UIs can branch on them
- NEVER write a bespoke fetch inside a useSWR call

## Mutations (MANDATORY)
- Writes use useSWRMutation or the global mutate, NEVER a bare fetch
- Optimistic updates: update the cache, then revalidate, rollback on error
- After a write, mutate the affected keys, NEVER manually refetch a list
- Use populateCache for writes that return the updated resource

## Revalidation
- revalidateOnFocus tuned per data type (off for static, on for live)
- dedupingInterval set to collapse duplicate requests in a window
- keepPreviousData for paginated UIs to avoid layout flashes
- Error retry with backoff, capped, NEVER infinite retry

## Hard rules
- NEVER fetch data with useEffect + useState
- NEVER write an inline fetcher inside useSWR
- NEVER build a cache key as an interpolated template string
- NEVER mutate by refetching a whole list when the cache can be updated
- NEVER hold loading/error in separate useState alongside SWR
- ALWAYS build keys through the key functions
- ALWAYS update the cache optimistically on writes
- ALWAYS type the fetcher and the hook return

Six rules here prevent the majority of cache-defeating code Claude generates without them.

The no-useEffect-fetching rule is the one that protects the entire premise. The manual useEffect-plus-useState pattern is the data fetching a model has seen the most, so it is the default Claude reaches for. The rule bans it for any data that belongs in the cache, which is almost all of it. Every fetch goes through useSWR, so the cache, deduplication, and cross-component sharing all apply. The manual pattern survives only for genuinely local, non-cached side effects.

The stable-key rule is what makes the cache actually hit. SWR caches by key, so if the same logical query produces two different keys, it is two cache entries and two network calls. The most common way this happens is an inline template string where the interpolated values land in a different order or format. The rule moves key construction into named functions so keys.orders(filters) always produces the same key for the same filters, which is the precondition for any cache hit.

The single-fetcher rule removes the per-call fetch boilerplate and centralizes error handling. Without it, Claude writes a different inline fetch in every useSWR call, each with its own header handling and its own idea of what counts as an error. The rule mandates one typed fetcher that throws on non-2xx, attaches auth, and returns typed data, so every hook gets consistent behaviour and the error UI can rely on a consistent error shape.

The shared fetcher

One fetcher handles every request: it attaches auth, throws on non-2xx with a structured error, and returns typed data.

// src/lib/fetcher.ts
export class FetchError extends Error {
  constructor(
    message: string,
    readonly status: number,
    readonly body: unknown,
  ) {
    super(message);
    this.name = "FetchError";
  }
}

export async function fetcher<T>(key: string | readonly unknown[]): Promise<T> {
  const url = Array.isArray(key) ? key[0] : (key as string);
  const params = Array.isArray(key) && key[1] ? toQuery(key[1]) : "";

  const res = await fetch(`${url}${params}`, {
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${getToken()}`,
    },
  });

  if (!res.ok) {
    const body = await res.json().catch(() => null);
    throw new FetchError(`request failed: ${res.status}`, res.status, body);
  }

  return res.json() as Promise<T>;
}

function toQuery(filters: unknown): string {
  const entries = Object.entries(filters as Record<string, string>)
    .filter(([, v]) => v != null)
    .sort(([a], [b]) => a.localeCompare(b));
  return entries.length ? `?${new URLSearchParams(entries).toString()}` : "";
}

The fetcher accepts either a string key or an array key, extracts the URL and any filter object, and serializes the filters into a query string with the parameters sorted, so the same filters always produce the same URL regardless of property order. On a non-2xx response it throws a FetchError that carries the status and parsed body, so an error UI can branch on error.status === 403 versus error.status === 404. The generic <T> types the return, so every hook that uses this fetcher gets a typed result. This single fetcher is what the CLAUDE.md rule mandates, and it means no useSWR call ever needs its own fetch logic.

Stable cache keys

Keys are built by functions, never interpolated inline, so the same query always produces the same cache entry.

// src/lib/keys.ts
export interface OrderFilters {
  status?: string;
  userId?: string;
}

export const keys = {
  order: (id: string) => ["/api/orders/", id] as const,
  orders: (filters: OrderFilters = {}) => ["/api/orders", filters] as const,
  user: (id: string) => ["/api/users/", id] as const,
};

Each key function returns an as const tuple, which gives SWR a stable, structurally-compared key and gives TypeScript a precise type. keys.orders(filters) always produces ["/api/orders", filters], and because SWR compares array keys structurally, two calls with equivalent filters hit the same cache entry. Centralizing keys here means a change to the URL or the key shape happens in one place, and it makes it impossible to accidentally build a different key for the same query in two components. This is the stable-key discipline the CLAUDE.md rule exists to enforce, and it is the foundation every cache hit depends on.

Fetching with useSWR

A component fetches by passing a key and reading the typed return. Loading and error come from the hook, not from separate state.

// src/components/order-list.tsx
import useSWR from "swr";
import { fetcher } from "../lib/fetcher";
import { keys, type OrderFilters } from "../lib/keys";
import type { Order } from "../types";

export function OrderList({ filters }: { filters: OrderFilters }) {
  const { data, error, isLoading } = useSWR<Order[]>(
    keys.orders(filters),
    fetcher,
  );

  if (isLoading) return <p>Loading orders</p>;
  if (error) {
    return error.status === 403 ? <p>Not allowed</p> : <p>Failed to load</p>;
  }

  return (
    <ul>
      {data?.map((order) => (
        <li key={order.id}>
          {order.id}: {order.total}
        </li>
      ))}
    </ul>
  );
}

The hook takes the key from keys.orders(filters) and the shared fetcher, and returns data, error, and isLoading. There is no useEffect, no useState for the result, no separate loading or error state, because SWR provides all of it. The error handling branches on error.status, which works because the shared fetcher throws a structured FetchError. Two OrderList components mounted with the same filters share one cache entry and one network request, which is the deduplication that the key and fetcher discipline unlock. For the broader component patterns around this, Claude Code with React covers state and rendering.

Conditional fetching

Sometimes a fetch should only run when a dependency is available. The SWR way is a null key, not an early return before the hook, which would violate the rules of hooks.

export function UserOrders({ userId }: { userId: string | null }) {
  // when userId is null, the key is null and SWR does not fetch
  const { data } = useSWR<Order[]>(
    userId ? keys.orders({ userId }) : null,
    fetcher,
  );

  if (!userId) return <p>Select a user</p>;
  return <OrderTable orders={data ?? []} />;
}

When userId is null, the key passed to useSWR is null, which tells SWR to skip the fetch entirely while still calling the hook unconditionally. This is the correct way to do conditional fetching: the hook always runs, satisfying the rules of hooks, but the request only fires when the key is non-null. The common mistake the CLAUDE.md rule guards against is returning early before the useSWR call, which changes the number of hooks rendered and breaks React. The null key is the idiomatic pattern, and it composes cleanly with the key functions.

Optimistic mutations

A write should update the cache immediately, then reconcile with the server, rolling back on failure. This is where SWR's mutation API earns its place over a bare fetch and refetch.

// src/components/order-actions.tsx
import { useSWRConfig } from "swr";
import { fetcher } from "../lib/fetcher";
import { keys } from "../lib/keys";
import type { Order } from "../types";

export function useUpdateOrderStatus() {
  const { mutate } = useSWRConfig();

  return async (order: Order, status: string) => {
    const key = keys.order(order.id);
    const optimistic = { ...order, status };

    await mutate(
      key,
      async () => {
        const res = await fetch(`/api/orders/${order.id}`, {
          method: "PATCH",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ status }),
        });
        if (!res.ok) throw new Error("update failed");
        return res.json() as Promise<Order>;
      },
      {
        optimisticData: optimistic,
        rollbackOnError: true,
        populateCache: true,
        revalidate: false,
      },
    );

    // also refresh any list that includes this order
    await mutate(
      (k) => Array.isArray(k) && k[0] === "/api/orders",
      undefined,
      { revalidate: true },
    );
  };
}

The first mutate call updates the single-order cache entry. The optimisticData shows the new status instantly, before the network call returns, so the UI feels immediate. rollbackOnError reverts the cache if the request throws, so a failed write does not leave the UI showing a change that did not happen. populateCache: true writes the server's response into the cache, and revalidate: false skips a redundant refetch since the response is authoritative. The second mutate uses a filter function to revalidate every list key that includes orders, so any list showing this order refreshes. This is the optimistic-update pattern the CLAUDE.md rule mandates, and it is the difference between a write that feels instant and one that blocks the UI on a round trip. For the alternative of refetching after a write, which the rules forbid for cacheable data, the contrast is exactly the point: updating the cache is faster and keeps every consumer in sync.

Global configuration and revalidation

The SWRConfig provider at the app root sets defaults so every hook inherits sensible revalidation behaviour without repeating it.

// src/app-providers.tsx
import { SWRConfig } from "swr";
import { fetcher } from "./lib/fetcher";

export function AppProviders({ children }: { children: React.ReactNode }) {
  return (
    <SWRConfig
      value={{
        fetcher,
        revalidateOnFocus: false,
        dedupingInterval: 5000,
        errorRetryCount: 3,
        keepPreviousData: true,
        onError: (err) => {
          if (err.status >= 500) reportError(err);
        },
      }}
    >
      {children}
    </SWRConfig>
  );
}

Setting the fetcher here means hooks can omit it and inherit the shared one. revalidateOnFocus: false stops a refetch every time the window regains focus, which is right for data that does not change second-to-second; flip it to true for live dashboards. dedupingInterval: 5000 collapses duplicate requests for the same key within a five-second window, so a burst of components mounting at once produces one request. errorRetryCount: 3 caps retries so a persistently failing endpoint does not retry forever. keepPreviousData: true holds the last result while a new key loads, which prevents the layout flash when filters change. These defaults encode the revalidation policy the CLAUDE.md rule describes, applied once at the root.

Permission hooks for SWR development

Most SWR development is safe, but the surrounding commands can run long-lived processes or modify the project. Permission hooks in .claude/settings.local.json keep the loop tight.

{
  "permissions": {
    "allow": [
      "Bash(npx vitest run*)",
      "Bash(npx tsc --noEmit*)",
      "Bash(npx eslint src*)",
      "Bash(npm run build*)"
    ],
    "deny": [
      "Bash(npm run dev*)",
      "Bash(npm install*--save*)",
      "Bash(npx swr*)"
    ]
  }
}

The allow list permits the type check, the test run, linting, and the production build, which are the commands Claude needs to verify a data-layer change. The deny list catches the long-lived dev server and dependency installs, which should be deliberate. This keeps Claude focused on writing and verifying the SWR code rather than starting servers or changing the dependency tree. Combined with the Claude Code hooks system for project-wide policy, the result is a development loop where the model can validate its work without side effects on the running environment.

Testing SWR hooks

Hooks are tested by rendering them inside a test cache and asserting on the data flow. The pattern wraps the hook in a fresh SWRConfig per test so the cache does not leak between cases.

// src/components/order-list.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import { SWRConfig } from "swr";
import { OrderList } from "./order-list";

function renderWithSWR(ui: React.ReactElement, fetcher: () => Promise<unknown>) {
  return render(
    <SWRConfig value={{ provider: () => new Map(), fetcher, dedupingInterval: 0 }}>
      {ui}
    </SWRConfig>,
  );
}

test("renders orders from the fetcher", async () => {
  const fetcher = async () => [{ id: "a", total: 50 }];
  renderWithSWR(<OrderList filters={{}} />, fetcher);

  await waitFor(() => expect(screen.getByText(/a: 50/)).toBeInTheDocument());
});

test("shows the not-allowed message on a 403", async () => {
  const fetcher = async () => {
    throw Object.assign(new Error("forbidden"), { status: 403 });
  };
  renderWithSWR(<OrderList filters={{}} />, fetcher);

  await waitFor(() => expect(screen.getByText("Not allowed")).toBeInTheDocument());
});

Each test wraps the component in a fresh SWRConfig with provider: () => new Map(), which gives it an isolated cache so one test's data never bleeds into another. The fetcher is replaced with a test double that returns known data or throws a known error, so the test controls exactly what the hook sees without touching the network. The first test asserts the happy path renders, the second asserts the error branch shows the right message for a 403. This is the testing pattern that the shared-fetcher and structured-error discipline make possible, because the error shape is predictable. For the full test runner setup, Claude Code with Vitest covers the configuration.

Building a data layer that actually uses the cache

The SWR CLAUDE.md in this guide produces a data layer where every fetch goes through useSWR with a stable key from a key function, one typed fetcher handles auth and errors, mutations update the cache optimistically and reconcile with the server, and global config sets revalidation policy once. The result is the cache-first behaviour SWR promises: shared data across components, deduplicated requests, and instant-feeling writes, instead of a codebase that has SWR installed but fetches with useEffect half the time.

The underlying principle is the same as any library used with Claude Code. SWR without a CLAUDE.md produces code that reaches for the manual useEffect fetch and builds unstable keys, because that is the data fetching a model knows best and the readable default it falls back to. The CLAUDE.md is what makes the cache-keyed, typed, mutation-safe patterns the default for Claude.

For the React rendering and state patterns around the data, Claude Code with React covers the component layer. For the Next.js integration with server components and route handlers, Claude Code with Next.js covers the framework side. For the test runner that validates hooks against an isolated cache, Claude Code with Vitest covers the setup.

Get Claudify. Build an SWR data layer where every fetch hits the cache and every write feels instant.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame