web-data-fetching-swr ยท diff
git:20260202.b236384 to git:20260316.9090a29
186 added, 878 removed. Audit A to A.
---
name: web-data-fetching-swr
description: SWR data fetching patterns - useSWR, useSWRMutation, caching, revalidation, infinite scroll
---
# SWR Data Fetching Patterns
- > **Quick Guide:** Use SWR for lightweight data fetching with stale-while-revalidate caching. Ideal for read-heavy applications with minimal mutations. Choose over React Query when you need a smaller bundle size and simpler API.
+ > **Quick Guide:** SWR implements the stale-while-revalidate caching strategy: show cached data instantly, revalidate in the background. Keys must be stable (strings or stable arrays), `isLoading` is for initial fetches only (use `isValidating` for background refreshes), and all write operations go through `useSWRMutation`. The null key pattern is how you do conditional fetching -- never call hooks conditionally.
---
<critical_requirements>
## CRITICAL: Before Using This Skill
- **(You MUST use a stable key - keys should NOT change on every render or you'll trigger infinite requests)**
+ **(You MUST use a stable key -- keys should NOT change on every render or you'll trigger infinite requests)**
- **(You MUST handle isLoading vs isValidating correctly - isLoading is true only on initial fetch with no data)**
+ **(You MUST handle isLoading vs isValidating correctly -- isLoading is true only on initial fetch with no data)**
- **(You MUST wrap mutations in `useSWRMutation` for write operations - NOT useSWR)**
+ **(You MUST wrap mutations in `useSWRMutation` for write operations -- NOT useSWR)**
- **(You MUST use named constants for ALL timeout, retry, and interval values - NO magic numbers)**
+ **(You MUST use named constants for ALL timeout, retry, and interval values -- NO magic numbers)**
- **(You MUST use named exports only - NO default exports)**
+ **(You MUST use named exports only -- NO default exports)**
</critical_requirements>
---
- **Auto-detection:** SWR useSWR, useSWRMutation, useSWRInfinite, SWRConfig, mutate, revalidate, fetcher, stale-while-revalidate
+ **Auto-detection:** SWR, useSWR, useSWRMutation, useSWRInfinite, useSWRImmutable, SWRConfig, mutate, revalidate, fetcher, stale-while-revalidate, preload
**When to use:**
- Read-heavy applications with infrequent mutations
- - Need lightweight bundle (5.3KB vs 16KB for React Query)
+ - Need lightweight bundle (~5KB gzipped)
- Simple caching with automatic revalidation
- - Next.js applications (built by Vercel, seamless integration)
- Applications where stale-while-revalidate pattern is desired
**When NOT to use:**
- - Complex mutation workflows with many side effects (use React Query)
- - Need request cancellation out of the box (use React Query)
- - Complex dependent queries with fine-grained control
-
- > **Note (SWR 2.0+):** SWR DevTools browser extension is now available with zero setup for v2+. See [SWR DevTools](https://swr-devtools.vercel.app/) for installation.
+ - Complex mutation workflows requiring many lifecycle callbacks
+ - Need built-in request cancellation (SWR requires manual AbortController)
+ - Complex dependent queries needing fine-grained invalidation control
**Key patterns covered:**
- - useSWR hook for data fetching with caching
- - Fetcher function patterns (fetch, axios)
- - isLoading vs isValidating distinction
- - Revalidation strategies (focus, reconnect, interval)
- - useSWRMutation for write operations
- - Optimistic updates with rollback
- - useSWRInfinite for pagination
- - Conditional fetching (null key pattern)
- - SWRConfig for global configuration
- - Suspense integration
+ - useSWR hook with typed fetchers and state handling
+ - isLoading vs isValidating distinction (the most common mistake)
+ - Revalidation strategies (focus, reconnect, interval, manual)
+ - useSWRMutation for write operations with optimistic updates
+ - useSWRInfinite for cursor and offset pagination
+ - Null key pattern for conditional fetching
+ - SWRConfig for global defaults and SSR fallback
**Detailed Resources:**
- - For code examples, see [examples/](examples/)
- - For decision frameworks and anti-patterns, see [reference.md](reference.md)
+ - [examples/core.md](examples/core.md) -- Fetchers, return values, SWRConfig, key patterns
+ - [examples/mutations.md](examples/mutations.md) -- useSWRMutation, optimistic updates, cache invalidation
+ - [examples/caching.md](examples/caching.md) -- Revalidation strategies, prefetching, persistence
+ - [examples/pagination.md](examples/pagination.md) -- useSWRInfinite, infinite scroll, offset pagination
+ - [examples/conditional.md](examples/conditional.md) -- Dependent queries, auth-gated fetching
+ - [examples/error-handling.md](examples/error-handling.md) -- Retry config, error boundaries, network detection
+ - [examples/suspense.md](examples/suspense.md) -- Suspense integration, SSR fallback patterns
+ - [reference.md](reference.md) -- Decision frameworks, configuration tables, anti-patterns
---
<philosophy>
## Philosophy
- SWR (stale-while-revalidate) is a data fetching strategy that returns cached (stale) data first, then sends the fetch request (revalidate), and finally comes with the up-to-date data. This creates a fast, responsive UI while ensuring data freshness.
+ SWR (stale-while-revalidate) returns cached data first, then revalidates in the background. This creates fast, responsive UIs while ensuring data freshness.
**Core principles:**
- **Stale-While-Revalidate**: Show cached data immediately, update in background
- **Deduplication**: Multiple components using same key share one request
- **Focus Revalidation**: Refetch when user returns to tab
- **Optimistic UI**: Update UI immediately, rollback on error
- **Minimal API**: Simple hooks, less configuration than alternatives
**Trade-offs:**
- - Simpler API means less control over complex scenarios
+ - Simpler API means less control over complex mutation scenarios
- Request cancellation requires manual AbortController setup
- - Less opinionated about mutations than React Query
-
- **SWR 2.0+ Features:**
-
- - SWR DevTools browser extension (zero setup for v2+)
- - useSWRMutation for remote mutations with trigger function
- - preload API for prefetching resources
- - isLoading state (distinct from isValidating)
- - keepPreviousData option for smooth data transitions
- - throwOnError option for error boundary integration
+ - Less opinionated about mutations (fewer lifecycle callbacks)
</philosophy>
---
<patterns>
## Core Patterns
- ### Pattern 1: Basic useSWR Setup
-
- Use useSWR for fetching data with automatic caching and revalidation.
-
- #### Constants
-
- ```typescript
- const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "/api";
- ```
+ ### Pattern 1: Typed Fetcher
- #### Fetcher Function
+ The fetcher must throw on non-OK responses. If it doesn't throw, SWR treats error bodies as valid data.
```typescript
// lib/fetcher.ts
+ interface FetchError extends Error {
+ info: unknown;
+ status: number;
+ }
+
const fetcher = async <T>(url: string): Promise<T> => {
const response = await fetch(url);
-
if (!response.ok) {
- const error = new Error("An error occurred while fetching the data.");
- // Attach extra info to the error object
- (error as any).info = await response.json();
- (error as any).status = response.status;
+ const error = new Error("Fetch failed") as FetchError;
+ error.info = await response.json().catch(() => null);
+ error.status = response.status;
throw error;
}
-
return response.json();
};
export { fetcher };
+ export type { FetchError };
```
- #### Implementation
-
- ```typescript
- // components/user-profile.tsx
- import useSWR from "swr";
- import { fetcher } from "@/lib/fetcher";
-
- interface User {
- id: string;
- name: string;
- email: string;
- }
-
- function UserProfile({ userId }: { userId: string }) {
- const { data, error, isLoading, isValidating, mutate } = useSWR<User>(
- `/api/users/${userId}`,
- fetcher
- );
-
- // isLoading: First load, no data yet
- if (isLoading) return <UserProfileSkeleton />;
-
- // error: Request failed
- if (error) return <ErrorCard message={error.message} onRetry={() => mutate()} />;
-
- // No data after loading
- if (!data) return <NotFound message="User not found" />;
-
- return (
- <article>
- {/* isValidating: Background refresh in progress */}
- {isValidating && <RefreshIndicator />}
- <h1>{data.name}</h1>
- <p>{data.email}</p>
- </article>
- );
- }
-
- export { UserProfile };
- ```
+ **Why good:** Throws on error (required for SWR error state to work), attaches status for conditional handling, typed error enables downstream type narrowing
- **Why good:** Clear distinction between isLoading (initial) and isValidating (background), typed fetcher provides type safety, bound mutate enables manual revalidation
+ See [examples/core.md](examples/core.md) for axios, GraphQL, and multi-argument fetcher variants.
---
- ### Pattern 2: Return Values and States
-
- Understand all useSWR return values for proper state handling.
+ ### Pattern 2: isLoading vs isValidating
- #### State Machine
+ The most common SWR mistake. `isLoading` is true only on initial fetch with no data. `isValidating` is true during any in-flight request.
```typescript
- // Understanding useSWR states
- interface SWRState<T> {
- data: T | undefined; // The fetched data
- error: Error | undefined; // Error object if request failed
- isLoading: boolean; // True when fetching AND no data exists
- isValidating: boolean; // True when any request is in-flight
- mutate: () => Promise<T>; // Manually revalidate
- }
-
// State combinations:
// Initial load: { data: undefined, isLoading: true, isValidating: true }
- // Success: { data: {...}, isLoading: false, isValidating: false }
- // Revalidating: { data: {...}, isLoading: false, isValidating: true }
- // Error (no data): { error: {...}, isLoading: false, isValidating: false }
- // Error (has data): { data: {...}, error: {...}, isLoading: false, isValidating: false }
+ // Success: { data: T, isLoading: false, isValidating: false }
+ // Revalidating: { data: T, isLoading: false, isValidating: true }
+ // Error (no data): { error: Error, isLoading: false, isValidating: false }
+ // Error (has data): { data: T, error: Error, isLoading: false }
```
- #### Implementation
-
```typescript
- // components/data-display.tsx
- import useSWR from "swr";
-
- function DataDisplay({ endpoint }: { endpoint: string }) {
- const { data, error, isLoading, isValidating, mutate } = useSWR(endpoint, fetcher);
-
- // Pattern: Show loading only on initial fetch
- if (isLoading) {
- return <Skeleton />;
- }
-
- // Pattern: Show error with retry
- if (error && !data) {
- return (
- <div className="error">
- <p>Failed to load: {error.message}</p>
- <button onClick={() => mutate()}>Retry</button>
- </div>
- );
- }
-
- // Pattern: Show stale data with error banner
- if (error && data) {
- return (
- <div>
- <Banner type="warning">Data may be outdated. {error.message}</Banner>
- <DataView data={data} />
- </div>
- );
- }
-
- // Pattern: Show data with refresh indicator
- return (
- <div>
- {isValidating && <span className="refresh-indicator">Updating...</span>}
- <DataView data={data} />
- </div>
- );
- }
+ // BAD: Using isValidating as loading indicator hides cached data
+ if (isValidating) return <Spinner />;
- export { DataDisplay };
+ // GOOD: isLoading for initial, isValidating for refresh indicator
+ if (isLoading) return <Spinner />;
+ return (
+ <div>
+ {isValidating && <RefreshIndicator />}
+ {error && data && <Banner>Data may be outdated</Banner>}
+ <Content data={data} />
+ </div>
+ );
```
- **Why good:** Handles all state combinations gracefully, shows stale data with error banner rather than hiding it, refresh indicator informs users without blocking content
-
- ---
-
- ### Pattern 3: Global Configuration with SWRConfig
+ **Why bad:** Showing spinner during background revalidation hides perfectly valid cached data, defeating the purpose of stale-while-revalidate
- Configure SWR defaults at the application level.
+ See [examples/core.md](examples/core.md) for full state handling with error + stale data combinations.
- #### Constants
+ ---
- ```typescript
- const REVALIDATE_INTERVAL_MS = 30 * 1000;
- const ERROR_RETRY_COUNT = 3;
- const ERROR_RETRY_INTERVAL_MS = 5000;
- const DEDUPING_INTERVAL_MS = 2000;
- ```
+ ### Pattern 3: SWRConfig Global Defaults
- #### Implementation
+ Centralize fetcher, retry, and revalidation settings. Nested SWRConfig overrides parent config.
```typescript
- // providers/swr-provider.tsx
- "use client";
-
- import { SWRConfig } from "swr";
- import type { ReactNode } from "react";
- import { fetcher } from "@/lib/fetcher";
-
- const REVALIDATE_INTERVAL_MS = 30 * 1000;
const ERROR_RETRY_COUNT = 3;
const ERROR_RETRY_INTERVAL_MS = 5000;
- const DEDUPING_INTERVAL_MS = 2000;
-
- interface SWRProviderProps {
- children: ReactNode;
- fallback?: Record<string, unknown>;
- }
-
- function SWRProvider({ children, fallback = {} }: SWRProviderProps) {
- return (
- <SWRConfig
- value={{
- // Default fetcher for all useSWR calls
- fetcher,
-
- // Revalidation settings
- revalidateOnFocus: true,
- revalidateOnReconnect: true,
- revalidateIfStale: true,
-
- // Polling (disabled by default)
- refreshInterval: 0,
-
- // Error handling
- errorRetryCount: ERROR_RETRY_COUNT,
- errorRetryInterval: ERROR_RETRY_INTERVAL_MS,
- shouldRetryOnError: true,
-
- // Deduplication
- dedupingInterval: DEDUPING_INTERVAL_MS,
-
- // Performance
- keepPreviousData: true,
-
- // Pre-fetched data (from SSR/SSG)
- fallback,
-
- // Global error handler
- onError: (error, key) => {
- if (error.status !== 403 && error.status !== 404) {
- // Report to error tracking service
- console.error(`SWR Error [${key}]:`, error);
- }
- },
- }}
- >
- {children}
- </SWRConfig>
- );
- }
-
- export { SWRProvider };
- ```
-
- **Why good:** Centralized configuration reduces repetition, fallback enables SSR/SSG data hydration, named constants make intervals self-documenting, global error handler enables centralized logging
-
- ---
-
- ### Pattern 4: Fetcher Patterns (fetch, axios)
-
- Create typed fetchers for different HTTP clients.
-
- #### Fetch Fetcher
-
- ```typescript
- // lib/fetchers/fetch-fetcher.ts
- interface FetchError extends Error {
- info: unknown;
- status: number;
- }
-
- async function fetchFetcher<T>(url: string): Promise<T> {
- const response = await fetch(url, {
- credentials: "include",
- headers: {
- "Content-Type": "application/json",
- },
- });
-
- if (!response.ok) {
- const error = new Error("Fetch failed") as FetchError;
- error.info = await response.json().catch(() => null);
- error.status = response.status;
- throw error;
- }
-
- return response.json();
- }
-
- export { fetchFetcher };
- export type { FetchError };
- ```
-
- #### Axios Fetcher
-
- ```typescript
- // lib/fetchers/axios-fetcher.ts
- import axios from "axios";
- import type { AxiosError } from "axios";
-
- const API_TIMEOUT_MS = 10000;
-
- const apiClient = axios.create({
- baseURL: process.env.NEXT_PUBLIC_API_URL,
- timeout: API_TIMEOUT_MS,
- withCredentials: true,
- });
-
- async function axiosFetcher<T>(url: string): Promise<T> {
- const response = await apiClient.get<T>(url);
- return response.data;
- }
-
- // Fetcher with POST (for complex queries)
- async function axiosPostFetcher<T>([url, body]: [string, unknown]): Promise<T> {
- const response = await apiClient.post<T>(url, body);
- return response.data;
- }
-
- export { axiosFetcher, axiosPostFetcher, apiClient };
- ```
-
- #### GraphQL Fetcher
-
- ```typescript
- // lib/fetchers/graphql-fetcher.ts
- interface GraphQLVariables {
- [key: string]: unknown;
- }
-
- interface GraphQLResponse<T> {
- data: T;
- errors?: Array<{ message: string }>;
- }
-
- const GRAPHQL_ENDPOINT = process.env.NEXT_PUBLIC_GRAPHQL_URL || "/graphql";
-
- async function graphqlFetcher<T>([query, variables]: [
- string,
- GraphQLVariables?,
- ]): Promise<T> {
- const response = await fetch(GRAPHQL_ENDPOINT, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ query, variables }),
- });
-
- const json: GraphQLResponse<T> = await response.json();
-
- if (json.errors) {
- throw new Error(json.errors.map((e) => e.message).join(", "));
- }
-
- return json.data;
- }
-
- export { graphqlFetcher };
- ```
-
- **Why good:** Typed fetchers provide full TypeScript support, error objects include status for conditional handling, axios instance enables interceptors and defaults, array keys enable multi-parameter fetchers
-
- ---
-
- ### Pattern 5: Revalidation Strategies
-
- Control when and how data is revalidated.
-
- #### Constants
+ const DEDUP_INTERVAL_MS = 2000;
- ```typescript
- const POLL_INTERVAL_MS = 10 * 1000;
- const FOCUS_THROTTLE_MS = 5000;
+ <SWRConfig value={{
+ fetcher,
+ errorRetryCount: ERROR_RETRY_COUNT,
+ errorRetryInterval: ERROR_RETRY_INTERVAL_MS,
+ dedupingInterval: DEDUP_INTERVAL_MS,
+ keepPreviousData: true,
+ fallback, // Pre-fetched data for SSR hydration
+ }}>
+ {children}
+ </SWRConfig>
```
- #### Implementation
-
- ```typescript
- // components/live-data.tsx
- import useSWR from "swr";
-
- const POLL_INTERVAL_MS = 10 * 1000;
- const FOCUS_THROTTLE_MS = 5000;
-
- // Pattern 1: Polling for real-time data
- function LiveStockPrice({ symbol }: { symbol: string }) {
- const { data } = useSWR(
- `/api/stocks/${symbol}`,
- fetcher,
- {
- // Poll every 10 seconds
- refreshInterval: POLL_INTERVAL_MS,
- // Don't poll when window is hidden
- refreshWhenHidden: false,
- // Don't poll when offline
- refreshWhenOffline: false,
- }
- );
-
- return <span>{data?.price}</span>;
- }
-
- // Pattern 2: Revalidate on focus (default behavior)
- function UserDashboard() {
- const { data } = useSWR("/api/dashboard", fetcher, {
- revalidateOnFocus: true,
- // Throttle focus revalidation
- focusThrottleInterval: FOCUS_THROTTLE_MS,
- });
-
- return <Dashboard data={data} />;
- }
-
- // Pattern 3: Revalidate on reconnect
- function OfflineAwareData() {
- const { data } = useSWR("/api/data", fetcher, {
- revalidateOnReconnect: true,
- });
-
- return <DataView data={data} />;
- }
-
- // Pattern 4: Disable automatic revalidation (static data)
- function StaticContent() {
- const { data } = useSWR("/api/config", fetcher, {
- revalidateOnFocus: false,
- revalidateOnReconnect: false,
- revalidateIfStale: false,
- });
-
- return <Config data={data} />;
- }
-
- // Pattern 5: Manual revalidation only
- function ManualRefresh() {
- const { data, mutate } = useSWR("/api/data", fetcher, {
- revalidateOnFocus: false,
- revalidateOnReconnect: false,
- revalidateIfStale: false,
- refreshInterval: 0,
- });
-
- return (
- <div>
- <DataView data={data} />
- <button onClick={() => mutate()}>Refresh</button>
- </div>
- );
- }
-
- export { LiveStockPrice, UserDashboard, OfflineAwareData, StaticContent, ManualRefresh };
- ```
+ **Why good:** Eliminates config duplication across components, `fallback` prop enables SSR data hydration, nested configs allow per-section overrides
- **Why good:** Different strategies for different data freshness needs, named constants make intervals clear, disabled options for static data prevent unnecessary requests
+ See [examples/core.md](examples/core.md) for full provider setup and nested config override patterns.
---
- ### Pattern 6: useSWRMutation for Write Operations
-
- Use useSWRMutation for POST/PUT/DELETE operations.
+ ### Pattern 4: useSWRMutation for Writes
- #### Implementation
+ Never use `useSWR` for mutations. `useSWR` fires on mount -- `useSWRMutation` fires on demand via `trigger()`.
```typescript
- // components/create-post-form.tsx
import useSWRMutation from "swr/mutation";
- import { useState } from "react";
- import type { FormEvent } from "react";
- interface CreatePostInput {
- title: string;
- content: string;
- }
-
- interface Post {
- id: string;
- title: string;
- content: string;
- createdAt: string;
- }
-
- // Mutation fetcher - receives key and { arg }
- async function createPost(url: string, { arg }: { arg: CreatePostInput }): Promise<Post> {
+ async function createPost(
+ url: string,
+ { arg }: { arg: CreatePostInput },
+ ): Promise<Post> {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(arg),
});
-
- if (!response.ok) {
- throw new Error("Failed to create post");
- }
-
+ if (!response.ok) throw new Error("Failed to create post");
return response.json();
}
- function CreatePostForm({ onSuccess }: { onSuccess?: (post: Post) => void }) {
- const [title, setTitle] = useState("");
- const [content, setContent] = useState("");
-
- const { trigger, isMutating, error, reset } = useSWRMutation(
- "/api/posts",
- createPost,
- {
- onSuccess: (data) => {
- setTitle("");
- setContent("");
- onSuccess?.(data);
- },
- onError: (err) => {
- console.error("Create post failed:", err);
- },
- }
- );
-
- const handleSubmit = async (e: FormEvent) => {
- e.preventDefault();
- if (!title.trim() || !content.trim()) return;
-
- await trigger({ title, content });
- };
-
- return (
- <form onSubmit={handleSubmit}>
- {error && (
- <div className="error">
- <p>{error.message}</p>
- <button type="button" onClick={reset}>Dismiss</button>
- </div>
- )}
- <input
- value={title}
- onChange={(e) => setTitle(e.target.value)}
- placeholder="Title"
- disabled={isMutating}
- />
- <textarea
- value={content}
- onChange={(e) => setContent(e.target.value)}
- placeholder="Content"
- disabled={isMutating}
- />
- <button type="submit" disabled={isMutating || !title.trim() || !content.trim()}>
- {isMutating ? "Creating..." : "Create Post"}
- </button>
- </form>
- );
- }
-
- export { CreatePostForm };
+ const { trigger, isMutating, error, reset } = useSWRMutation(
+ "/api/posts",
+ createPost,
+ );
+ await trigger({ title, content });
```
- **Why good:** trigger function gives control over when mutation fires, isMutating provides loading state, reset clears error state, separate from useSWR keeps read/write concerns separated
+ **Why good:** `trigger()` gives explicit control over when mutation fires, `isMutating` provides loading state, `reset` clears error state, separate from useSWR keeps read/write concerns apart
- ---
+ See [examples/mutations.md](examples/mutations.md) for optimistic updates, cache invalidation, and `populateCache` patterns.
- ### Pattern 7: Optimistic Updates
+ ---
- Update UI immediately while mutation is in progress.
+ ### Pattern 5: Optimistic Updates with Rollback
- #### Implementation
+ Update UI immediately while mutation is in-flight. Rollback on error.
```typescript
- // components/todo-item.tsx
- import useSWR, { useSWRConfig } from "swr";
- import useSWRMutation from "swr/mutation";
-
- interface Todo {
- id: string;
- title: string;
- completed: boolean;
- }
-
- async function toggleTodo(url: string, { arg }: { arg: { completed: boolean } }) {
- const response = await fetch(url, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(arg),
- });
- return response.json();
- }
-
- function TodoItem({ todo }: { todo: Todo }) {
- const { mutate } = useSWRConfig();
-
- const { trigger } = useSWRMutation(
- `/api/todos/${todo.id}`,
- toggleTodo,
- {
- // Optimistic update
- optimisticData: (currentData: Todo) => ({
- ...currentData,
- completed: !currentData.completed,
- }),
-
- // Rollback on error
- rollbackOnError: true,
-
- // Revalidate after mutation
- revalidate: true,
-
- // Also update the list cache
- onSuccess: () => {
- mutate("/api/todos");
- },
- }
- );
-
- return (
- <label>
- <input
- type="checkbox"
- checked={todo.completed}
- onChange={() => trigger({ completed: !todo.completed })}
- />
- <span style={{ textDecoration: todo.completed ? "line-through" : "none" }}>
- {todo.title}
- </span>
- </label>
- );
- }
-
- export { TodoItem };
+ const { trigger } = useSWRMutation(`/api/todos/${todo.id}`, toggleTodo, {
+ optimisticData: (currentData: Todo) => ({
+ ...currentData,
+ completed: !currentData.completed,
+ }),
+ rollbackOnError: true,
+ revalidate: true,
+ });
```
- #### Advanced Optimistic Pattern with List Updates
-
- ```typescript
- // components/todo-list.tsx
- import useSWR, { useSWRConfig } from "swr";
- import useSWRMutation from "swr/mutation";
+ **Why good:** `optimisticData` shows instant feedback, `rollbackOnError` ensures consistency on failure, `revalidate: true` syncs with server after success
- interface Todo {
- id: string;
- title: string;
- completed: boolean;
- }
+ See [examples/mutations.md](examples/mutations.md) for list-level optimistic updates and `populateCache` for skipping revalidation.
- async function deleteTodo(url: string) {
- const response = await fetch(url, { method: "DELETE" });
- if (!response.ok) throw new Error("Delete failed");
- return response.json();
- }
+ ---
- function TodoList() {
- const { data: todos, mutate } = useSWR<Todo[]>("/api/todos", fetcher);
+ ### Pattern 6: Null Key for Conditional Fetching
- const handleDelete = async (todoId: string) => {
- // Optimistically remove from list
- const optimisticTodos = todos?.filter((t) => t.id !== todoId);
+ Pass `null` as the key to skip the request. Never call hooks conditionally.
- // Update cache optimistically, then revalidate
- await mutate(
- async () => {
- await fetch(`/api/todos/${todoId}`, { method: "DELETE" });
- return optimisticTodos;
- },
- {
- optimisticData: optimisticTodos,
- rollbackOnError: true,
- revalidate: true,
- }
- );
- };
+ ```typescript
+ // BAD: Conditional hook call (breaks Rules of Hooks)
+ if (!userId) return <SelectUser />;
+ const { data } = useSWR(`/api/users/${userId}`, fetcher);
- return (
- <ul>
- {todos?.map((todo) => (
- <li key={todo.id}>
- {todo.title}
- <button onClick={() => handleDelete(todo.id)}>Delete</button>
- </li>
- ))}
- </ul>
- );
- }
+ // GOOD: Null key prevents request without conditional hook
+ const { data } = useSWR(userId ? `/api/users/${userId}` : null, fetcher);
- export { TodoList };
+ // GOOD: Dependent queries -- second waits for first
+ const { data: user } = useSWR(`/api/users/${userId}`, fetcher);
+ const { data: posts } = useSWR(user ? `/api/users/${user.id}/posts` : null, fetcher);
```
- **Why good:** optimisticData shows immediate feedback, rollbackOnError ensures data consistency on failure, mutate with function enables complex update logic, list cache updated after item mutation
-
- ---
-
- ### Pattern 8: useSWRInfinite for Pagination
+ **Why good:** Hook always called (no Rules of Hooks violation), null key is idiomatic SWR pattern, enables data cascades for dependent queries
- Implement infinite scroll with useSWRInfinite.
+ See [examples/conditional.md](examples/conditional.md) for auth-gated, feature-flag, and complex multi-condition patterns.
- #### Constants
+ ---
- ```typescript
- const PAGE_SIZE = 20;
- const INTERSECTION_THRESHOLD = 0.5;
- ```
+ ### Pattern 7: useSWRInfinite for Pagination
- #### Implementation
+ The `getKey` function receives page index and previous page data. Return `null` to stop.
```typescript
- // components/infinite-post-list.tsx
import useSWRInfinite from "swr/infinite";
- import { useCallback, useRef, useEffect } from "react";
- interface Post {
- id: string;
- title: string;
- excerpt: string;
- }
-
- interface PostsResponse {
- posts: Post[];
- nextCursor: string | null;
- hasMore: boolean;
- }
-
const PAGE_SIZE = 20;
- const INTERSECTION_THRESHOLD = 0.5;
- // Key function - receives page index and previous page data
const getKey = (pageIndex: number, previousPageData: PostsResponse | null) => {
- // Reached the end
- if (previousPageData && !previousPageData.hasMore) return null;
-
- // First page
+ if (previousPageData && !previousPageData.hasMore) return null; // End
if (pageIndex === 0) return `/api/posts?limit=${PAGE_SIZE}`;
-
- // Subsequent pages with cursor
return `/api/posts?limit=${PAGE_SIZE}&cursor=${previousPageData?.nextCursor}`;
};
- function InfinitePostList() {
- const loadMoreRef = useRef<HTMLDivElement>(null);
-
- const {
- data,
- error,
- size,
- setSize,
- isLoading,
- isValidating,
- } = useSWRInfinite<PostsResponse>(getKey, fetcher, {
+ const { data, size, setSize, isLoading } = useSWRInfinite<PostsResponse>(
+ getKey,
+ fetcher,
+ {
revalidateFirstPage: false,
- revalidateOnFocus: false,
- });
-
- // Flatten pages into single array
- const posts = data?.flatMap((page) => page.posts) ?? [];
- const isEmpty = data?.[0]?.posts.length === 0;
- const isReachingEnd = data?.[data.length - 1]?.hasMore === false;
- const isLoadingMore = isLoading || (size > 0 && data && typeof data[size - 1] === "undefined");
-
- // Intersection Observer for infinite scroll
- const loadMore = useCallback(() => {
- if (!isReachingEnd && !isLoadingMore) {
- setSize(size + 1);
- }
- }, [isReachingEnd, isLoadingMore, setSize, size]);
-
- useEffect(() => {
- const observer = new IntersectionObserver(
- (entries) => {
- if (entries[0].isIntersecting) {
- loadMore();
- }
- },
- { threshold: INTERSECTION_THRESHOLD }
- );
-
- const currentRef = loadMoreRef.current;
- if (currentRef) observer.observe(currentRef);
-
- return () => {
- if (currentRef) observer.unobserve(currentRef);
- };
- }, [loadMore]);
-
- if (isLoading) return <PostListSkeleton count={PAGE_SIZE} />;
- if (error) return <ErrorCard message={error.message} />;
- if (isEmpty) return <EmptyState message="No posts found" />;
-
- return (
- <div className="post-list">
- <ul>
- {posts.map((post) => (
- <li key={post.id}>
- <article>
- <h3>{post.title}</h3>
- <p>{post.excerpt}</p>
- </article>
- </li>
- ))}
- </ul>
-
- <div ref={loadMoreRef} className="load-more-sentinel">
- {isLoadingMore && <Spinner />}
- {isReachingEnd && posts.length > 0 && <p>No more posts</p>}
- </div>
- </div>
- );
- }
+ },
+ );
- export { InfinitePostList };
+ const posts = data?.flatMap((page) => page.posts) ?? [];
+ const isReachingEnd = data?.[data.length - 1]?.hasMore === false;
```
- **Why good:** getKey function handles pagination logic, null return stops fetching, flatMap combines pages, IntersectionObserver enables smooth infinite scroll, proper loading states prevent UI flicker
-
- ---
-
- ### Pattern 9: Conditional Fetching
-
- Control when requests are made using null key or conditional logic.
-
- #### Null Key Pattern
-
- ```typescript
- // components/conditional-data.tsx
- import useSWR from "swr";
-
- // Pattern 1: Null key prevents request
- function UserProfile({ userId }: { userId: string | null }) {
- // Won't fetch if userId is null
- const { data, isLoading } = useSWR(
- userId ? `/api/users/${userId}` : null,
- fetcher
- );
-
- if (!userId) return <p>Please select a user</p>;
- if (isLoading) return <Skeleton />;
-
- return <Profile user={data} />;
- }
-
- // Pattern 2: Dependent queries
- function UserPosts({ userId }: { userId: string }) {
- // First query
- const { data: user } = useSWR(`/api/users/${userId}`, fetcher);
-
- // Dependent query - only runs when user data exists
- const { data: posts } = useSWR(
- user ? `/api/users/${user.id}/posts` : null,
- fetcher
- );
-
- return (
- <div>
- <h1>{user?.name}</h1>
- <PostList posts={posts} />
- </div>
- );
- }
-
- // Pattern 3: Conditional based on state
- function SearchResults() {
- const [searchTerm, setSearchTerm] = useState("");
- const MIN_SEARCH_LENGTH = 3;
-
- // Only search when term is long enough
- const { data, isLoading } = useSWR(
- searchTerm.length >= MIN_SEARCH_LENGTH
- ? `/api/search?q=${encodeURIComponent(searchTerm)}`
- : null,
- fetcher,
- {
- // Don't keep stale search results
- keepPreviousData: false,
- }
- );
-
- return (
- <div>
- <input
- value={searchTerm}
- onChange={(e) => setSearchTerm(e.target.value)}
- placeholder="Search..."
- />
- {isLoading && <Spinner />}
- {data && <Results items={data} />}
- </div>
- );
- }
-
- export { UserProfile, UserPosts, SearchResults };
- ```
+ **Why good:** `getKey` returning null stops fetching, `flatMap` flattens pages, `revalidateFirstPage: false` prevents refetching all pages on focus
- **Why good:** Null key is the idiomatic SWR pattern for conditional fetching, dependent queries enable data cascades, keepPreviousData: false prevents showing stale search results
+ See [examples/pagination.md](examples/pagination.md) for IntersectionObserver infinite scroll, offset pagination, and filtered pagination with reset.
---
- ### Pattern 10: TypeScript Patterns
-
- Proper typing for SWR hooks.
+ ### Pattern 8: Revalidation Strategies
- #### Implementation
+ Choose strategy based on data freshness requirements.
```typescript
- // types/api.ts
- interface User {
- id: string;
- name: string;
- email: string;
- }
-
- interface Post {
- id: string;
- title: string;
- content: string;
- authorId: string;
- }
-
- interface ApiError {
- message: string;
- status: number;
- }
-
- // Typed hook wrapper
- import useSWR from "swr";
- import type { SWRConfiguration, Key, Fetcher } from "swr";
-
- function useTypedSWR<T>(
- key: Key,
- options?: SWRConfiguration<T, ApiError>
- ) {
- return useSWR<T, ApiError>(key, fetcher, options);
- }
-
- // Usage in component
- function UserCard({ userId }: { userId: string }) {
- const { data, error } = useTypedSWR<User>(`/api/users/${userId}`);
+ const POLL_INTERVAL_MS = 10 * 1000;
- if (error) {
- // error is typed as ApiError
- if (error.status === 404) return <NotFound />;
- return <Error message={error.message} />;
- }
+ // Real-time: polling
+ useSWR(key, fetcher, {
+ refreshInterval: POLL_INTERVAL_MS,
+ refreshWhenHidden: false,
+ });
- // data is typed as User | undefined
- return <Card name={data?.name} email={data?.email} />;
- }
+ // Default: revalidate on focus/reconnect (enabled by default)
+ useSWR(key, fetcher, { revalidateOnFocus: true, revalidateOnReconnect: true });
- // Generic fetcher with type inference
- async function typedFetcher<T>(url: string): Promise<T> {
- const response = await fetch(url);
- if (!response.ok) {
- const error: ApiError = {
- message: "Fetch failed",
- status: response.status,
- };
- throw error;
- }
- return response.json() as Promise<T>;
- }
+ // Static: disable all revalidation
+ useSWR(key, fetcher, {
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ revalidateIfStale: false,
+ });
- export { useTypedSWR, typedFetcher };
- export type { User, Post, ApiError };
+ // Shorthand for static: useSWRImmutable
+ import useSWRImmutable from "swr/immutable";
+ useSWRImmutable(key, fetcher);
```
- **Why good:** Generic types flow through to components, error typing enables type-safe error handling, wrapper hooks reduce boilerplate, type inference works with conditional data
+ **Why good:** Different strategies for different freshness needs, `useSWRImmutable` is cleaner than disabling all options manually, `refreshWhenHidden: false` prevents polling when tab is hidden
+ See [examples/caching.md](examples/caching.md) for prefetching with `preload()`, cache persistence with localStorage, and deduplication.
+
</patterns>
---
- <integration>
+ <red_flags>
- ## Integration Guide
+ ## RED FLAGS
- **Works with:**
+ **High Priority Issues:**
- - **Next.js**: Built by Vercel, seamless integration with App Router and Pages Router, supports SSR/SSG fallback
- - **React**: Client-side data fetching with hooks
- - **axios**: Can use axios as fetcher for interceptors and defaults
- - **TypeScript**: Full type inference for data and errors
+ - **Unstable key causing infinite requests** -- Object/array keys create new references each render. Use string keys or stable arrays of primitives.
+ - **isValidating used as loading state** -- Shows spinner during background refresh, hiding cached data. Use `isLoading` for initial load only.
+ - **useSWR for mutations** -- `useSWR` fires on mount. Use `useSWRMutation` for POST/PUT/DELETE.
+ - **Fetcher doesn't throw on error** -- Non-throwing fetcher returns error body as `data`, error state never triggers.
+ - **Conditional hook call** -- `if (!userId) return; const { data } = useSWR(...)` breaks Rules of Hooks. Use null key pattern.
- **Replaces / Conflicts with:**
+ **Medium Priority Issues:**
- - **React Query**: Both are data fetching libraries - choose one. SWR is simpler, React Query has more features
- - **Apollo Client (for REST)**: SWR is for REST/custom APIs, Apollo is for GraphQL
- - **Custom fetch hooks**: SWR provides caching and deduplication that custom hooks typically lack
+ - **Missing `rollbackOnError` with `optimisticData`** -- Without rollback, failed mutations leave stale optimistic data in cache.
+ - **`keepPreviousData: true` for search** -- Shows stale search results for a different query. Set to `false` for search.
+ - **`revalidateAll: true` with useSWRInfinite** -- Refetches all loaded pages on every focus event. Disable for performance.
+ - **Missing error retry configuration** -- Default retry may not be appropriate (retries 404s, retries auth errors).
+ - **Creating fetcher inside component** -- Creates new function reference each render, breaking deduplication.
- </integration>
+ **Gotchas & Edge Cases:**
+ - `null` key stops fetching, but `undefined` key still fetches (gets coerced to string `"undefined"`)
+ - `mutate()` without arguments revalidates the bound key only, but global `mutate()` without a key filter revalidates everything
+ - `refreshInterval: 0` disables polling (same as omitting the option)
+ - `revalidateOnFocus` fires on every tab focus even if data is fresh (use `focusThrottleInterval` to limit)
+ - Multiple `useSWR` with same key share cache and deduplicate requests automatically
+ - `fallback` in `SWRConfig` must match exact key strings -- `/api/users/1` and `/api/users/1/` are different keys
+ - `useSWRInfinite` revalidates all pages by default (set `revalidateAll: false`)
+ - Error objects don't serialize well for cache persistence -- use structured error types
+ - `useSWRImmutable` in v2.4+ properly overrides global `refreshInterval` settings (fixed from earlier versions)
+
+ </red_flags>
+
---
<critical_reminders>
## CRITICAL REMINDERS
- **(You MUST use a stable key - keys should NOT change on every render or you'll trigger infinite requests)**
+ **(You MUST use a stable key -- keys should NOT change on every render or you'll trigger infinite requests)**
- **(You MUST handle isLoading vs isValidating correctly - isLoading is true only on initial fetch with no data)**
+ **(You MUST handle isLoading vs isValidating correctly -- isLoading is true only on initial fetch with no data)**
- **(You MUST wrap mutations in `useSWRMutation` for write operations - NOT useSWR)**
+ **(You MUST wrap mutations in `useSWRMutation` for write operations -- NOT useSWR)**
- **(You MUST use named constants for ALL timeout, retry, and interval values - NO magic numbers)**
+ **(You MUST use named constants for ALL timeout, retry, and interval values -- NO magic numbers)**
- **(You MUST use named exports only - NO default exports)**
+ **(You MUST use named exports only -- NO default exports)**
**Failure to follow these rules will cause infinite request loops, incorrect loading states, and unmaintainable code.**
</critical_reminders>