
Web Data Fetching Swr
- 7 installs
- 19 repo stars
- Updated July 19, 2026
- agents-inc/skills
web-data-fetching-swr is a Claude Code skill that generates SWR stale-while-revalidate data-fetching patterns for React including useSWR, useSWRMutation, and useSWRInfinite.
About
A Claude Code skill for the SWR data-fetching library in React. It implements the stale-while-revalidate strategy: show cached data instantly and revalidate in the background. It covers useSWR, useSWRMutation for writes, useSWRInfinite for pagination, revalidation strategies, and the null-key conditional fetch pattern. A developer uses it in read-heavy React apps that want a lightweight caching layer.
- SWR stale-while-revalidate data fetching for React
- useSWR, useSWRMutation, useSWRInfinite patterns
- isLoading vs isValidating and null-key conditional fetching
Web Data Fetching Swr by the numbers
- 7 all-time installs (skills.sh)
- Ranked #1,761 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 1, 2026 (Skillselion catalog sync)
web-data-fetching-swr capabilities & compatibility
- Capabilities
- data fetching · data caching · revalidation · infinite scroll · optimistic updates
- Use cases
- frontend · api development
What web-data-fetching-swr says it does
SWR implements the stale-while-revalidate caching strategy: show cached data instantly, revalidate in the background.
SWR data fetching patterns - useSWR, useSWRMutation, caching, revalidation, infinite scroll
npx skills add https://github.com/agents-inc/skills --skill web-data-fetching-swrAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 7 |
|---|---|
| repo stars | ★ 19 |
| Last updated | July 19, 2026 |
| Repository | agents-inc/skills ↗ |
What it does
Fetch and revalidate data in read-heavy React apps with SWR hooks.
Who is it for?
Read-heavy React apps wanting a lightweight (~5KB) cache with automatic revalidation
Skip if: Complex mutation workflows or apps needing built-in request cancellation
When should I use this skill?
Adding SWR data fetching, mutations, or infinite scroll to a React app
What you get
SWR hooks with stable keys, correct loading states, and mutation handling
- useSWR fetchers
- useSWRMutation write handlers
- useSWRInfinite pagination
By the numbers
- core is ~5KB gzipped
- 7 resource files (core, mutations, caching, pagination, conditional, error-handling, suspense)
Files
SWR Data Fetching Patterns
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),isLoadingis for initial fetches only (useisValidatingfor background refreshes), and all write operations go throughuseSWRMutation. 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 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 use named constants for ALL timeout, retry, and interval values -- NO magic numbers)
(You MUST use named exports only -- NO default exports)
</critical_requirements>
---
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 (~5KB gzipped)
- Simple caching with automatic revalidation
- Applications where stale-while-revalidate pattern is desired
When NOT to use:
- 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 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:
- examples/core.md -- Fetchers, return values, SWRConfig, key patterns
- examples/mutations.md -- useSWRMutation, optimistic updates, cache invalidation
- examples/caching.md -- Revalidation strategies, prefetching, persistence
- examples/pagination.md -- useSWRInfinite, infinite scroll, offset pagination
- examples/conditional.md -- Dependent queries, auth-gated fetching
- examples/error-handling.md -- Retry config, error boundaries, network detection
- examples/suspense.md -- Suspense integration, SSR fallback patterns
- reference.md -- Decision frameworks, configuration tables
---
<philosophy>
Philosophy
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 mutation scenarios
- Request cancellation requires manual AbortController setup
- Less opinionated about mutations (fewer lifecycle callbacks)
</philosophy>
---
<patterns>
Core Patterns
Pattern 1: Typed Fetcher
The fetcher must throw on non-OK responses. If it doesn't throw, SWR treats error bodies as valid data.
// 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("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 };Why good: Throws on error (required for SWR error state to work), attaches status for conditional handling, typed error enables downstream type narrowing
See examples/core.md for axios, GraphQL, and multi-argument fetcher variants.
---
Pattern 2: isLoading vs isValidating
The most common SWR mistake. isLoading is true only on initial fetch with no data. isValidating is true during any in-flight request.
// State combinations:
// Initial load: { data: undefined, isLoading: true, isValidating: true }
// 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 }// BAD: Using isValidating as loading indicator hides cached data
if (isValidating) return <Spinner />;
// 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 bad: Showing spinner during background revalidation hides perfectly valid cached data, defeating the purpose of stale-while-revalidate
See examples/core.md for full state handling with error + stale data combinations.
---
Pattern 3: SWRConfig Global Defaults
Centralize fetcher, retry, and revalidation settings. Nested SWRConfig overrides parent config.
const ERROR_RETRY_COUNT = 3;
const ERROR_RETRY_INTERVAL_MS = 5000;
const DEDUP_INTERVAL_MS = 2000;
<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>Why good: Eliminates config duplication across components, fallback prop enables SSR data hydration, nested configs allow per-section overrides
See examples/core.md for full provider setup and nested config override patterns.
---
Pattern 4: useSWRMutation for Writes
Never use useSWR for mutations. useSWR fires on mount -- useSWRMutation fires on demand via trigger().
import useSWRMutation from "swr/mutation";
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");
return response.json();
}
const { trigger, isMutating, error, reset } = useSWRMutation(
"/api/posts",
createPost,
);
await trigger({ title, content });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 for optimistic updates, cache invalidation, and populateCache patterns.
---
Pattern 5: Optimistic Updates with Rollback
Update UI immediately while mutation is in-flight. Rollback on error.
const { trigger } = useSWRMutation(`/api/todos/${todo.id}`, toggleTodo, {
optimisticData: (currentData: Todo) => ({
...currentData,
completed: !currentData.completed,
}),
rollbackOnError: true,
revalidate: true,
});Why good: optimisticData shows instant feedback, rollbackOnError ensures consistency on failure, revalidate: true syncs with server after success
See examples/mutations.md for list-level optimistic updates and populateCache for skipping revalidation.
---
Pattern 6: Null Key for Conditional Fetching
Pass null as the key to skip the request. Never call hooks conditionally.
// BAD: Conditional hook call (breaks Rules of Hooks)
if (!userId) return <SelectUser />;
const { data } = useSWR(`/api/users/${userId}`, fetcher);
// GOOD: Null key prevents request without conditional hook
const { data } = useSWR(userId ? `/api/users/${userId}` : null, fetcher);
// 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: Hook always called (no Rules of Hooks violation), null key is idiomatic SWR pattern, enables data cascades for dependent queries
See examples/conditional.md for auth-gated, feature-flag, and complex multi-condition patterns.
---
Pattern 7: useSWRInfinite for Pagination
The getKey function receives page index and previous page data. Return null to stop.
import useSWRInfinite from "swr/infinite";
const PAGE_SIZE = 20;
const getKey = (pageIndex: number, previousPageData: PostsResponse | null) => {
if (previousPageData && !previousPageData.hasMore) return null; // End
if (pageIndex === 0) return `/api/posts?limit=${PAGE_SIZE}`;
return `/api/posts?limit=${PAGE_SIZE}&cursor=${previousPageData?.nextCursor}`;
};
const { data, size, setSize, isLoading } = useSWRInfinite<PostsResponse>(
getKey,
fetcher,
{
revalidateFirstPage: false,
},
);
const posts = data?.flatMap((page) => page.posts) ?? [];
const isReachingEnd = data?.[data.length - 1]?.hasMore === false;Why good: getKey returning null stops fetching, flatMap flattens pages, revalidateFirstPage: false prevents refetching all pages on focus
See examples/pagination.md for IntersectionObserver infinite scroll, offset pagination, and filtered pagination with reset.
---
Pattern 8: Revalidation Strategies
Choose strategy based on data freshness requirements.
const POLL_INTERVAL_MS = 10 * 1000;
// Real-time: polling
useSWR(key, fetcher, {
refreshInterval: POLL_INTERVAL_MS,
refreshWhenHidden: false,
});
// Default: revalidate on focus/reconnect (enabled by default)
useSWR(key, fetcher, { revalidateOnFocus: true, revalidateOnReconnect: true });
// Static: disable all revalidation
useSWR(key, fetcher, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
});
// Shorthand for static: useSWRImmutable
import useSWRImmutable from "swr/immutable";
useSWRImmutable(key, fetcher);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 for prefetching with preload(), cache persistence with localStorage, and deduplication.
</patterns>
---
<red_flags>
RED FLAGS
High Priority Issues:
- 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
isLoadingfor initial load only. - useSWR for mutations --
useSWRfires on mount. UseuseSWRMutationfor 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.
Medium Priority Issues:
- 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
falsefor 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.
Gotchas & Edge Cases:
nullkey stops fetching, butundefinedkey still fetches (gets coerced to string"undefined")mutate()without arguments revalidates the bound key only, but globalmutate()without a key filter revalidates everythingrefreshInterval: 0disables polling (same as omitting the option)revalidateOnFocusfires on every tab focus even if data is fresh (usefocusThrottleIntervalto limit)- Multiple
useSWRwith same key share cache and deduplicate requests automatically fallbackinSWRConfigmust match exact key strings --/api/users/1and/api/users/1/are different keysuseSWRInfiniterevalidates all pages by default (setrevalidateAll: false)- Error objects don't serialize well for cache persistence -- use structured error types
useSWRImmutablein v2.4+ properly overrides globalrefreshIntervalsettings (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 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 use named constants for ALL timeout, retry, and interval values -- NO magic numbers)
(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>
SWR - Caching & Revalidation Examples
Revalidation strategies and cache configuration. See core.md for basic patterns.
---
Revalidation Strategies
Focus Revalidation (Default)
// components/user-dashboard.tsx
import useSWR from "swr";
const FOCUS_THROTTLE_MS = 5000;
function UserDashboard() {
const { data, isValidating } = useSWR("/api/dashboard", fetcher, {
// Revalidate when window regains focus (default: true)
revalidateOnFocus: true,
// Throttle focus revalidation to prevent rapid re-fetches
focusThrottleInterval: FOCUS_THROTTLE_MS,
});
return (
<div>
{isValidating && <RefreshIndicator />}
<DashboardContent data={data} />
</div>
);
}
export { UserDashboard };Reconnect Revalidation
// components/offline-aware-data.tsx
import useSWR from "swr";
function OfflineAwareData() {
const { data, isValidating } = useSWR("/api/data", fetcher, {
// Revalidate when network reconnects (default: true)
revalidateOnReconnect: true,
});
return (
<div>
{isValidating && <span>Syncing...</span>}
<DataView data={data} />
</div>
);
}
export { OfflineAwareData };Polling (Interval Revalidation)
// components/live-data.tsx
import useSWR from "swr";
const POLL_INTERVAL_MS = 10 * 1000;
function LiveStockPrice({ symbol }: { symbol: string }) {
const { data } = useSWR(`/api/stocks/${symbol}`, fetcher, {
// Poll every 10 seconds
refreshInterval: POLL_INTERVAL_MS,
// Don't poll when tab is hidden
refreshWhenHidden: false,
// Don't poll when offline
refreshWhenOffline: false,
});
return (
<div className="stock-price">
<span>{symbol}</span>
<span>${data?.price?.toFixed(2)}</span>
</div>
);
}
export { LiveStockPrice };Stale Data Revalidation
// components/cached-data.tsx
import useSWR from "swr";
function CachedData() {
const { data } = useSWR("/api/config", fetcher, {
// Revalidate if data in cache is stale (default: true)
revalidateIfStale: true,
});
return <ConfigView config={data} />;
}
export { CachedData };Why good: Different strategies for different freshness requirements, named constants make intervals clear, throttling prevents excessive requests, hidden/offline options save bandwidth
---
Disable Revalidation (Static Data)
Immutable Data
// components/static-config.tsx
import useSWR from "swr";
function StaticConfig() {
const { data } = useSWR("/api/app-config", fetcher, {
// Disable all automatic revalidation
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
refreshInterval: 0,
});
return <AppConfig config={data} />;
}
export { StaticConfig };Using useSWRImmutable
// components/immutable-data.tsx
import useSWRImmutable from "swr/immutable";
// Shorthand for disabling all revalidation
function ImmutableData({ resourceId }: { resourceId: string }) {
const { data } = useSWRImmutable(`/api/resources/${resourceId}`, fetcher);
// Equivalent to:
// useSWR(key, fetcher, {
// revalidateIfStale: false,
// revalidateOnFocus: false,
// revalidateOnReconnect: false,
// })
return <ResourceView resource={data} />;
}
export { ImmutableData };Why good: useSWRImmutable is cleaner for static data, prevents unnecessary network requests, data fetched once and cached indefinitely
---
Manual Revalidation
Using Bound Mutate
// components/manual-refresh.tsx
import useSWR from "swr";
function ManualRefresh() {
const { data, mutate, isValidating } = useSWR("/api/data", fetcher, {
// Disable automatic revalidation
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshInterval: 0,
});
// Bound mutate - only revalidates this key
const handleRefresh = async () => {
await mutate();
};
return (
<div>
<DataView data={data} />
<button onClick={handleRefresh} disabled={isValidating}>
{isValidating ? "Refreshing..." : "Refresh"}
</button>
</div>
);
}
export { ManualRefresh };Using Global Mutate
// components/global-revalidation.tsx
import useSWR, { useSWRConfig } from "swr";
function GlobalRevalidation() {
const { mutate } = useSWRConfig();
// Revalidate specific key
const refreshUsers = () => mutate("/api/users");
// Revalidate multiple keys matching pattern
const refreshAll = () => mutate(
(key) => typeof key === "string" && key.startsWith("/api/"),
undefined,
{ revalidate: true }
);
// Clear all cache
const clearCache = () => mutate(
() => true,
undefined,
{ revalidate: false }
);
return (
<div>
<button onClick={refreshUsers}>Refresh Users</button>
<button onClick={refreshAll}>Refresh All API Data</button>
<button onClick={clearCache}>Clear Cache</button>
</div>
);
}
export { GlobalRevalidation };Why good: Bound mutate is simpler for single-key revalidation, global mutate enables batch operations, filter function allows pattern-based revalidation
---
Cache Key Strategies
Including Query Parameters
// components/filtered-list.tsx
import useSWR from "swr";
import { useState } from "react";
type Status = "all" | "active" | "archived";
function FilteredList() {
const [status, setStatus] = useState<Status>("all");
const [page, setPage] = useState(1);
// Each unique key combination is cached separately
const { data, isLoading } = useSWR(
`/api/items?status=${status}&page=${page}`,
fetcher
);
return (
<div>
<select value={status} onChange={(e) => setStatus(e.target.value as Status)}>
<option value="all">All</option>
<option value="active">Active</option>
<option value="archived">Archived</option>
</select>
{isLoading ? <Skeleton /> : <ItemList items={data?.items} />}
<Pagination
page={page}
total={data?.totalPages}
onChange={setPage}
/>
</div>
);
}
export { FilteredList };Keep Previous Data
// components/smooth-filter.tsx
import useSWR from "swr";
import { useState } from "react";
function SmoothFilteredList() {
const [filter, setFilter] = useState("");
const { data, isValidating } = useSWR(
`/api/items?filter=${filter}`,
fetcher,
{
// Keep previous data while loading new data
keepPreviousData: true,
}
);
return (
<div>
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter..."
/>
{isValidating && <LoadingOverlay />}
{/* Shows previous results while loading new filter */}
<ItemList items={data?.items} />
</div>
);
}
export { SmoothFilteredList };Why good: Query params in key create separate cache entries, keepPreviousData prevents content flash during filter changes, smooth UX during data transitions
---
Deduplication
Automatic Request Deduplication
// Multiple components using same key share one request
// components/user-avatar.tsx
function UserAvatar() {
const { data } = useSWR("/api/user", fetcher);
return <img src={data?.avatar} alt={data?.name} />;
}
// components/user-name.tsx
function UserName() {
const { data } = useSWR("/api/user", fetcher);
return <span>{data?.name}</span>;
}
// components/user-header.tsx
function UserHeader() {
// Both components request same key, but only ONE fetch is made
return (
<header>
<UserAvatar />
<UserName />
</header>
);
}
export { UserHeader };Deduping Interval Configuration
// providers/swr-config.tsx
import { SWRConfig } from "swr";
const DEDUP_INTERVAL_MS = 2000;
function SWRProvider({ children }) {
return (
<SWRConfig
value={{
// Requests within this window are deduplicated
dedupingInterval: DEDUP_INTERVAL_MS,
}}
>
{children}
</SWRConfig>
);
}
export { SWRProvider };Why good: Automatic deduplication prevents redundant requests, multiple components can use same data without coordination, configurable interval for different use cases
---
Prefetching
Prefetch on Hover
// components/prefetch-link.tsx
import { preload } from "swr";
import { fetcher } from "../lib/fetcher";
function PrefetchLink({ userId, href }: { userId: string; href: string }) {
const handleMouseEnter = () => {
// Prefetch user data on hover
preload(`/api/users/${userId}`, fetcher);
};
return (
<a href={href} onMouseEnter={handleMouseEnter}>
View Profile
</a>
);
}
export { PrefetchLink };Prefetch with preload (SWR 2.0+)
// components/prefetch-list.tsx
import { preload } from "swr";
import { useEffect } from "react";
import { fetcher } from "../lib/fetcher";
function PrefetchList({ userIds }: { userIds: string[] }) {
useEffect(() => {
// Prefetch all user data on mount using official preload API
userIds.forEach((id) => {
preload(`/api/users/${id}`, fetcher);
});
}, [userIds]);
return <UserList userIds={userIds} />;
}
export { PrefetchList };Why good: preload is the official SWR 2.0 prefetch API, hover prefetch makes navigation feel instant, batch prefetch prepares data before it's needed
---
Cache Persistence (localStorage)
Custom Cache Provider
// providers/persistent-swr-provider.tsx
// Mark as client component if using an SSR framework
import { SWRConfig } from "swr";
import type { Cache, State } from "swr";
const CACHE_KEY = "swr-cache";
function localStorageProvider(): Cache<unknown> {
// Initialize from localStorage
const map = new Map<string, State<unknown, unknown>>(
JSON.parse(localStorage.getItem(CACHE_KEY) || "[]")
);
// Save to localStorage before unload
window.addEventListener("beforeunload", () => {
const appCache = JSON.stringify(Array.from(map.entries()));
localStorage.setItem(CACHE_KEY, appCache);
});
return {
get: (key) => map.get(key),
set: (key, value) => {
map.set(key, value);
},
delete: (key) => {
map.delete(key);
},
keys: () => map.keys(),
};
}
function PersistentSWRProvider({ children }: { children: React.ReactNode }) {
return (
<SWRConfig value={{ provider: localStorageProvider }}>
{children}
</SWRConfig>
);
}
export { PersistentSWRProvider };Why good: Custom cache provider enables persistence, data survives page refresh, useful for offline-first applications
---
Anti-Pattern Examples
// BAD: Not using keepPreviousData for filters (content flash)
const { data } = useSWR(`/api/items?filter=${filter}`, fetcher);
// Shows undefined while loading new filter
// BAD: Polling when tab is hidden (wastes bandwidth)
const { data } = useSWR("/api/data", fetcher, {
refreshInterval: 10000,
refreshWhenHidden: true, // Don't do this!
});
// BAD: Different keys for same data (no cache sharing)
// Component A
const { data } = useSWR(`/api/user/${userId}`, fetcher);
// Component B
const { data } = useSWR(`/api/users/${userId}`, fetcher); // Different key!// GOOD: keepPreviousData prevents flash
const { data } = useSWR(`/api/items?filter=${filter}`, fetcher, {
keepPreviousData: true,
});
// GOOD: Stop polling when hidden
const { data } = useSWR("/api/data", fetcher, {
refreshInterval: POLL_INTERVAL_MS,
refreshWhenHidden: false,
});
// GOOD: Consistent keys enable cache sharing
const USERS_KEY = (id: string) => `/api/users/${id}`;
// Both components use same key factory
const { data } = useSWR(USERS_KEY(userId), fetcher);Why bad examples fail: Missing keepPreviousData causes content flash, polling when hidden wastes bandwidth, inconsistent keys prevent cache sharing
SWR - Conditional Fetching Examples
Dependent queries and enabled patterns. See core.md for basic patterns.
---
Null Key Pattern
Basic Conditional Fetch
// components/conditional-user.tsx
import useSWR from "swr";
interface User {
id: string;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: string | null }) {
// Won't fetch when userId is null
const { data, isLoading, error } = useSWR<User>(
userId ? `/api/users/${userId}` : null,
fetcher
);
if (!userId) {
return <p>Please select a user</p>;
}
if (isLoading) {
return <Skeleton />;
}
if (error) {
return <Error message={error.message} />;
}
return (
<div>
<h1>{data?.name}</h1>
<p>{data?.email}</p>
</div>
);
}
export { UserProfile };Why good: Null key is idiomatic SWR pattern, hook is always called (no conditional hook violation), clean loading/error states
---
Dependent Queries
Sequential Data Loading
// components/user-with-posts.tsx
import useSWR from "swr";
interface User {
id: string;
name: string;
}
interface Post {
id: string;
title: string;
userId: string;
}
function UserWithPosts({ userId }: { userId: string }) {
// First query
const { data: user, isLoading: isLoadingUser } = useSWR<User>(
`/api/users/${userId}`,
fetcher
);
// Dependent query - only runs when user is loaded
const { data: posts, isLoading: isLoadingPosts } = useSWR<Post[]>(
user ? `/api/users/${user.id}/posts` : null,
fetcher
);
if (isLoadingUser) {
return <UserSkeleton />;
}
return (
<div>
<h1>{user?.name}</h1>
<section>
<h2>Posts</h2>
{isLoadingPosts ? (
<PostsSkeleton />
) : (
<ul>
{posts?.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)}
</section>
</div>
);
}
export { UserWithPosts };Chained Dependencies
// components/organization-team-members.tsx
import useSWR from "swr";
interface Organization {
id: string;
name: string;
defaultTeamId: string;
}
interface Team {
id: string;
name: string;
}
interface Member {
id: string;
name: string;
role: string;
}
function OrganizationTeamMembers({ orgSlug }: { orgSlug: string }) {
// Step 1: Get organization
const { data: org } = useSWR<Organization>(
`/api/orgs/${orgSlug}`,
fetcher
);
// Step 2: Get default team (depends on org)
const { data: team } = useSWR<Team>(
org ? `/api/teams/${org.defaultTeamId}` : null,
fetcher
);
// Step 3: Get team members (depends on team)
const { data: members, isLoading } = useSWR<Member[]>(
team ? `/api/teams/${team.id}/members` : null,
fetcher
);
if (!org) return <OrgSkeleton />;
if (!team) return <TeamSkeleton />;
if (isLoading) return <MembersSkeleton />;
return (
<div>
<h1>{org.name}</h1>
<h2>{team.name}</h2>
<ul>
{members?.map((member) => (
<li key={member.id}>
{member.name} - {member.role}
</li>
))}
</ul>
</div>
);
}
export { OrganizationTeamMembers };Why good: Each query waits for dependencies, null key prevents premature requests, progressive loading improves perceived performance
---
Conditional Based on State
Search with Minimum Length
// components/search-results.tsx
import useSWR from "swr";
import { useState, useDeferredValue } from "react";
interface SearchResult {
id: string;
title: string;
description: string;
}
const MIN_SEARCH_LENGTH = 3;
const DEBOUNCE_MS = 300;
function SearchResults() {
const [searchTerm, setSearchTerm] = useState("");
// Use deferred value for smoother typing
const deferredSearch = useDeferredValue(searchTerm);
const { data, isLoading, error } = useSWR<SearchResult[]>(
deferredSearch.length >= MIN_SEARCH_LENGTH
? `/api/search?q=${encodeURIComponent(deferredSearch)}`
: null,
fetcher,
{
// Don't keep previous search results
keepPreviousData: false,
}
);
return (
<div>
<input
type="search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={`Search (min ${MIN_SEARCH_LENGTH} characters)...`}
/>
{searchTerm.length > 0 && searchTerm.length < MIN_SEARCH_LENGTH && (
<p className="hint">Enter at least {MIN_SEARCH_LENGTH} characters</p>
)}
{isLoading && <Spinner />}
{error && <Error message={error.message} />}
{data && data.length === 0 && (
<p>No results found for "{deferredSearch}"</p>
)}
{data && data.length > 0 && (
<ul>
{data.map((result) => (
<li key={result.id}>
<h3>{result.title}</h3>
<p>{result.description}</p>
</li>
))}
</ul>
)}
</div>
);
}
export { SearchResults };Toggle-Based Fetch
// components/optional-details.tsx
import useSWR from "swr";
import { useState } from "react";
interface ProductDetails {
specifications: Record<string, string>;
reviews: Array<{ id: string; rating: number; text: string }>;
}
function ProductWithDetails({ productId }: { productId: string }) {
const [showDetails, setShowDetails] = useState(false);
// Only fetch when details are requested
const { data: details, isLoading } = useSWR<ProductDetails>(
showDetails ? `/api/products/${productId}/details` : null,
fetcher
);
return (
<div>
<button onClick={() => setShowDetails(!showDetails)}>
{showDetails ? "Hide Details" : "Show Details"}
</button>
{showDetails && (
<div className="details">
{isLoading ? (
<Spinner />
) : (
<>
<h3>Specifications</h3>
<dl>
{Object.entries(details?.specifications ?? {}).map(([key, value]) => (
<div key={key}>
<dt>{key}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
<h3>Reviews</h3>
<ul>
{details?.reviews.map((review) => (
<li key={review.id}>
{"⭐".repeat(review.rating)} {review.text}
</li>
))}
</ul>
</>
)}
</div>
)}
</div>
);
}
export { ProductWithDetails };Why good: Search waits for minimum input, useDeferredValue improves typing responsiveness, toggle prevents unnecessary initial request
---
Authentication-Based Fetch
Only Fetch When Authenticated
// components/protected-data.tsx
import useSWR from "swr";
import { useAuth } from "../hooks/use-auth";
interface ProtectedData {
sensitiveInfo: string;
}
function ProtectedDataView() {
const { isAuthenticated, isLoading: isAuthLoading } = useAuth();
// Only fetch when authenticated
const { data, isLoading, error } = useSWR<ProtectedData>(
isAuthenticated ? "/api/protected-data" : null,
fetcher
);
if (isAuthLoading) {
return <AuthCheckingSkeleton />;
}
if (!isAuthenticated) {
return <LoginPrompt />;
}
if (isLoading) {
return <DataSkeleton />;
}
if (error) {
if (error.status === 401) {
return <SessionExpired />;
}
return <Error message={error.message} />;
}
return (
<div>
<h1>Protected Data</h1>
<p>{data?.sensitiveInfo}</p>
</div>
);
}
export { ProtectedDataView };Fetch with User ID from Auth
// components/my-profile.tsx
import useSWR from "swr";
import { useAuth } from "../hooks/use-auth";
interface UserProfile {
name: string;
email: string;
avatar: string;
}
function MyProfile() {
const { user } = useAuth();
// Fetch user's own profile when logged in
const { data: profile, isLoading } = useSWR<UserProfile>(
user?.id ? `/api/users/${user.id}/profile` : null,
fetcher
);
if (!user) {
return <p>Please log in to view your profile</p>;
}
if (isLoading) {
return <ProfileSkeleton />;
}
return (
<div className="profile">
<img src={profile?.avatar} alt={profile?.name} />
<h1>{profile?.name}</h1>
<p>{profile?.email}</p>
</div>
);
}
export { MyProfile };Why good: Auth check prevents unauthorized requests, auth context provides user info, clean handling of unauthenticated state
---
Feature Flag Based Fetch
Conditional Feature Data
// components/feature-gated-data.tsx
import useSWR from "swr";
import { useFeatureFlag } from "../hooks/use-feature-flags";
interface BetaFeatureData {
newDashboard: unknown;
}
function FeatureGatedComponent() {
const isBetaEnabled = useFeatureFlag("beta-dashboard");
// Only fetch beta data when feature is enabled
const { data: betaData } = useSWR<BetaFeatureData>(
isBetaEnabled ? "/api/beta/dashboard" : null,
fetcher
);
if (!isBetaEnabled) {
return <LegacyDashboard />;
}
return <BetaDashboard data={betaData} />;
}
export { FeatureGatedComponent };Why good: Feature flags control data loading, no unnecessary requests for disabled features, clean fallback to legacy
---
Multiple Conditions
Complex Conditional Logic
// components/complex-conditional.tsx
import useSWR from "swr";
interface DashboardData {
metrics: unknown;
}
interface Props {
userId: string | null;
isAdmin: boolean;
selectedOrg: string | null;
}
function ComplexConditionalData({ userId, isAdmin, selectedOrg }: Props) {
// Build key based on multiple conditions
const shouldFetch = userId && selectedOrg;
const endpoint = shouldFetch
? isAdmin
? `/api/admin/orgs/${selectedOrg}/dashboard`
: `/api/users/${userId}/orgs/${selectedOrg}/dashboard`
: null;
const { data, isLoading, error } = useSWR<DashboardData>(endpoint, fetcher);
if (!userId) {
return <p>Please log in</p>;
}
if (!selectedOrg) {
return <p>Please select an organization</p>;
}
if (isLoading) {
return <DashboardSkeleton />;
}
if (error) {
return <Error message={error.message} />;
}
return <Dashboard data={data} isAdmin={isAdmin} />;
}
export { ComplexConditionalData };Using Function Key
// components/function-key-conditional.tsx
import useSWR from "swr";
function FunctionKeyConditional({ userId, filter }: { userId: string | null; filter: string }) {
// Function that returns key or null
const { data } = useSWR(
() => {
// Skip if no user
if (!userId) return null;
// Skip if filter is invalid
if (filter.length < 2) return null;
// Return the key
return `/api/users/${userId}/items?filter=${filter}`;
},
fetcher
);
return <ItemList items={data} />;
}
export { FunctionKeyConditional };Why good: Function key enables complex conditional logic, multiple conditions checked cleanly, admin/user paths handled transparently
---
Anti-Pattern Examples
// BAD: Conditional hook call (breaks Rules of Hooks)
function BadConditional({ userId }) {
if (!userId) return <p>No user</p>;
// This hook is called conditionally!
const { data } = useSWR(`/api/users/${userId}`, fetcher);
return <div>{data?.name}</div>;
}
// BAD: Using undefined instead of null (fetches "/api/users/undefined")
function BadUndefined({ userId }) {
const { data } = useSWR(`/api/users/${userId}`, fetcher);
// If userId is undefined, fetches "/api/users/undefined"!
return <div>{data?.name}</div>;
}
// BAD: Checking data before null key (still fetches)
function BadCheck({ userId }) {
const { data } = useSWR(
userId ? `/api/users/${userId}` : null,
fetcher
);
// This check is redundant - null key already handles this
if (!userId) return <p>No user</p>;
return <div>{data?.name}</div>;
}// GOOD: Hook always called, key is conditional
function GoodConditional({ userId }) {
const { data, isLoading } = useSWR(
userId ? `/api/users/${userId}` : null,
fetcher
);
if (!userId) return <p>No user</p>;
if (isLoading) return <Skeleton />;
return <div>{data?.name}</div>;
}
// GOOD: Explicit null check
function GoodNullCheck({ userId }) {
const { data } = useSWR(
userId != null ? `/api/users/${userId}` : null,
fetcher
);
return <div>{data?.name}</div>;
}
// GOOD: Handle all states properly
function GoodAllStates({ userId }) {
const { data, isLoading, error } = useSWR(
userId ? `/api/users/${userId}` : null,
fetcher
);
if (!userId) return <SelectUser />;
if (isLoading) return <Skeleton />;
if (error) return <Error message={error.message} />;
if (!data) return <NotFound />;
return <UserProfile user={data} />;
}Why bad examples fail: Conditional hooks violate React rules, undefined in template literal creates wrong URL, redundant checks add complexity
SWR - Core Examples
Basic usage, fetcher patterns, and return values. See SKILL.md for core concepts.
Extended examples:
- caching.md - Revalidation strategies, immutable data
- mutations.md - mutate, useSWRMutation, optimistic updates
- pagination.md - useSWRInfinite, infinite scroll
- conditional.md - Dependent queries, enabled patterns
- error-handling.md - Retry, error boundaries
- suspense.md - Suspense integration, SSR patterns
---
Basic useSWR Usage
Simple Fetch
// components/user-profile.tsx
import useSWR from "swr";
interface User {
id: string;
name: string;
email: string;
avatar: string;
}
const fetcher = (url: string) => fetch(url).then((res) => res.json());
function UserProfile({ userId }: { userId: string }) {
const { data, error, isLoading } = useSWR<User>(
`/api/users/${userId}`,
fetcher
);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!data) return <div>User not found</div>;
return (
<div>
<img src={data.avatar} alt={data.name} />
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}
export { UserProfile };Why good: Clean separation of loading/error/data states, typed data with generics, named export
---
Fetcher Patterns
Standard Fetch Fetcher
// lib/fetcher.ts
interface FetchError extends Error {
info: unknown;
status: number;
}
const fetcher = async <T>(url: string): Promise<T> => {
const response = await fetch(url, {
credentials: "include",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
const error = new Error("An error occurred") as FetchError;
error.info = await response.json().catch(() => ({}));
error.status = response.status;
throw error;
}
return response.json();
};
export { fetcher };
export type { FetchError };Axios Fetcher
// lib/axios-fetcher.ts
import axios from "axios";
const API_BASE_URL = process.env.API_BASE_URL || "";
const API_TIMEOUT_MS = 10000;
const apiClient = axios.create({
baseURL: API_BASE_URL,
timeout: API_TIMEOUT_MS,
withCredentials: true,
});
// Add request interceptor for auth
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
const axiosFetcher = async <T>(url: string): Promise<T> => {
const response = await apiClient.get<T>(url);
return response.data;
};
export { axiosFetcher, apiClient };Fetcher with Multiple Arguments
// lib/multi-arg-fetcher.ts
// Array keys allow multiple arguments to fetcher
type FetcherArgs = [url: string, options?: RequestInit];
const fetcherWithOptions = async ([url, options]: FetcherArgs) => {
const response = await fetch(url, {
...options,
credentials: "include",
});
if (!response.ok) throw new Error("Fetch failed");
return response.json();
};
// Usage
function PostsByUser({ userId, filter }: { userId: string; filter: string }) {
const { data } = useSWR(
[`/api/users/${userId}/posts`, { method: "GET" }],
fetcherWithOptions
);
return <PostList posts={data} />;
}
export { fetcherWithOptions };POST Fetcher (for complex queries)
// lib/post-fetcher.ts
interface PostFetcherArgs {
url: string;
body: unknown;
}
const postFetcher = async <T>({ url, body }: PostFetcherArgs): Promise<T> => {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) throw new Error("Request failed");
return response.json();
};
// Usage with array key
function SearchResults({ query }: { query: string }) {
const { data } = useSWR(
query ? { url: "/api/search", body: { query } } : null,
postFetcher
);
return <Results items={data} />;
}
export { postFetcher };Why good: Typed fetchers provide full TypeScript support, error info included for debugging, interceptors enable auth headers, array keys support multiple arguments
---
Return Values and States
All Return Values
// Understanding all useSWR return values
import useSWR from "swr";
function DataComponent({ endpoint }: { endpoint: string }) {
const {
data, // T | undefined - The fetched data
error, // Error | undefined - Error object if failed
isLoading, // boolean - Initial load with no data
isValidating, // boolean - Any request in-flight
mutate, // function - Manually revalidate
} = useSWR(endpoint, fetcher);
// State combinations:
// Initial load: data=undefined, isLoading=true, isValidating=true
// 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, isValidating=false
return <div>...</div>;
}
export { DataComponent };Proper State Handling
// components/proper-state-handling.tsx
import useSWR from "swr";
interface Post {
id: string;
title: string;
content: string;
}
function PostView({ postId }: { postId: string }) {
const { data, error, isLoading, isValidating, mutate } = useSWR<Post>(
`/api/posts/${postId}`,
fetcher
);
// 1. Initial loading (no data yet)
if (isLoading) {
return <PostSkeleton />;
}
// 2. Error with no data
if (error && !data) {
return (
<ErrorCard>
<p>Failed to load post: {error.message}</p>
<button onClick={() => mutate()}>Retry</button>
</ErrorCard>
);
}
// 3. Error but has stale data
if (error && data) {
return (
<div>
<WarningBanner>
Data may be outdated. Error: {error.message}
<button onClick={() => mutate()}>Retry</button>
</WarningBanner>
<PostContent post={data} />
</div>
);
}
// 4. No data and no error (shouldn't happen after loading)
if (!data) {
return <NotFound message="Post not found" />;
}
// 5. Success - show data with optional refresh indicator
return (
<article>
{isValidating && (
<RefreshIndicator aria-label="Refreshing..." />
)}
<h1>{data.title}</h1>
<p>{data.content}</p>
<button onClick={() => mutate()}>Refresh</button>
</article>
);
}
export { PostView };Why good: Handles all possible state combinations, shows stale data with error banner, refresh indicator doesn't block content, manual refresh option available
---
Global Configuration
SWRConfig Provider
// providers/swr-provider.tsx
// Mark as client component if using an SSR framework
import { SWRConfig } from "swr";
import type { ReactNode } from "react";
import { fetcher } from "../lib/fetcher";
const REVALIDATE_FOCUS_INTERVAL_MS = 5000;
const ERROR_RETRY_COUNT = 3;
const ERROR_RETRY_INTERVAL_MS = 5000;
const DEDUP_INTERVAL_MS = 2000;
interface SWRProviderProps {
children: ReactNode;
fallback?: Record<string, unknown>;
}
function SWRProvider({ children, fallback = {} }: SWRProviderProps) {
return (
<SWRConfig
value={{
// Default fetcher
fetcher,
// Revalidation behavior
revalidateOnFocus: true,
revalidateOnReconnect: true,
revalidateIfStale: true,
focusThrottleInterval: REVALIDATE_FOCUS_INTERVAL_MS,
// Error handling
shouldRetryOnError: true,
errorRetryCount: ERROR_RETRY_COUNT,
errorRetryInterval: ERROR_RETRY_INTERVAL_MS,
// Request deduplication
dedupingInterval: DEDUP_INTERVAL_MS,
// UX improvements
keepPreviousData: true,
// SSR fallback data
fallback,
// Global error handler
onError: (error, key) => {
// Don't report 404s
if (error.status === 404) return;
console.error(`SWR Error [${key}]:`, error);
// Report to error tracking service
// errorTracker.captureException(error, { extra: { key } });
},
// Global success handler
onSuccess: (data, key) => {
// Optional: track successful fetches
// analytics.track('data_fetched', { key });
},
}}
>
{children}
</SWRConfig>
);
}
export { SWRProvider };Nested Config Override
// components/static-section.tsx
import { SWRConfig } from "swr";
// Override global config for specific section
function StaticSection({ children }: { children: React.ReactNode }) {
return (
<SWRConfig
value={{
// Disable all revalidation for static content
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
refreshInterval: 0,
}}
>
{children}
</SWRConfig>
);
}
export { StaticSection };Why good: Centralized configuration reduces duplication, named constants are self-documenting, global error handler enables centralized logging, nested config allows overrides for specific sections
---
Key Patterns
String Keys
// Simple string key (most common)
const { data } = useSWR("/api/users", fetcher);
// With path parameters
const { data } = useSWR(`/api/users/${userId}`, fetcher);
// With query parameters
const { data } = useSWR(`/api/users?status=${status}&page=${page}`, fetcher);Array Keys
// Array key for multiple arguments
const { data } = useSWR(["/api/users", userId, filter], fetcher);
// The fetcher receives the array as argument
const fetcher = ([url, userId, filter]) => {
return fetch(`${url}/${userId}?filter=${filter}`).then((r) => r.json());
};Object Keys (Not Recommended)
// BAD: Object creates new reference each render = infinite requests
const { data } = useSWR({ url: "/api/users", page: 1 }, fetcher);
// GOOD: Serialize object to string
const { data } = useSWR(
`/api/users?${new URLSearchParams({ page: String(page) })}`,
fetcher,
);Null Key (Skip Request)
// Null key prevents the request
const { data } = useSWR(userId ? `/api/users/${userId}` : null, fetcher);
// Function that returns null
const { data } = useSWR(() => (isReady ? "/api/data" : null), fetcher);Why good: String keys are stable and predictable, array keys allow multiple arguments, null key pattern enables conditional fetching without breaking hook rules
---
Anti-Pattern Examples
// BAD: Unstable key (new object each render)
const { data } = useSWR({ endpoint: "/api/users" }, fetcher);
// BAD: Conditional hook call
if (userId) {
const { data } = useSWR(`/api/users/${userId}`, fetcher);
}
// BAD: Using isValidating as loading state
if (isValidating) return <Spinner />; // Hides cached data!
// BAD: Magic numbers
const { data } = useSWR("/api/data", fetcher, {
refreshInterval: 30000,
errorRetryInterval: 5000,
});
// BAD: Fetcher doesn't throw on error
const fetcher = (url) => fetch(url).then(r => r.json()); // Returns error body as data!// GOOD: Stable string key
const { data } = useSWR(`/api/users`, fetcher);
// GOOD: Null key for conditional fetching
const { data } = useSWR(userId ? `/api/users/${userId}` : null, fetcher);
// GOOD: isLoading for initial, isValidating for refresh indicator
if (isLoading) return <Spinner />;
return (
<div>
{isValidating && <RefreshBadge />}
<Content data={data} />
</div>
);
// GOOD: Named constants
const POLL_INTERVAL_MS = 30 * 1000;
const RETRY_INTERVAL_MS = 5 * 1000;
const { data } = useSWR("/api/data", fetcher, {
refreshInterval: POLL_INTERVAL_MS,
errorRetryInterval: RETRY_INTERVAL_MS,
});
// GOOD: Fetcher throws on error
const fetcher = async (url) => {
const response = await fetch(url);
if (!response.ok) throw new Error("Fetch failed");
return response.json();
};Why bad examples fail: Unstable keys cause infinite requests, conditional hooks violate React rules, wrong loading state hides content, magic numbers are unmaintainable, non-throwing fetcher returns error body as data
SWR - Error Handling Examples
Retry configuration and error boundaries. See core.md for basic patterns.
---
Fetcher Error Handling
Typed Error Fetcher
// lib/fetcher.ts
interface FetchError extends Error {
info: unknown;
status: number;
}
const fetcher = async <T>(url: string): Promise<T> => {
const response = await fetch(url, {
credentials: "include",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
const error = new Error("An error occurred") as FetchError;
// Try to parse error body
try {
error.info = await response.json();
} catch {
error.info = { message: response.statusText };
}
error.status = response.status;
throw error;
}
return response.json();
};
export { fetcher };
export type { FetchError };Why good: Error includes status for conditional handling, info captures server error response, typed error enables TypeScript inference
---
Error Retry Configuration
Global Retry Settings
// providers/swr-provider.tsx
import { SWRConfig } from "swr";
const ERROR_RETRY_COUNT = 3;
const ERROR_RETRY_INTERVAL_MS = 5000;
const MAX_RETRY_INTERVAL_MS = 30000;
function SWRProvider({ children }: { children: React.ReactNode }) {
return (
<SWRConfig
value={{
// Enable error retry
shouldRetryOnError: true,
// Maximum retry attempts
errorRetryCount: ERROR_RETRY_COUNT,
// Fixed interval between retries
errorRetryInterval: ERROR_RETRY_INTERVAL_MS,
// Custom retry logic with exponential backoff
onErrorRetry: (error, key, config, revalidate, { retryCount }) => {
// Never retry on 404
if (error.status === 404) return;
// Never retry on 401/403 (auth errors)
if (error.status === 401 || error.status === 403) return;
// Only retry up to max count
if (retryCount >= ERROR_RETRY_COUNT) return;
// Exponential backoff with max
const delay = Math.min(
ERROR_RETRY_INTERVAL_MS * Math.pow(2, retryCount),
MAX_RETRY_INTERVAL_MS
);
setTimeout(() => revalidate({ retryCount }), delay);
},
}}
>
{children}
</SWRConfig>
);
}
export { SWRProvider };Per-Query Retry Override
// components/critical-data.tsx
import useSWR from "swr";
const CRITICAL_RETRY_COUNT = 5;
const CRITICAL_RETRY_INTERVAL_MS = 2000;
function CriticalData() {
const { data, error, isLoading } = useSWR("/api/critical-data", fetcher, {
// More aggressive retry for critical data
errorRetryCount: CRITICAL_RETRY_COUNT,
errorRetryInterval: CRITICAL_RETRY_INTERVAL_MS,
// Custom retry logic
onErrorRetry: (error, key, config, revalidate, { retryCount }) => {
// Always retry critical data (except auth errors)
if (error.status === 401) return;
if (retryCount >= CRITICAL_RETRY_COUNT) return;
setTimeout(() => revalidate({ retryCount }), CRITICAL_RETRY_INTERVAL_MS);
},
});
if (error && !data) {
return (
<CriticalErrorBanner>
<p>Failed to load critical data after {CRITICAL_RETRY_COUNT} attempts</p>
<button onClick={() => mutate()}>Retry Now</button>
</CriticalErrorBanner>
);
}
return <DataView data={data} />;
}
export { CriticalData };Why good: Named constants for retry config, custom onErrorRetry enables exponential backoff, different retry strategies for different data importance
---
Component-Level Error Handling
Error States in Component
// components/user-data.tsx
import useSWR from "swr";
import type { FetchError } from "../lib/fetcher";
interface User {
id: string;
name: string;
}
function UserData({ userId }: { userId: string }) {
const { data, error, isLoading, mutate } = useSWR<User, FetchError>(
`/api/users/${userId}`,
fetcher
);
// Loading state
if (isLoading) {
return <Skeleton />;
}
// Error handling by status code
if (error) {
switch (error.status) {
case 401:
return (
<ErrorCard variant="warning">
<p>Your session has expired</p>
<a href="/login">Log in again</a>
</ErrorCard>
);
case 403:
return (
<ErrorCard variant="warning">
<p>You don't have permission to view this user</p>
</ErrorCard>
);
case 404:
return (
<ErrorCard variant="info">
<p>User not found</p>
<a href="/users">View all users</a>
</ErrorCard>
);
case 429:
return (
<ErrorCard variant="warning">
<p>Too many requests. Please wait a moment.</p>
<button onClick={() => mutate()}>Retry</button>
</ErrorCard>
);
default:
return (
<ErrorCard variant="error">
<p>Failed to load user: {error.message}</p>
<button onClick={() => mutate()}>Retry</button>
</ErrorCard>
);
}
}
// No data after loading (shouldn't happen, but handle it)
if (!data) {
return <p>No data available</p>;
}
// Success
return (
<div>
<h1>{data.name}</h1>
</div>
);
}
export { UserData };Stale Data with Error
// components/stale-with-error.tsx
import useSWR from "swr";
function StaleWithError({ endpoint }: { endpoint: string }) {
const { data, error, isValidating, mutate } = useSWR(endpoint, fetcher);
// Show stale data with error banner
if (error && data) {
return (
<div>
<WarningBanner>
<p>Showing cached data. Latest update failed: {error.message}</p>
<button onClick={() => mutate()} disabled={isValidating}>
{isValidating ? "Retrying..." : "Retry"}
</button>
</WarningBanner>
<DataView data={data} />
</div>
);
}
// Error with no data
if (error && !data) {
return (
<ErrorBanner>
<p>Failed to load: {error.message}</p>
<button onClick={() => mutate()}>Retry</button>
</ErrorBanner>
);
}
// Success
return <DataView data={data} />;
}
export { StaleWithError };Why good: Status-specific error handling, stale data shown with warning instead of hiding, manual retry available
---
Global Error Handler
Centralized Error Logging
// providers/swr-provider.tsx
import { SWRConfig } from "swr";
function SWRProvider({ children }: { children: React.ReactNode }) {
return (
<SWRConfig
value={{
onError: (error, key) => {
// Don't log expected errors
if (error.status === 404) return;
if (error.status === 401) {
// Redirect to login
window.location.href = "/login";
return;
}
// Log to console in development
console.error(`SWR Error [${key}]:`, error);
// Report to error tracking service
if (process.env.NODE_ENV === "production") {
errorTracker.captureException(error, {
extra: {
swrKey: key,
status: error.status,
info: error.info,
},
});
}
},
}}
>
{children}
</SWRConfig>
);
}
export { SWRProvider };Why good: Centralized error logging, auth errors handled globally, production error tracking integration
---
Error Boundaries Integration
React Error Boundary with SWR
// components/error-boundary.tsx
import { Component, type ReactNode } from "react";
interface Props {
children: ReactNode;
fallback: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("Error boundary caught:", error, errorInfo);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
export { ErrorBoundary };SWR with Error Boundary
// components/data-with-boundary.tsx
import useSWR from "swr";
import { ErrorBoundary } from "./error-boundary";
function DataLoader() {
const { data, error } = useSWR("/api/data", fetcher, {
// Throw errors to be caught by boundary
suspense: true,
});
// With suspense: true, errors are thrown
return <DataView data={data} />;
}
function DataWithBoundary() {
return (
<ErrorBoundary
fallback={
<ErrorCard>
<p>Something went wrong loading data</p>
<button onClick={() => window.location.reload()}>
Reload Page
</button>
</ErrorCard>
}
>
<DataLoader />
</ErrorBoundary>
);
}
export { DataWithBoundary };Why good: Error boundary catches thrown errors, suspense mode enables error boundary integration, clean fallback UI
---
Disable Retry Patterns
Never Retry Certain Errors
// providers/smart-retry-provider.tsx
import { SWRConfig } from "swr";
const NON_RETRYABLE_STATUS_CODES = [400, 401, 403, 404, 422];
const DEFAULT_RETRY_COUNT = 3;
const RETRY_DELAY_MS = 5000;
function SmartRetryProvider({ children }: { children: React.ReactNode }) {
return (
<SWRConfig
value={{
onErrorRetry: (error, key, config, revalidate, { retryCount }) => {
// Never retry client errors
if (NON_RETRYABLE_STATUS_CODES.includes(error.status)) {
return;
}
// Limit retries
if (retryCount >= DEFAULT_RETRY_COUNT) {
return;
}
// Retry server errors
if (error.status >= 500) {
setTimeout(() => revalidate({ retryCount }), RETRY_DELAY_MS);
}
},
}}
>
{children}
</SWRConfig>
);
}
export { SmartRetryProvider };Disable All Retry
// components/no-retry-data.tsx
import useSWR from "swr";
function NoRetryData() {
const { data, error } = useSWR("/api/one-shot", fetcher, {
shouldRetryOnError: false,
errorRetryCount: 0,
});
return <DataView data={data} error={error} />;
}
export { NoRetryData };Why good: Non-retryable errors identified by status code, server errors (5xx) retried, client errors (4xx) not retried
---
Network Error Detection
Online/Offline Handling
// components/network-aware.tsx
import useSWR from "swr";
import { useState, useEffect } from "react";
function NetworkAwareData() {
const [isOnline, setIsOnline] = useState(
typeof navigator !== "undefined" ? navigator.onLine : true
);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);
const { data, error, mutate } = useSWR("/api/data", fetcher, {
// Don't revalidate when offline
revalidateOnFocus: isOnline,
revalidateOnReconnect: true,
});
if (!isOnline && !data) {
return (
<OfflineBanner>
<p>You're offline. Data will load when connection is restored.</p>
</OfflineBanner>
);
}
if (!isOnline && data) {
return (
<div>
<OfflineBanner>
<p>You're offline. Showing cached data.</p>
</OfflineBanner>
<DataView data={data} />
</div>
);
}
if (error) {
return (
<ErrorBanner>
<p>Failed to load: {error.message}</p>
<button onClick={() => mutate()}>Retry</button>
</ErrorBanner>
);
}
return <DataView data={data} />;
}
export { NetworkAwareData };Why good: Network status detection, cached data shown when offline, revalidation disabled when offline to prevent unnecessary errors
---
Anti-Pattern Examples
// BAD: Fetcher doesn't throw on error
const fetcher = (url) => fetch(url).then(r => r.json());
// Returns error response body as "data" instead of triggering error state
// BAD: Not handling specific error codes
if (error) {
return <p>Something went wrong</p>; // No specific handling for 401, 404, etc.
}
// BAD: Hiding stale data on error
if (error) {
return <Error />; // Hides potentially useful cached data
}
// BAD: Magic numbers for retry
errorRetryCount: 3,
errorRetryInterval: 5000,// GOOD: Fetcher throws on error
const fetcher = async (url) => {
const res = await fetch(url);
if (!res.ok) {
const error = new Error('Fetch failed');
error.status = res.status;
throw error;
}
return res.json();
};
// GOOD: Handle specific error codes
if (error) {
if (error.status === 404) return <NotFound />;
if (error.status === 401) return <LoginPrompt />;
return <GenericError message={error.message} />;
}
// GOOD: Show stale data with error banner
if (error && data) {
return (
<>
<WarningBanner>Update failed</WarningBanner>
<DataView data={data} />
</>
);
}
// GOOD: Named constants
const ERROR_RETRY_COUNT = 3;
const ERROR_RETRY_INTERVAL_MS = 5000;
errorRetryCount: ERROR_RETRY_COUNT,
errorRetryInterval: ERROR_RETRY_INTERVAL_MS,Why bad examples fail: Non-throwing fetcher returns errors as data, generic error messages confuse users, hiding stale data provides worse UX, magic numbers are unmaintainable
SWR - Mutation Examples
mutate, useSWRMutation, and optimistic updates. See core.md for basic patterns.
---
useSWRMutation Basics
Simple Mutation
// 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> {
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");
}
return response.json();
}
function CreatePostForm() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const { trigger, isMutating, error, reset } = useSWRMutation(
"/api/posts",
createPost
);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
try {
const newPost = await trigger({ title, content });
console.log("Created post:", newPost);
setTitle("");
setContent("");
} catch (err) {
// Error is also available via `error` return value
console.error("Failed:", err);
}
};
return (
<form onSubmit={handleSubmit}>
{error && (
<div className="error">
<span>{error.message}</span>
<button type="button" onClick={reset}>Dismiss</button>
</div>
)}
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={isMutating}
placeholder="Title"
/>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
disabled={isMutating}
placeholder="Content..."
/>
<button type="submit" disabled={isMutating || !title.trim()}>
{isMutating ? "Creating..." : "Create Post"}
</button>
</form>
);
}
export { CreatePostForm };Why good: trigger returns promise for await, isMutating provides loading state, error state for display, reset clears error state
---
Mutation Types
Update (PUT/PATCH)
// components/edit-profile.tsx
import useSWRMutation from "swr/mutation";
interface UpdateUserInput {
name?: string;
email?: string;
avatar?: string;
}
async function updateUser(url: string, { arg }: { arg: UpdateUserInput }) {
const response = await fetch(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(arg),
});
if (!response.ok) throw new Error("Update failed");
return response.json();
}
function EditProfile({ userId }: { userId: string }) {
const { trigger, isMutating } = useSWRMutation(
`/api/users/${userId}`,
updateUser
);
const handleSave = async (data: UpdateUserInput) => {
await trigger(data);
};
return (
<ProfileForm
onSave={handleSave}
isLoading={isMutating}
/>
);
}
export { EditProfile };Delete
// components/delete-post-button.tsx
import useSWRMutation from "swr/mutation";
async function deletePost(url: string) {
const response = await fetch(url, { method: "DELETE" });
if (!response.ok) throw new Error("Delete failed");
return response.json();
}
function DeletePostButton({ postId, onDeleted }: { postId: string; onDeleted?: () => void }) {
const { trigger, isMutating } = useSWRMutation(
`/api/posts/${postId}`,
deletePost,
{
onSuccess: () => {
onDeleted?.();
},
}
);
const handleDelete = async () => {
if (window.confirm("Are you sure you want to delete this post?")) {
await trigger();
}
};
return (
<button onClick={handleDelete} disabled={isMutating}>
{isMutating ? "Deleting..." : "Delete"}
</button>
);
}
export { DeletePostButton };Why good: Separate mutation functions for different HTTP methods, onSuccess callback for side effects, confirmation before destructive action
---
Optimistic Updates
Basic Optimistic Update
// components/like-button.tsx
import useSWRMutation from "swr/mutation";
import useSWR from "swr";
interface Post {
id: string;
title: string;
likes: number;
likedByMe: boolean;
}
async function toggleLike(url: string) {
const response = await fetch(url, { method: "POST" });
if (!response.ok) throw new Error("Like failed");
return response.json();
}
function LikeButton({ postId }: { postId: string }) {
const { data: post } = useSWR<Post>(`/api/posts/${postId}`, fetcher);
const { trigger, isMutating } = useSWRMutation(
`/api/posts/${postId}/like`,
toggleLike,
{
// Optimistic update
optimisticData: (currentData: Post) => ({
...currentData,
likes: currentData.likedByMe ? currentData.likes - 1 : currentData.likes + 1,
likedByMe: !currentData.likedByMe,
}),
// Rollback on error
rollbackOnError: true,
// Revalidate after success
revalidate: true,
}
);
if (!post) return null;
return (
<button
onClick={() => trigger()}
disabled={isMutating}
className={post.likedByMe ? "liked" : ""}
>
{post.likedByMe ? "Unlike" : "Like"} ({post.likes})
</button>
);
}
export { LikeButton };Optimistic Update with List
// components/todo-list.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,
{
optimisticData: () => ({
...todo,
completed: !todo.completed,
}),
rollbackOnError: true,
// Also update the list cache
onSuccess: () => {
mutate("/api/todos");
},
}
);
return (
<label className="todo-item">
<input
type="checkbox"
checked={todo.completed}
onChange={() => trigger({ completed: !todo.completed })}
/>
<span className={todo.completed ? "completed" : ""}>
{todo.title}
</span>
</label>
);
}
export { TodoItem };Why good: optimisticData shows immediate feedback, rollbackOnError ensures data consistency, list cache updated after item mutation
---
Cache Updates After Mutation
Invalidate Related Data
// components/create-comment.tsx
import useSWRMutation from "swr/mutation";
import { useSWRConfig } from "swr";
async function createComment(url: string, { arg }: { arg: { content: string } }) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(arg),
});
return response.json();
}
function CreateComment({ postId }: { postId: string }) {
const { mutate } = useSWRConfig();
const [content, setContent] = useState("");
const { trigger, isMutating } = useSWRMutation(
`/api/posts/${postId}/comments`,
createComment,
{
onSuccess: () => {
// Invalidate comments list
mutate(`/api/posts/${postId}/comments`);
// Invalidate post (comment count may have changed)
mutate(`/api/posts/${postId}`);
// Clear form
setContent("");
},
}
);
return (
<form onSubmit={(e) => { e.preventDefault(); trigger({ content }); }}>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
disabled={isMutating}
/>
<button type="submit" disabled={isMutating || !content.trim()}>
{isMutating ? "Posting..." : "Post Comment"}
</button>
</form>
);
}
export { CreateComment };Direct Cache Update (Without Refetch)
// components/update-user-name.tsx
import useSWRMutation from "swr/mutation";
import { useSWRConfig } from "swr";
interface User {
id: string;
name: string;
email: string;
}
async function updateUser(url: string, { arg }: { arg: { name: string } }) {
const response = await fetch(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(arg),
});
return response.json();
}
function UpdateUserName({ userId }: { userId: string }) {
const { mutate } = useSWRConfig();
const { trigger } = useSWRMutation(
`/api/users/${userId}`,
updateUser,
{
// populateCache updates cache with mutation response
populateCache: (result: User, currentData: User) => ({
...currentData,
...result,
}),
// Don't revalidate since we're updating cache directly
revalidate: false,
}
);
return (
<button onClick={() => trigger({ name: "New Name" })}>
Update Name
</button>
);
}
export { UpdateUserName };Why good: onSuccess allows invalidating related queries, populateCache updates cache without refetch, revalidate: false prevents unnecessary network request
---
Using Bound mutate
mutate for Revalidation
// components/refresh-data.tsx
import useSWR from "swr";
function RefreshableData() {
const { data, mutate, isValidating } = useSWR("/api/data", fetcher);
// Simple revalidation
const refresh = () => mutate();
return (
<div>
<DataView data={data} />
<button onClick={refresh} disabled={isValidating}>
{isValidating ? "Refreshing..." : "Refresh"}
</button>
</div>
);
}
export { RefreshableData };mutate with Data
// components/optimistic-update.tsx
import useSWR from "swr";
interface Counter {
value: number;
}
function OptimisticCounter() {
const { data, mutate } = useSWR<Counter>("/api/counter", fetcher);
const increment = async () => {
// Optimistic update with async function
await mutate(
async (currentData) => {
// Make API call
const response = await fetch("/api/counter/increment", { method: "POST" });
return response.json();
},
{
// Show optimistic value immediately
optimisticData: (current) => ({
value: (current?.value ?? 0) + 1,
}),
// Rollback on error
rollbackOnError: true,
// Revalidate after
revalidate: true,
}
);
};
return (
<div>
<span>Count: {data?.value ?? 0}</span>
<button onClick={increment}>Increment</button>
</div>
);
}
export { OptimisticCounter };Why good: Bound mutate is simpler for single-key operations, async function pattern enables optimistic updates with API call, rollback handles errors gracefully
---
Global mutate Patterns
Invalidate by Pattern
// lib/cache-utils.ts
import { mutate } from "swr";
// Invalidate all user-related queries
const invalidateUserQueries = () => {
mutate(
(key) => typeof key === "string" && key.startsWith("/api/users"),
undefined,
{ revalidate: true },
);
};
// Invalidate specific keys
const invalidateKeys = (keys: string[]) => {
keys.forEach((key) => mutate(key));
};
// Clear entire cache
const clearCache = () => {
mutate(() => true, undefined, { revalidate: false });
};
export { invalidateUserQueries, invalidateKeys, clearCache };After Logout
// hooks/use-logout.ts
import { useSWRConfig } from "swr";
function useLogout() {
const { mutate } = useSWRConfig();
const logout = async () => {
// Call logout API
await fetch("/api/auth/logout", { method: "POST" });
// Clear all cached data
mutate(() => true, undefined, { revalidate: false });
// Redirect to login
window.location.href = "/login";
};
return { logout };
}
export { useLogout };Why good: Pattern-based invalidation enables batch updates, cache clearing on logout prevents data leaks, global mutate handles cross-component cache updates
---
Anti-Pattern Examples
// BAD: Using useSWR for mutations
const { data, mutate } = useSWR("/api/posts", async () => {
return fetch("/api/posts", {
method: "POST",
body: JSON.stringify({ title: "New Post" }),
}).then((r) => r.json());
}); // This fires immediately on mount!
// BAD: Not using optimisticData for responsive UI
const { trigger } = useSWRMutation("/api/like", toggleLike);
// UI waits for server response before updating
// BAD: Missing rollbackOnError
const { trigger } = useSWRMutation("/api/update", updateFn, {
optimisticData: (data) => ({ ...data, updated: true }),
// Missing rollbackOnError: true - data stays wrong on error!
});
// BAD: Invalidating everything
const { mutate } = useSWRConfig();
mutate(); // Invalidates ALL cached data!// GOOD: useSWRMutation for write operations
const { trigger } = useSWRMutation("/api/posts", createPost);
await trigger({ title: "New Post" }); // Fires on demand
// GOOD: Optimistic update for instant feedback
const { trigger } = useSWRMutation("/api/like", toggleLike, {
optimisticData: (data) => ({ ...data, liked: !data.liked }),
rollbackOnError: true,
});
// GOOD: Full optimistic pattern
const { trigger } = useSWRMutation("/api/update", updateFn, {
optimisticData: (data) => ({ ...data, updated: true }),
rollbackOnError: true,
revalidate: true,
});
// GOOD: Invalidate specific keys
const { mutate } = useSWRConfig();
mutate("/api/posts"); // Only invalidates postsWhy bad examples fail: useSWR fires immediately on mount (wrong for mutations), missing optimisticData makes UI feel slow, missing rollback leaves incorrect data on error, global mutate without key invalidates everything
SWR - Pagination Examples
useSWRInfinite and infinite scroll patterns. See core.md for basic patterns.
---
useSWRInfinite Basics
Basic Infinite List
// components/infinite-list.tsx
import useSWRInfinite from "swr/infinite";
interface Post {
id: string;
title: string;
excerpt: string;
}
interface PostsResponse {
posts: Post[];
nextCursor: string | null;
}
const PAGE_SIZE = 20;
// Key generator function
const getKey = (pageIndex: number, previousPageData: PostsResponse | null) => {
// Reached the end
if (previousPageData && !previousPageData.nextCursor) return null;
// First page, no cursor needed
if (pageIndex === 0) return `/api/posts?limit=${PAGE_SIZE}`;
// Add cursor to subsequent requests
return `/api/posts?limit=${PAGE_SIZE}&cursor=${previousPageData?.nextCursor}`;
};
function InfinitePostList() {
const {
data,
error,
size,
setSize,
isLoading,
isValidating,
} = useSWRInfinite<PostsResponse>(getKey, fetcher);
// Flatten all pages into single array
const posts = data?.flatMap((page) => page.posts) ?? [];
// Derived states
const isEmpty = data?.[0]?.posts.length === 0;
const isReachingEnd = data?.[data.length - 1]?.nextCursor === null;
const isLoadingMore = isLoading || (size > 0 && data && typeof data[size - 1] === "undefined");
const loadMore = () => {
if (!isReachingEnd && !isLoadingMore) {
setSize(size + 1);
}
};
if (isLoading) return <PostSkeleton count={PAGE_SIZE} />;
if (error) return <Error message={error.message} />;
if (isEmpty) return <EmptyState message="No posts found" />;
return (
<div>
<ul>
{posts.map((post) => (
<li key={post.id}>
<h3>{post.title}</h3>
<p>{post.excerpt}</p>
</li>
))}
</ul>
{!isReachingEnd && (
<button onClick={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? "Loading..." : "Load More"}
</button>
)}
</div>
);
}
export { InfinitePostList };Why good: getKey function controls pagination logic, null return stops fetching, flatMap combines pages, derived states prevent unnecessary renders
---
Infinite Scroll with Intersection Observer
Automatic Load on Scroll
// components/infinite-scroll-list.tsx
import useSWRInfinite from "swr/infinite";
import { useCallback, useRef, useEffect } from "react";
interface Item {
id: string;
name: string;
}
interface ItemsResponse {
items: Item[];
hasMore: boolean;
}
const PAGE_SIZE = 20;
const INTERSECTION_THRESHOLD = 0.5;
const ROOT_MARGIN = "100px";
const getKey = (pageIndex: number, previousPageData: ItemsResponse | null) => {
if (previousPageData && !previousPageData.hasMore) return null;
return `/api/items?page=${pageIndex + 1}&limit=${PAGE_SIZE}`;
};
function InfiniteScrollList() {
const loadMoreRef = useRef<HTMLDivElement>(null);
const {
data,
size,
setSize,
isLoading,
isValidating,
} = useSWRInfinite<ItemsResponse>(getKey, fetcher, {
// Don't revalidate all pages on focus
revalidateFirstPage: false,
});
const items = data?.flatMap((page) => page.items) ?? [];
const isReachingEnd = data?.[data.length - 1]?.hasMore === false;
const isLoadingMore = !isLoading && isValidating;
const loadMore = useCallback(() => {
if (!isReachingEnd && !isValidating) {
setSize(size + 1);
}
}, [isReachingEnd, isValidating, setSize, size]);
// Intersection Observer for infinite scroll
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
},
{
threshold: INTERSECTION_THRESHOLD,
rootMargin: ROOT_MARGIN,
}
);
const currentRef = loadMoreRef.current;
if (currentRef) observer.observe(currentRef);
return () => {
if (currentRef) observer.unobserve(currentRef);
};
}, [loadMore]);
if (isLoading) return <ItemSkeleton count={PAGE_SIZE} />;
return (
<div className="infinite-scroll-container">
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
{/* Sentinel element - triggers load when visible */}
<div ref={loadMoreRef} className="load-more-sentinel">
{isLoadingMore && <Spinner />}
{isReachingEnd && items.length > 0 && (
<p className="end-message">No more items</p>
)}
</div>
</div>
);
}
export { InfiniteScrollList };Why good: IntersectionObserver triggers load automatically, rootMargin starts loading before user reaches bottom, revalidateFirstPage: false prevents unnecessary refetches
---
Offset-Based Pagination
Traditional Page Numbers
// components/paginated-list.tsx
import useSWR from "swr";
import { useState } from "react";
interface PaginatedResponse<T> {
items: T[];
totalPages: number;
currentPage: number;
totalItems: number;
}
interface User {
id: string;
name: string;
email: string;
}
const PAGE_SIZE = 10;
function PaginatedUserList() {
const [page, setPage] = useState(1);
const { data, isLoading, error } = useSWR<PaginatedResponse<User>>(
`/api/users?page=${page}&limit=${PAGE_SIZE}`,
fetcher,
{
// Keep previous data while loading new page
keepPreviousData: true,
}
);
if (isLoading && !data) return <Skeleton />;
if (error) return <Error message={error.message} />;
if (!data?.items.length) return <EmptyState />;
return (
<div>
<ul>
{data.items.map((user) => (
<li key={user.id}>
{user.name} - {user.email}
</li>
))}
</ul>
<Pagination
currentPage={data.currentPage}
totalPages={data.totalPages}
onPageChange={setPage}
/>
<p>
Showing {data.items.length} of {data.totalItems} users
</p>
</div>
);
}
export { PaginatedUserList };Offset Pagination with useSWRInfinite
// components/offset-infinite-list.tsx
import useSWRInfinite from "swr/infinite";
interface PaginatedResponse<T> {
items: T[];
total: number;
}
const PAGE_SIZE = 20;
const getKey = (pageIndex: number) => {
return `/api/items?offset=${pageIndex * PAGE_SIZE}&limit=${PAGE_SIZE}`;
};
function OffsetInfiniteList() {
const { data, size, setSize, isLoading } = useSWRInfinite<PaginatedResponse<Item>>(
getKey,
fetcher
);
const items = data?.flatMap((page) => page.items) ?? [];
const total = data?.[0]?.total ?? 0;
const isReachingEnd = items.length >= total;
return (
<div>
<p>Loaded {items.length} of {total}</p>
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
{!isReachingEnd && (
<button onClick={() => setSize(size + 1)}>
Load More
</button>
)}
</div>
);
}
export { OffsetInfiniteList };Why good: keepPreviousData prevents flash during page changes, offset calculation is simple, total from first page determines end condition
---
Bidirectional Pagination
Load Previous and Next
// components/chat-messages.tsx
import useSWRInfinite from "swr/infinite";
import { useRef, useEffect } from "react";
interface Message {
id: string;
content: string;
timestamp: string;
}
interface MessagesResponse {
messages: Message[];
hasPrevious: boolean;
hasNext: boolean;
cursor: string;
}
const PAGE_SIZE = 50;
function ChatMessages({ channelId }: { channelId: string }) {
const containerRef = useRef<HTMLDivElement>(null);
// Get newer messages (forward)
const getKeyNewer = (pageIndex: number, previousPageData: MessagesResponse | null) => {
if (previousPageData && !previousPageData.hasNext) return null;
if (pageIndex === 0) return `/api/channels/${channelId}/messages?limit=${PAGE_SIZE}`;
return `/api/channels/${channelId}/messages?after=${previousPageData?.cursor}&limit=${PAGE_SIZE}`;
};
const {
data: newerData,
size: newerSize,
setSize: setNewerSize,
} = useSWRInfinite<MessagesResponse>(getKeyNewer, fetcher);
// All messages combined
const messages = newerData?.flatMap((page) => page.messages) ?? [];
const hasMoreNewer = newerData?.[newerData.length - 1]?.hasNext !== false;
// Scroll to bottom on new messages
useEffect(() => {
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [messages.length]);
return (
<div ref={containerRef} className="chat-container">
{hasMoreNewer && (
<button onClick={() => setNewerSize(newerSize + 1)}>
Load older messages
</button>
)}
{messages.map((message) => (
<div key={message.id} className="message">
<span className="timestamp">{message.timestamp}</span>
<p>{message.content}</p>
</div>
))}
</div>
);
}
export { ChatMessages };Why good: Separate infinite queries for different directions, cursor-based prevents duplicates, auto-scroll improves UX
---
Filtered Pagination
Filter with Reset
// components/filtered-paginated-list.tsx
import useSWRInfinite from "swr/infinite";
import { useState, useEffect } from "react";
interface Product {
id: string;
name: string;
category: string;
price: number;
}
interface ProductsResponse {
products: Product[];
hasMore: boolean;
}
const PAGE_SIZE = 20;
function FilteredProductList() {
const [category, setCategory] = useState<string>("");
const [sortBy, setSortBy] = useState<string>("name");
const getKey = (pageIndex: number, previousPageData: ProductsResponse | null) => {
if (previousPageData && !previousPageData.hasMore) return null;
const params = new URLSearchParams({
page: String(pageIndex + 1),
limit: String(PAGE_SIZE),
sortBy,
});
if (category) params.set("category", category);
return `/api/products?${params}`;
};
const {
data,
size,
setSize,
isLoading,
isValidating,
mutate,
} = useSWRInfinite<ProductsResponse>(getKey, fetcher, {
revalidateFirstPage: false,
});
// Reset to page 1 when filters change
useEffect(() => {
setSize(1);
}, [category, sortBy, setSize]);
const products = data?.flatMap((page) => page.products) ?? [];
const isReachingEnd = data?.[data.length - 1]?.hasMore === false;
return (
<div>
<div className="filters">
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<option value="name">Name</option>
<option value="price">Price</option>
<option value="newest">Newest</option>
</select>
</div>
{isLoading ? (
<Skeleton />
) : (
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name} - ${product.price}
</li>
))}
</ul>
)}
{!isReachingEnd && (
<button
onClick={() => setSize(size + 1)}
disabled={isValidating}
>
{isValidating ? "Loading..." : "Load More"}
</button>
)}
</div>
);
}
export { FilteredProductList };Why good: Filter changes reset to page 1, URL params included in key create separate caches, each filter combination cached independently
---
Configuration Options
useSWRInfinite Options
// components/configured-infinite-list.tsx
import useSWRInfinite from "swr/infinite";
function ConfiguredInfiniteList() {
const { data, size, setSize } = useSWRInfinite(getKey, fetcher, {
// Initial number of pages to load
initialSize: 1,
// Don't revalidate all pages, just first page
revalidateAll: false,
// Don't revalidate first page on focus/reconnect
revalidateFirstPage: false,
// Persist size across unmounts
persistSize: false,
// Parallel fetching (careful with rate limits)
parallel: false,
});
return <List data={data} loadMore={() => setSize(size + 1)} />;
}
export { ConfiguredInfiniteList };Why good: revalidateAll: false prevents refetching all pages, revalidateFirstPage: false improves performance, parallel option for faster loading when safe
---
Anti-Pattern Examples
// BAD: Using regular useSWR for pagination (no pagination support)
const [page, setPage] = useState(1);
const { data } = useSWR(`/api/items?page=${page}`, fetcher);
// Can't accumulate pages, loses data when page changes
// BAD: Missing null return in getKey (infinite loop)
const getKey = (pageIndex, prev) => {
return `/api/items?page=${pageIndex}`; // Never stops!
};
// BAD: revalidateAll with many pages (performance killer)
const { data } = useSWRInfinite(getKey, fetcher, {
revalidateAll: true, // Refetches ALL pages on every focus!
});
// BAD: Not using flatMap for combined data
const allItems = data?.map((page) => page.items); // Array of arrays!// GOOD: useSWRInfinite for accumulating pages
const { data, size, setSize } = useSWRInfinite(getKey, fetcher);
const items = data?.flatMap((page) => page.items) ?? [];
// GOOD: Proper end detection in getKey
const getKey = (pageIndex, prev) => {
if (prev && !prev.hasMore) return null; // Stop when done
return `/api/items?page=${pageIndex}`;
};
// GOOD: Disable revalidateAll for performance
const { data } = useSWRInfinite(getKey, fetcher, {
revalidateAll: false,
revalidateFirstPage: false,
});
// GOOD: flatMap combines pages
const allItems = data?.flatMap((page) => page.items) ?? [];Why bad examples fail: Regular useSWR loses data on page change, missing null causes infinite requests, revalidateAll hammers API, map without flat creates nested arrays
SWR - Suspense & SSR Examples
Suspense integration and server-side rendering patterns. See core.md for basic patterns.
---
React Suspense Integration
Basic Suspense Mode
// components/suspense-data.tsx
import useSWR from "swr";
import { Suspense } from "react";
interface User {
id: string;
name: string;
email: string;
}
function UserData({ userId }: { userId: string }) {
// With suspense: true, component suspends until data is ready
const { data } = useSWR<User>(`/api/users/${userId}`, fetcher, {
suspense: true,
});
// No need for loading check -- Suspense handles it
// data is guaranteed to be available here
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}
function UserProfile({ userId }: { userId: string }) {
return (
<Suspense fallback={<UserSkeleton />}>
<UserData userId={userId} />
</Suspense>
);
}
export { UserProfile };Why good: Suspense handles loading state declaratively, data is guaranteed non-null in component, cleaner component code
---
Global Suspense Configuration
Enable Suspense Globally
// providers/suspense-swr-provider.tsx
// Mark as client component if using an SSR framework
import { SWRConfig } from "swr";
import type { ReactNode } from "react";
function SuspenseSWRProvider({ children }: { children: ReactNode }) {
return (
<SWRConfig
value={{
suspense: true,
}}
>
{children}
</SWRConfig>
);
}
export { SuspenseSWRProvider };Opt-Out Per Query
// components/non-suspense-data.tsx
import useSWR from "swr";
// Even with global suspense: true, this query opts out
function NonSuspenseData() {
const { data, isLoading } = useSWR("/api/optional-data", fetcher, {
suspense: false, // Override global setting
});
if (isLoading) return <Skeleton />;
return <DataView data={data} />;
}
export { NonSuspenseData };Why good: Global config reduces repetition, per-query override for special cases, flexibility without duplication
---
Error Boundaries with Suspense
Suspense + Error Boundary Pattern
// components/data-boundary.tsx
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
import useSWR from "swr";
interface User {
id: string;
name: string;
}
function UserDataInner({ userId }: { userId: string }) {
const { data } = useSWR<User>(`/api/users/${userId}`, fetcher, {
suspense: true,
});
return (
<div>
<h1>{data.name}</h1>
</div>
);
}
// Alternative: throwOnError option (SWR 2.0+)
// Throws errors to error boundary WITHOUT suspense mode
function UserDataWithThrow({ userId }: { userId: string }) {
const { data, isLoading } = useSWR<User>(`/api/users/${userId}`, fetcher, {
throwOnError: true, // Throws to error boundary
});
if (isLoading) return <Skeleton />;
return (
<div>
<h1>{data.name}</h1>
</div>
);
}
function UserDataWithBoundary({ userId }: { userId: string }) {
return (
<ErrorBoundary
fallback={
<ErrorCard>
<p>Failed to load user</p>
<button onClick={() => window.location.reload()}>Reload</button>
</ErrorCard>
}
onError={(error) => {
console.error("User data error:", error);
}}
>
<Suspense fallback={<UserSkeleton />}>
<UserDataInner userId={userId} />
</Suspense>
</ErrorBoundary>
);
}
export { UserDataWithBoundary };Nested Suspense Boundaries
// components/dashboard.tsx
import { Suspense } from "react";
import useSWR from "swr";
function UserInfo() {
const { data } = useSWR("/api/user", fetcher, { suspense: true });
return <UserCard user={data} />;
}
function Notifications() {
const { data } = useSWR("/api/notifications", fetcher, { suspense: true });
return <NotificationList items={data} />;
}
function RecentActivity() {
const { data } = useSWR("/api/activity", fetcher, { suspense: true });
return <ActivityFeed items={data} />;
}
function Dashboard() {
return (
<div className="dashboard">
{/* Each section has its own Suspense boundary */}
<Suspense fallback={<UserInfoSkeleton />}>
<UserInfo />
</Suspense>
<div className="dashboard-panels">
<Suspense fallback={<NotificationsSkeleton />}>
<Notifications />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
</div>
);
}
export { Dashboard };Why good: Error boundary catches suspense errors, nested boundaries enable progressive loading, each section loads independently
---
SSR Data Hydration
SWR supports server-side data hydration through fallbackData (per-hook) and SWRConfig fallback (global). This works with any SSR framework.
Per-Hook Fallback
// components/user-profile.tsx
// Mark as client component if using an SSR framework
import useSWR from "swr";
interface User {
id: string;
name: string;
email: string;
}
interface UserProfileProps {
initialData: User;
userId: string;
}
function UserProfile({ initialData, userId }: UserProfileProps) {
// Use initialData from server, revalidate on client
const { data } = useSWR<User>(`/api/users/${userId}`, fetcher, {
fallbackData: initialData,
});
return (
<div>
<h1>{data?.name}</h1>
<p>{data?.email}</p>
</div>
);
}
export { UserProfile };Global Fallback via SWRConfig
// providers/swr-provider.tsx
// Mark as client component if using an SSR framework
import { SWRConfig } from "swr";
import type { ReactNode } from "react";
interface SWRProviderProps {
children: ReactNode;
fallback?: Record<string, unknown>;
}
function SWRProvider({ children, fallback = {} }: SWRProviderProps) {
return (
<SWRConfig
value={{
fallback,
revalidateOnFocus: true,
revalidateOnReconnect: true,
}}
>
{children}
</SWRConfig>
);
}
export { SWRProvider };Why good: Server-fetched data hydrates client (no loading flash), fallbackData is per-hook, fallback in SWRConfig enables multi-key hydration, framework-agnostic approach
---
Preloading Data
See caching.md for preload() prefetch patterns (hover prefetch, batch prefetch on mount).---
Anti-Pattern Examples
// BAD: Using suspense without Suspense boundary (crashes app)
function BadSuspense() {
const { data } = useSWR("/api/data", fetcher, { suspense: true });
// If no <Suspense> parent, this throws to nearest error boundary or crashes
return <div>{data.value}</div>;
}
// BAD: SSR without fallback (loading flash on hydration)
function BadSSR({ serverData }) {
const { data } = useSWR("/api/data", fetcher);
// Shows loading state on client even though we have serverData
return <div>{data?.value}</div>;
}
// BAD: Mixing suspense with manual loading check
function BadSuspenseMix() {
const { data, isLoading } = useSWR("/api/data", fetcher, { suspense: true });
if (isLoading) return <Spinner />; // This never runs with suspense!
return <div>{data.value}</div>;
}// GOOD: Suspense with Suspense boundary
function GoodSuspense() {
return (
<Suspense fallback={<Spinner />}>
<DataComponent />
</Suspense>
);
}
function DataComponent() {
const { data } = useSWR("/api/data", fetcher, { suspense: true });
return <div>{data.value}</div>;
}
// GOOD: SSR with fallback/fallbackData
function GoodSSR({ serverData }) {
const { data } = useSWR("/api/data", fetcher, {
fallbackData: serverData, // Use server data, revalidate on client
});
return <div>{data?.value}</div>;
}
// GOOD: Suspense handles loading state
function GoodSuspenseClean() {
// With suspense: true, data is guaranteed non-null
const { data } = useSWR("/api/data", fetcher, { suspense: true });
return <div>{data.value}</div>; // No loading check needed
}Why bad examples fail: Missing Suspense boundary crashes app, no fallback causes loading flash, isLoading never true with suspense mode
# yaml-language-server: $schema=https://raw.githubusercontent.com/agents-inc/cli/main/src/schemas/metadata.schema.json
category: web-server-state
slug: swr
domain: web
author: "@vince"
displayName: SWR
cliDescription: Server state and caching
usageGuidance: Use when implementing stale-while-revalidate data fetching patterns.
SWR Data Fetching - Reference
Decision frameworks and configuration reference. See SKILL.md for core concepts and red flags.
---
<decision_framework>
Decision Framework
Choosing Revalidation Strategy
How fresh does data need to be?
├─ Real-time (< 10s stale)?
│ └─ Use refreshInterval with polling
├─ Fresh when user returns?
│ └─ Use revalidateOnFocus: true (default)
├─ Fresh when reconnected?
│ └─ Use revalidateOnReconnect: true (default)
├─ Static/config data?
│ └─ Use useSWRImmutable or disable all revalidation
└─ Manual refresh only?
└─ Disable auto-revalidation, use mutate()Choosing Mutation Approach
Need to modify server data?
├─ Simple POST/PUT/DELETE?
│ └─ useSWRMutation with trigger() ✓
├─ Need optimistic UI?
│ └─ useSWRMutation with optimisticData + rollbackOnError ✓
├─ Need to update related cache after mutation?
│ └─ Use global mutate() to invalidate related keys
├─ Want to skip revalidation after mutation?
│ └─ Use populateCache + revalidate: false
└─ Need to update list after item mutation?
└─ Invalidate list key in onSuccess callbackKey Pattern Selection
What should the cache key be?
├─ Simple GET with path params?
│ └─ `/api/users/${userId}` ✓
├─ GET with query params?
│ └─ `/api/users?status=${status}&page=${page}` ✓
├─ Need multiple arguments?
│ └─ Use array key: ['/api/users', userId, filter]
├─ POST body affects response?
│ └─ Use array key: ['/api/search', searchBody]
└─ Need to skip request?
└─ Return null from keyPagination Pattern Selection
What kind of pagination?
├─ Infinite scroll / "load more"?
│ └─ useSWRInfinite with getKey function
├─ Traditional page numbers?
│ └─ useSWR with page in key + keepPreviousData: true
├─ Cursor-based API?
│ └─ useSWRInfinite with cursor in getKey
└─ Offset-based API?
└─ useSWRInfinite with offset calculation</decision_framework>
---
Configuration Reference
SWRConfig Options
| Option | Default | Description |
|---|---|---|
fetcher | - | Default fetcher function |
revalidateOnFocus | true | Revalidate when window gains focus |
revalidateOnReconnect | true | Revalidate when network reconnects |
revalidateIfStale | true | Revalidate if data is stale |
revalidateOnMount | - | Revalidate when component mounts |
refreshInterval | 0 | Polling interval (0 = disabled) |
refreshWhenHidden | false | Poll when tab is hidden |
refreshWhenOffline | false | Poll when offline |
shouldRetryOnError | true | Retry on error |
errorRetryCount | - | Max retry attempts (unlimited if unset) |
errorRetryInterval | 5000 | Retry interval (ms) |
dedupingInterval | 2000 | Deduplication window (ms) |
focusThrottleInterval | 5000 | Focus revalidation throttle (ms) |
loadingTimeout | 3000 | Timeout before onLoadingSlow (ms) |
keepPreviousData | false | Keep data when key changes |
suspense | false | Enable Suspense mode |
throwOnError | false | Throw errors to error boundary (v2+) |
fallback | {} | Pre-fetched data for SSR |
fallbackData | - | Per-hook fallback data |
onLoadingSlow | - | Callback when request exceeds loadingTimeout |
onSuccess | - | Callback on successful fetch |
onError | - | Callback on fetch error |
onErrorRetry | - | Custom error retry handler |
onDiscarded | - | Callback when request is discarded |
isPaused() | - | Function to pause revalidation |
compare | - | Custom comparison function for data |
use | - | Middleware array |
useSWR Return Values
| Value | Type | Description |
|---|---|---|
data | `T \ | undefined` |
error | `Error \ | undefined` |
isLoading | boolean | True on initial load with no data |
isValidating | boolean | True when any request in-flight |
mutate | function | Bound mutate for this key |
useSWRMutation Return Values
| Value | Type | Description |
|---|---|---|
data | `T \ | undefined` |
error | `Error \ | undefined` |
isMutating | boolean | True when mutation in-flight |
trigger | function | Function to trigger mutation |
reset | function | Reset data and error state |
useSWRMutation Options
| Option | Default | Description |
|---|---|---|
optimisticData | - | Update cache optimistically before fetch |
revalidate | true | Revalidate cache after mutation |
populateCache | false | Write mutation result to cache |
rollbackOnError | true | Revert optimistic data on error |
throwOnError | true | Whether trigger() throws on error |
onSuccess | - | Callback on success |
onError | - | Callback on error |
useSWRInfinite Options
| Option | Default | Description |
|---|---|---|
initialSize | 1 | Number of pages to load initially |
revalidateAll | false | Revalidate all pages on trigger |
revalidateFirstPage | true | Revalidate first page on focus/reconnect |
persistSize | false | Persist page count across unmounts |
parallel | false | Fetch pages in parallel |
---
Sources
Related skills
FAQ
What is the difference between isLoading and isValidating?
isLoading is true only on the initial fetch with no data; isValidating is true during background refreshes.
How do I do conditional fetching in SWR?
Pass a null key to skip the request; never call the hook conditionally.