
React Fetch Cache Patterns
- 90 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
react-fetch-cache-patterns is a Claude Code skill for frontend development.
About
react-fetch-cache-patterns is a Claude Code skill for frontend development. It helps solo builders move faster with AI-assisted coding.
- react-fetch-cache-patterns
- Frontend Development
- AI-coding skill
React Fetch Cache Patterns by the numbers
- 90 all-time installs (skills.sh)
- +9 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,088 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill react-fetch-cache-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 90 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with frontend development tasks during AI-assisted development.?
Helps with frontend development tasks during AI-assisted development.
Who is it for?
Best when you're working on frontend development and need structured help with react fetch cache patterns.
Skip if: Teams with no frontend development needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with frontend development tasks during AI-assisted development., or when react-fetch-cache-patterns is a claude code skill for frontend development.
What you get
Structured output aligned to react-fetch-cache-patterns: react-fetch-cache-patterns, Frontend Development.
Files
Experimental React Data Fetching & Caching Best Practices
Implementation patterns for React applications that fetch and cache many API requests without overwhelming the backend. 48 rules across 8 categories, ordered by execution lifecycle impact — earlier categories cascade through everything downstream. Templates show both library-based (TanStack Query, SWR) and library-free (pure React + AbortController) implementations so the patterns are usable regardless of stack constraints.
When to Apply
- Writing or reviewing a React component that calls
fetch,useQuery,useSWR, or any data-fetching hook - Designing a list, feed, or carousel that displays many items each requiring data
- Investigating "the backend is getting hammered" or "the page loads slowly" symptoms
- Choosing between client-side fetching, route loaders, server components, or SSR
- Implementing prefetch, retry, optimistic updates, or any failure-handling logic
- Refactoring code that already does data fetching but with waterfalls, no cache strategy, or no concurrency limits
Rule Categories by Priority
| # | Category | Impact | Prefix | Rules |
|---|---|---|---|---|
| 1 | Request Orchestration | CRITICAL | orch- | 7 |
| 2 | Cache Strategy | CRITICAL | cache- | 7 |
| 3 | Backend Protection | CRITICAL | protect- | 7 |
| 4 | Prefetch & Hydration | HIGH | prefetch- | 6 |
| 5 | Failure Resilience | HIGH | resilience- | 6 |
| 6 | Feed & Carousel Patterns | MEDIUM-HIGH | feed- | 7 |
| 7 | Mutation & Invalidation | MEDIUM | mutate- | 4 |
| 8 | Component Patterns | MEDIUM | render- | 4 |
Quick Reference
1. Request Orchestration (CRITICAL)
- `orch-parallelize-independent-fetches` — Use
Promise.allfor independent requests; never serialawait - `orch-batch-n-plus-one-fanout` — Collapse per-row fetches via DataLoader-style batching
- `orch-dedupe-in-flight-requests` — One in-flight request per key, shared across subscribers
- `orch-lift-fetch-to-route-loader` — Fetch in parallel with route chunk download
- `orch-avoid-effect-chains` — Flatten the dependency graph; only true dependencies wait
- `orch-server-fetch-when-possible` — Move fetches to RSC/server when they don't depend on client state
- `orch-prefer-bulk-endpoint-for-fanout` — One bulk request beats N parallel requests
2. Cache Strategy (CRITICAL)
- `cache-deterministic-keys` — Canonicalize cache keys; strip undefined, sort arrays
- `cache-normalize-shared-entities` — Store each entity once; views hold references
- `cache-set-stale-time` — Tune
staleTimeto data volatility, not refresh frequency - `cache-stale-while-revalidate` — Render stale instantly; revalidate in background
- `cache-select-subscribed-fields` — Subscribe to slices, not whole objects
- `cache-shared-key-factory` — One typed source of truth for keys; prevents read/write drift
- `cache-tiered-stale-fresh` — Different
staleTimeper data class (realtime, fresh, warm, cold, static)
3. Backend Protection (CRITICAL)
- `protect-concurrency-limit-fanout` — Cap simultaneous requests with
p-limit/semaphore - `protect-collapse-identical-requests` — In-flight dedup at the fetch layer
- `protect-debounce-user-driven-fetches` — Wait for user pause before firing search/filter requests
- `protect-throttle-scroll-triggered` — Use IntersectionObserver for viewport-triggered fetches
- `protect-jittered-retry-backoff` — Add random jitter to prevent thundering-herd retries
- `protect-circuit-breaker` — Stop calling persistently failing endpoints for a cooldown window
- `protect-rate-limit-aware-client` — Honor
Retry-AfterandX-RateLimit-*headers
4. Prefetch & Hydration (HIGH)
- `prefetch-hover-intent-links` — Prefetch on hover/pointerdown for instant navigation
- `prefetch-parallel-loader-queries` — Parallelize independent queries inside loaders
- `prefetch-hydrate-server-cache` — Ship server-fetched data via
HydrationBoundary/ RSC - `prefetch-idle-likely-next` — Use
requestIdleCallbackfor likely-next data - `prefetch-viewport-triggered-next-page` — Fire next-page prefetch via
rootMargin - `prefetch-budget-and-priority` — Tier prefetches by priority; respect
Save-Data
5. Failure Resilience (HIGH)
- `resilience-abort-on-unmount` — Forward
AbortSignalto cancel stale fetches - `resilience-bounded-timeouts` — Set per-endpoint timeouts via
AbortSignal.timeout() - `resilience-scoped-error-boundaries` — One boundary per data section, not per page
- `resilience-stale-fallback` — Render stale cache when fresh fetch fails
- `resilience-no-auto-retry-mutations` — Use idempotency keys or don't auto-retry
- `resilience-graceful-degradation` — Critical/important/decorative tiers with different failure modes
6. Feed & Carousel Patterns (MEDIUM-HIGH)
- `feed-virtualize-long-lists` — Use TanStack Virtual for lists > 50 items
- `feed-cursor-pagination` — Cursors beat offset for inserts and large offsets
- `feed-split-summary-from-detail` — Carousel summaries lightweight; detail on demand
- `feed-multi-carousel-isolation` — Per-carousel error/Suspense boundaries + tiered fallbacks for homepage feeds
- `feed-stable-keys-across-pages` — Use entity IDs as keys; never index
- `feed-image-lazy-and-sized` —
loading="lazy"+ explicit dimensions - `feed-bounded-working-set` —
maxPages+ entity LRU eviction for unbounded feeds
7. Mutation & Invalidation (MEDIUM)
- `mutate-optimistic-updates-with-rollback` — Snapshot, optimistic write, rollback on error
- `mutate-surgical-invalidation` — Invalidate specific keys, not entire trees
- `mutate-set-data-over-invalidate` — Write mutation responses directly into the cache
- `mutate-cancel-queries-on-mutate` — Cancel in-flight queries before optimistic writes
8. Component Patterns (MEDIUM)
- `render-stable-query-keys` —
useMemoobject keys to keep references stable - `render-cap-fanout-in-lists` — Lift fetches; batch via DataLoader; virtualize
- `render-suspense-per-section` — One Suspense boundary per data section
- `render-colocate-fetch-with-consumer` — Put
useQuerynext to its consumer, not at the root
How to Use
1. Open references/_sections.md for category definitions and impact rationale 2. Read individual rule files for incorrect-vs-correct code examples 3. For ready-to-use scaffolds, see scaffolding templates 4. The AGENTS.md navigation document (auto-generated) provides a TOC for browsing
Scaffolding Templates
Six ready-to-adapt code templates under assets/templates/:
| Template | Library deps | Purpose |
|---|---|---|
use-resource-query.template.tsx | TanStack Query | Standard query hook with key factory, retry, abort, optional suspense |
use-resource-query-no-deps.template.tsx | None (pure React + AbortController) | Same patterns as above, library-free: hand-rolled cache, dedup, retry with jitter, staleTime/gcTime, concurrency limit |
carousel-data-loader.template.tsx | TanStack Query + react-error-boundary | Single carousel (summary + viewport-triggered detail) and multi-carousel feed with per-carousel failure isolation |
infinite-feed.template.tsx | TanStack Query + TanStack Virtual | Cursor-paginated infinite feed with virtualization and bounded working set |
prefetch-link.template.tsx | None | Hover/intent prefetch link wrapper |
request-collapser.template.ts | None | In-flight deduplication + concurrency limit utility |
Library-free path: if you can't add TanStack Query / SWR / DataLoader to your bundle (size constraints, host-app conflicts, dependency bans), start with use-resource-query-no-deps.template.tsx — it implements the core cache/dedup/retry/abort patterns in ~250 lines using only React and the web platform. The other templates that depend on TanStack can be adapted on top of it; only the request-collapser and prefetch-link templates are zero-dep out of the box.
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions, ordering, impact rationale |
| assets/templates/_template.md | Template for authoring new rules |
| metadata.json | Version, references, abstract |
Related Skills
react-optimise— General React render performance (this skill is data-fetching-specific)nextjs-bundle-optimizer— Bundle/payload optimization for Next.jsinngest-nextjs-patterns— Server-side workflow patterns (complements server-fetch guidance)
React Data Fetching & Caching
Version 0.1.0 Experimental May 2026
Note:
This document is mainly for agents and LLMs to follow when maintaining,
generating, or refactoring codebases. Humans may also find it useful,
but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Implementation patterns for React applications that fetch and cache many API requests without overwhelming the backend. 48 rules across 8 categories ordered by execution lifecycle impact: Request Orchestration (parallelism, batching, deduplication, route loaders), Cache Strategy (deterministic keys, normalization, staleTime, stale-while-revalidate, key factories, tiered freshness), Backend Protection (concurrency caps, request collapsing, debounce/throttle, jittered retries, circuit breakers, rate-limit awareness), Prefetch & Hydration (hover/intent prefetch, parallel loader queries, server hydration, idle prefetch, viewport-triggered, budget tiers), Failure Resilience (AbortController, bounded timeouts, scoped error boundaries, stale fallback, mutation idempotency, graceful degradation), Feed & Carousel Patterns (virtualization, cursor pagination, summary/detail split, multi-carousel failure isolation, stable keys, lazy images, bounded working set), Mutation & Invalidation (optimistic updates with rollback, surgical invalidation, setQueryData, cancel-on-mutate), and Component Patterns (stable query keys, fan-out caps, Suspense per section, colocation). Bundled with 6 scaffolding templates including both library-based (TanStack Query) and library-free (pure React + AbortController) implementations: resource query hook, no-deps resource query hook, carousel data loader (single + multi-carousel feed with failure isolation), infinite feed, prefetch link, request collapser.
---
Table of Contents
1. Request Orchestration — CRITICAL
- 1.1 Avoid useEffect Fetch Chains — CRITICAL (prevents render-fetch-render-fetch waterfalls)
- 1.2 Batch N+1 Fan-Out with DataLoader Pattern — CRITICAL (reduces N requests to 1)
- 1.3 Deduplicate In-Flight Requests by Key — CRITICAL (reduces M concurrent calls to 1)
- 1.4 Lift Fetches into Route Loaders — CRITICAL (200-800ms saved on route entry)
- 1.5 Move Fetches to the Server When Possible — HIGH (eliminates client-side round-trip + JS payload)
- 1.6 Parallelize Independent Fetches — CRITICAL (eliminates N-1 sequential round-trips)
- 1.7 Prefer a Bulk Endpoint over N Parallel Endpoints — CRITICAL (reduces N round-trips to 1)
2. Cache Strategy — CRITICAL
- 2.1 Build Deterministic Cache Keys — CRITICAL (prevents accidental cache misses on every render)
- 2.2 Centralize Cache Keys in a Key Factory — CRITICAL (prevents read/write key drift)
- 2.3 Normalize Shared Entities Across Views — CRITICAL (N-fold cache size reduction for shared entities)
- 2.4 Set staleTime to Suppress Redundant Refetches — CRITICAL (5-50x reduction in refetch rate)
- 2.5 Tier staleTime by Data Volatility — HIGH (reduces stale-data refetches 10-100×)
- 2.6 Use select to Subscribe to a Subset of Cache Data — CRITICAL (5-20x reduction in re-render rate)
- 2.7 Use Stale-While-Revalidate for Instant Renders — CRITICAL (0ms perceived wait for cached data)
3. Backend Protection — CRITICAL
- 3.1 Add Jitter to Retry Backoff — CRITICAL (prevents thundering-herd recovery)
- 3.2 Cap Concurrency on Client-Side Fan-Out — CRITICAL (prevents browser connection exhaustion + backend overload)
- 3.3 Collapse Identical Requests at the Fetch Layer — HIGH (prevents auth-refresh storms and retry-original races)
- 3.4 Debounce User-Driven Fetches — HIGH (10-30× reduction in search/filter requests)
- 3.5 Honor Server Rate-Limit Headers Client-Side — HIGH (prevents 429 retry loops + ban risk)
- 3.6 Throttle Scroll-Triggered Fetches — HIGH (reduces 60Hz event firing to 4-10Hz fetches)
- 3.7 Use Circuit Breakers on Persistently Failing Endpoints — HIGH (prevents retry storms on persistent failure)
4. Prefetch & Hydration — HIGH
- 4.1 Bound Prefetch Bandwidth by Priority Tier — MEDIUM-HIGH (prevents prefetch from competing with critical fetches)
- 4.2 Hydrate the Client Cache from Server-Rendered Data — HIGH (eliminates first-fetch on cached entities)
- 4.3 Prefetch Likely-Next Data on Idle — MEDIUM-HIGH (maintains instant transitions for predictable paths)
- 4.4 Prefetch Links on Hover and Intent — HIGH (100-300ms faster perceived navigation)
- 4.5 Prefetch the Next Page Before the Sentinel Hits Viewport — HIGH (eliminates loading-more spinners in feeds)
- 4.6 Run Route Loader Queries in Parallel — HIGH (reduces N sequential awaits to 1 round-trip)
5. Failure Resilience — HIGH
- 5.1 Abort Requests on Unmount or Navigation — HIGH (prevents stale-response races and memory leaks)
- 5.2 Avoid Auto-Retrying Non-Idempotent Mutations — HIGH (prevents double-charging, duplicate posts, double sends)
- 5.3 Bound Request Timeouts per Endpoint Class — HIGH (prevents indefinite hangs on degraded backends)
- 5.4 Fall Back to Stale Cache When Fresh Fetch Fails — HIGH (prevents temporary outages from becoming visible errors)
- 5.5 Gracefully Degrade Non-Critical Sections — MEDIUM-HIGH (preserves core flow when peripheral data fails)
- 5.6 Scope Error Boundaries to Data Sections — HIGH (prevents one fetch failure from breaking the entire page)
6. Feed & Carousel Patterns — MEDIUM-HIGH
- 6.1 Bound the In-Memory Working Set on Long Feeds — MEDIUM-HIGH (prevents unbounded memory growth on infinite scroll)
- 6.2 Defer Off-Screen Feed Images with Explicit Dimensions — MEDIUM-HIGH (prevents layout shift and saves 70-90% image bandwidth)
- 6.3 Isolate Failure Across a Feed of Carousels — MEDIUM-HIGH (prevents one failing carousel from breaking the homepage)
- 6.4 Split Carousel Summaries from Item Details — MEDIUM-HIGH (reduces initial carousel payload 5-20×)
- 6.5 Use Cursor Pagination over Offset — MEDIUM-HIGH (prevents skip/duplicate items as the list shifts)
- 6.6 Use Stable Item Keys Across Paginated Pages — MEDIUM (prevents full-list re-render on each new page)
- 6.7 Virtualize Long Lists Beyond ~50 Items — MEDIUM-HIGH (10-100× fewer DOM nodes)
7. Mutation & Invalidation — MEDIUM
- 7.1 Apply Optimistic Updates with Rollback on Failure — MEDIUM (eliminates 200-800ms perceived mutation latency)
- 7.2 Cancel In-Flight Queries Before Mutating Their Cache — MEDIUM (prevents race conditions between mutation and refetch)
- 7.3 Invalidate Surgically, Not Globally — MEDIUM (reduces post-mutation refetch storms 10-100×)
- 7.4 Use setQueryData over Invalidate When the Result is Known — MEDIUM (eliminates 1 refetch per mutation)
8. Component Patterns — MEDIUM
- 8.1 Cap Fan-Out of Queries Inside Lists — MEDIUM (prevents unbounded query fan-out as lists grow)
- 8.2 Colocate Fetches with Their Consumers — MEDIUM (prevents prop drilling and unnecessary re-renders up the tree)
- 8.3 Place Suspense Boundaries Per Logical Section — MEDIUM (enables independent streaming of data sections)
- 8.4 Stabilize Object-Shaped Query Keys — MEDIUM (prevents new fetch on every parent render)
---
References
1. https://react.dev/reference/react/Suspense 2. https://tanstack.com/query/latest/docs/framework/react/overview 3. https://swr.vercel.app/ 4. https://nextjs.org/docs/app/building-your-application/data-fetching 5. https://tanstack.com/router/latest/docs/framework/react/guide/data-loading 6. https://tanstack.com/virtual/latest 7. https://vercel.com/blog/everything-about-data-fetching-in-nextjs 8. https://github.com/graphql/dataloader 9. https://developer.mozilla.org/en-US/docs/Web/API/AbortController 10. https://datatracker.ietf.org/doc/html/rfc5861 11. https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/ 12. https://tkdodo.eu/blog/practical-react-query
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
{Title}
{1-3 sentences explaining WHY this matters. What goes wrong without this pattern, and what the cascade effect is. This section is the highest-signal part of the rule — the model generalizes from understood reasoning, not from rules. Don't just say "use X" — explain the failure mode in concrete terms the model can internalize.
For data-fetching rules, frame the failure in terms of: extra round-trips, extra backend load, extra bytes downloaded, layout shift, race conditions, retry storms, or memory leaks. The mechanism is what makes the rule generalize to novel scenarios.}
Incorrect ({problem label}):
{Production-realistic bad code — not a strawman.}
{Examples should use real names (CommentList, ProductCarousel) not foo/bar.}
{Comments explain the *cost*: "// 200 requests" or "// blocks main thread".}
function BadExample() {
const { data } = useFetch('/api/things'); // 🚨 explanation of what's wrong
}Correct ({solution label}):
{Good code — minimal diff from incorrect when possible.}
{Comments explain the *benefit*.}
function GoodExample() {
const { data } = useQuery({
queryKey: ['things'],
queryFn: fetchThings,
staleTime: 30_000, // ← the fix
});
}{Optional sections — include only when they add value:}
Alternative ({context}):
{Alternative valid approach}Implementation ({name of pattern}):
{Reusable utility worth shipping with the rule}With {framework/tool}:
{Tool-specific variant — e.g., Next.js App Router, TanStack Router, SWR}When NOT to use this pattern:
- {Specific exception with rationale}
- {Another specific exception}
Warning ({context}):
- {Gotcha that would burn a careful reader}
Benefits:
- {Concrete benefit 1}
- {Concrete benefit 2}
Pair with [[other-rule-slug]]: {how this rule combines with another}
Reference: [{Source Title}]({source URL — use authoritative sources only})
/**
* Recommender carousel template.
*
* Embedded patterns:
* - Summary/detail split ([[feed-split-summary-from-detail]])
* - Viewport-triggered detail fetch ([[feed-split-summary-from-detail]])
* - Hover prefetch ([[prefetch-hover-intent-links]])
* - Bulk endpoint for summaries ([[orch-prefer-bulk-endpoint-for-fanout]])
* - Per-item useQuery scoped to in-viewport cards only ([[render-cap-fanout-in-lists]])
* - Scoped error boundary + Suspense ([[resilience-scoped-error-boundaries]])
*
* Parameters to fill in:
* - ITEM_KIND: kebab-case label, e.g. "recommended" / "trending" / "recently-viewed"
* - ProductSummary / Product: replace with your domain types
* - fetchCarouselSummaries / fetchProduct: your API functions
*/
import { useEffect, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
// ─────────────────────────────────────────────────────────────────────────────
// Domain types — replace with yours
// ─────────────────────────────────────────────────────────────────────────────
type ProductSummary = {
id: string;
thumbnail: string;
title: string;
price: number;
};
type Product = ProductSummary & {
description: string;
variants: Array<{ id: string; name: string; price: number }>;
reviews: Array<{ id: string; rating: number; text: string }>;
// ... other heavy detail fields
};
// ─────────────────────────────────────────────────────────────────────────────
// API — replace with your client
// ─────────────────────────────────────────────────────────────────────────────
declare function fetchCarouselSummaries(
kind: string,
init?: { signal?: AbortSignal }
): Promise<ProductSummary[]>;
declare function fetchProduct(
id: string,
init?: { signal?: AbortSignal }
): Promise<Product>;
// ─────────────────────────────────────────────────────────────────────────────
// Carousel component
// ─────────────────────────────────────────────────────────────────────────────
export function RecommenderCarousel({ kind }: { kind: string }) {
const summariesQuery = useQuery({
queryKey: ['carousel', kind, 'summaries'],
queryFn: ({ signal }) => fetchCarouselSummaries(kind, { signal }),
staleTime: 5 * 60_000,
retry: 1,
throwOnError: false, // optional content — see [[resilience-graceful-degradation]]
});
if (summariesQuery.isError) return null; // optional: silent failure
if (!summariesQuery.data) return <CarouselSkeleton />;
return (
<section aria-label={`${kind} carousel`} className="overflow-x-auto">
<div className="flex gap-4">
{summariesQuery.data.map(summary => (
<CarouselCard key={summary.id} summary={summary} />
))}
</div>
</section>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Carousel card — fetches detail only when in viewport
// ─────────────────────────────────────────────────────────────────────────────
function CarouselCard({ summary }: { summary: ProductSummary }) {
const queryClient = useQueryClient();
const cardRef = useRef<HTMLDivElement>(null);
const [inViewport, setInViewport] = useState(false);
// Viewport-triggered detail fetch
useEffect(() => {
const el = cardRef.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => entry.isIntersecting && setInViewport(true),
{ rootMargin: '200px' }
);
obs.observe(el);
return () => obs.disconnect();
}, []);
const detailQuery = useQuery({
queryKey: ['product', summary.id],
queryFn: ({ signal }) => fetchProduct(summary.id, { signal }),
enabled: inViewport,
staleTime: 5 * 60_000,
retry: 1,
});
// Hover prefetch — also covers users who never scroll the card into view
const prefetchDetail = () =>
queryClient.prefetchQuery({
queryKey: ['product', summary.id],
queryFn: ({ signal }) => fetchProduct(summary.id, { signal }),
staleTime: 5 * 60_000,
});
return (
<div
ref={cardRef}
onMouseEnter={prefetchDetail}
onPointerDown={prefetchDetail}
className="flex-shrink-0 w-48"
>
<img
src={summary.thumbnail}
alt={summary.title}
width={192}
height={240}
loading="lazy"
decoding="async"
style={{ aspectRatio: '192 / 240', objectFit: 'cover' }}
/>
<h3 className="text-sm font-medium mt-2 truncate">{summary.title}</h3>
<p className="text-xs">${summary.price.toFixed(2)}</p>
{/* Show extra detail only after the detail fetch resolves */}
{detailQuery.data && (
<p className="text-xs text-muted mt-1 line-clamp-2">
{detailQuery.data.description}
</p>
)}
</div>
);
}
function CarouselSkeleton() {
return (
<div className="flex gap-4 overflow-x-hidden">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex-shrink-0 w-48">
<div className="w-full bg-gray-200 animate-pulse" style={{ aspectRatio: '192 / 240' }} />
<div className="h-4 bg-gray-200 mt-2 animate-pulse" />
<div className="h-3 bg-gray-200 mt-2 w-1/3 animate-pulse" />
</div>
))}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// MULTI-CAROUSEL FEED — composing several carousels with failure isolation
// ─────────────────────────────────────────────────────────────────────────────
//
// A homepage often renders 5-10 themed carousels: trending, recommended,
// recently-viewed, "people also bought", seasonal, etc. Each one is its own
// recommender pipeline — any of them can fail independently. The pattern below
// keeps the feed working even when 2-3 carousels fail.
//
// Embedded patterns:
// - Per-carousel scoped error boundary ([[resilience-scoped-error-boundaries]])
// - Per-carousel Suspense — feed streams in, doesn't block on slowest ([[render-suspense-per-section]])
// - Bounded concurrency on summary fetches ([[protect-concurrency-limit-fanout]])
// - Tier-based fallbacks: critical shows error, decorative silently hides ([[resilience-graceful-degradation]])
// - Viewport-triggered render for below-the-fold carousels ([[prefetch-viewport-triggered-next-page]])
// - Priority ordering (critical-first reveals)
import { Suspense, useEffect, useRef, useState, type ReactNode } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { useSuspenseQuery, useQueryClient } from '@tanstack/react-query';
type CarouselTier = 'critical' | 'important' | 'decorative';
type CarouselConfig = {
kind: string;
title: string;
tier: CarouselTier;
};
const FEED: CarouselConfig[] = [
{ kind: 'continue-watching', title: 'Continue watching', tier: 'critical' },
{ kind: 'recommended-for-you', title: 'Recommended for you', tier: 'important' },
{ kind: 'trending', title: 'Trending now', tier: 'important' },
{ kind: 'recently-viewed', title: 'Recently viewed', tier: 'decorative' },
{ kind: 'because-you-watched', title: 'Because you watched', tier: 'decorative' },
{ kind: 'seasonal', title: 'Seasonal picks', tier: 'decorative' },
];
export function MultiCarouselFeed() {
return (
<div className="flex flex-col gap-12">
{FEED.map((config, index) => (
<CarouselSlot
key={config.kind}
config={config}
// First 2 render eagerly (above fold); the rest defer to viewport
eager={index < 2}
/>
))}
</div>
);
}
// Slot wraps a carousel with discipline-correct fallbacks:
// - critical: error visible with retry
// - important: minimal "couldn't load" placeholder
// - decorative: silent — render nothing on failure
function CarouselSlot({
config,
eager,
}: { config: CarouselConfig; eager: boolean }) {
return (
<section aria-label={config.title}>
<h2 className="text-xl font-semibold mb-4">{config.title}</h2>
<CarouselErrorBoundary tier={config.tier} title={config.title}>
<Suspense fallback={<CarouselSkeleton />}>
{eager
? <SuspendingCarousel kind={config.kind} />
: <DeferredCarousel kind={config.kind} />}
</Suspense>
</CarouselErrorBoundary>
</section>
);
}
// Tier-aware error fallback
function CarouselErrorBoundary({
tier, title, children,
}: { tier: CarouselTier; title: string; children: ReactNode }) {
return (
<ErrorBoundary
onError={(error) => {
// Log every silent failure to observability — silence on the UI,
// not silence in monitoring
reportError({ carousel: title, tier, error });
}}
fallbackRender={({ resetErrorBoundary }) => {
if (tier === 'decorative') return null; // hidden — user doesn't notice
if (tier === 'important') return <CarouselError minimal onRetry={resetErrorBoundary} />;
return <CarouselError onRetry={resetErrorBoundary} />;
}}
>
{children}
</ErrorBoundary>
);
}
// Above-the-fold variant — uses useSuspenseQuery so it suspends until ready
function SuspendingCarousel({ kind }: { kind: string }) {
const { data: summaries } = useSuspenseQuery({
queryKey: ['carousel', kind, 'summaries'],
queryFn: ({ signal }) => fetchCarouselSummaries(kind, { signal }),
staleTime: 5 * 60_000,
});
return <CarouselTrack summaries={summaries} />;
}
// Below-the-fold variant — defer mounting until the slot enters the viewport
function DeferredCarousel({ kind }: { kind: string }) {
const ref = useRef<HTMLDivElement>(null);
const [inViewport, setInViewport] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => entry.isIntersecting && setInViewport(true),
{ rootMargin: '300px' }
);
obs.observe(el);
return () => obs.disconnect();
}, []);
if (!inViewport) {
// Reserve space so the slot has stable height while invisible
return <div ref={ref} style={{ height: 280 }} />;
}
// Now Suspense kicks in via the inner component
return <div ref={ref}><SuspendingCarousel kind={kind} /></div>;
}
function CarouselTrack({ summaries }: { summaries: ProductSummary[] }) {
return (
<div className="flex gap-4 overflow-x-auto">
{summaries.map(s => <CarouselCard key={s.id} summary={s} />)}
</div>
);
}
function CarouselError({
minimal = false, onRetry,
}: { minimal?: boolean; onRetry: () => void }) {
if (minimal) {
return (
<button
onClick={onRetry}
className="text-sm text-muted underline"
>
Couldn't load — tap to retry
</button>
);
}
return (
<div className="rounded border p-4 bg-yellow-50">
<p className="font-medium">We couldn't load this section.</p>
<button onClick={onRetry} className="mt-2 text-sm underline">
Try again
</button>
</div>
);
}
declare function reportError(payload: {
carousel: string;
tier: CarouselTier;
error: unknown;
}): void;
// ─────────────────────────────────────────────────────────────────────────────
// CONCURRENCY GUARDRAIL FOR MULTI-CAROUSEL FEEDS
// ─────────────────────────────────────────────────────────────────────────────
//
// With 6 carousels each fetching ~30 items, the homepage can fire 6 summary
// requests + dozens of detail prefetches in parallel. Wire a global
// concurrency cap so the feed degrades to "smooth and a bit slower" rather
// than "all fail with timeouts."
//
// Configure once at QueryClient setup:
//
// const queryClient = new QueryClient({
// defaultOptions: {
// queries: {
// // Default tier — overridden per-query for realtime / static data
// staleTime: 60_000,
// retry: (attempt, err) =>
// attempt < 2 && !(err instanceof HttpError && err.status >= 400 && err.status < 500),
// retryDelay: attempt => Math.random() * Math.min(30_000, 1000 * 2 ** attempt),
// },
// },
// });
//
// For the underlying `fetch` layer, wrap calls in the request collapser
// from `request-collapser.template.ts` — it caps concurrency at 6.
declare class HttpError extends Error {
status: number;
}
/**
* Cursor-paginated infinite feed with virtualization.
*
* Embedded patterns:
* - Cursor pagination ([[feed-cursor-pagination]])
* - Bounded working set via maxPages ([[feed-bounded-working-set]])
* - Virtualization ([[feed-virtualize-long-lists]])
* - Viewport-triggered next-page prefetch ([[prefetch-viewport-triggered-next-page]])
* - Stable keys ([[feed-stable-keys-across-pages]])
* - Stale-while-revalidate ([[cache-stale-while-revalidate]])
* - AbortSignal forwarded to fetch ([[resilience-abort-on-unmount]])
*
* Parameters to fill in:
* - FEED_NAME: kebab-case feed identifier (e.g. "home-feed", "user-posts")
* - FeedItem: row type
* - fetchFeedPage: your paginated API
* - estimatedRowHeight: approximate row height for the virtualizer
*/
import { useEffect, useMemo, useRef } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
// ─────────────────────────────────────────────────────────────────────────────
// Domain types — replace with yours
// ─────────────────────────────────────────────────────────────────────────────
type FeedItem = {
id: string;
// ... your fields
};
type FeedPage = {
items: FeedItem[];
nextCursor: string | null;
};
declare function fetchFeedPage(
cursor: string | null,
init?: { signal?: AbortSignal }
): Promise<FeedPage>;
// ─────────────────────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────────────────────
const FEED_NAME = 'FEED_NAME';
const ESTIMATED_ROW_HEIGHT = 120;
const OVERSCAN = 5;
const PREFETCH_ROOT_MARGIN = '800px';
export function InfiniteFeed() {
const parentRef = useRef<HTMLDivElement>(null);
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isError,
isLoading,
} = useInfiniteQuery({
queryKey: [FEED_NAME],
queryFn: ({ pageParam, signal }) => fetchFeedPage(pageParam, { signal }),
initialPageParam: null as string | null,
getNextPageParam: (last) => last.nextCursor,
maxPages: 8, // bounded working set
staleTime: 30_000,
refetchOnWindowFocus: false,
});
// Flatten + dedupe items across pages
const items = useMemo(() => {
const all = data?.pages.flatMap(p => p.items) ?? [];
const seen = new Set<string>();
return all.filter(item => seen.has(item.id) ? false : (seen.add(item.id), true));
}, [data]);
// Virtualizer — only ~25 rows in DOM regardless of items.length
const virtualizer = useVirtualizer({
count: hasNextPage ? items.length + 1 : items.length, // +1 slot for the next-page sentinel
getScrollElement: () => parentRef.current,
estimateSize: () => ESTIMATED_ROW_HEIGHT,
overscan: OVERSCAN,
});
// Viewport-triggered next-page prefetch
// Fires when the last virtual row is near the rendered range
useEffect(() => {
if (!hasNextPage || isFetchingNextPage) return;
const lastVirtual = virtualizer.getVirtualItems().at(-1);
if (lastVirtual && lastVirtual.index >= items.length - OVERSCAN) {
fetchNextPage();
}
}, [
virtualizer.getVirtualItems(),
items.length,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
]);
if (isLoading) return <FeedSkeleton />;
if (isError && items.length === 0) return <FeedError />;
return (
<div ref={parentRef} className="h-screen overflow-y-auto" data-feed={FEED_NAME}>
<div
style={{ height: virtualizer.getTotalSize(), width: '100%', position: 'relative' }}
>
{virtualizer.getVirtualItems().map(virtualRow => {
const item = items[virtualRow.index];
const isLoaderRow = virtualRow.index > items.length - 1;
return (
<div
key={isLoaderRow ? 'loader' : item.id}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
{isLoaderRow ? (
hasNextPage ? <Loader /> : <EndOfFeed />
) : (
<FeedRow item={item} />
)}
</div>
);
})}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Row — fill in your render
// ─────────────────────────────────────────────────────────────────────────────
function FeedRow({ item }: { item: FeedItem }) {
return (
<article className="border-b p-4">
{/* Replace with your row content */}
<div>{item.id}</div>
</article>
);
}
function FeedSkeleton() {
return (
<div className="space-y-4 p-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-24 bg-gray-200 animate-pulse rounded" />
))}
</div>
);
}
function Loader() { return <div className="p-4 text-center text-muted">Loading more…</div>; }
function EndOfFeed() { return <div className="p-4 text-center text-muted">End of feed</div>; }
function FeedError() { return <div className="p-4 text-center text-red-600">Couldn't load feed</div>; }
/**
* Hover/intent prefetch link wrapper.
*
* Embedded patterns:
* - Hover + pointerdown prefetch ([[prefetch-hover-intent-links]])
* - Connection-aware gating ([[prefetch-budget-and-priority]])
* - Single trigger per hover session (avoid re-firing on mouse jitter)
*
* Parameters to fill in:
* - The `prefetch` argument: a function that primes the cache for the destination.
* Typically calls `queryClient.prefetchQuery` with the same key/queryFn the
* destination's component would use.
*
* Wrap your existing <Link> (Next, TanStack Router) — this works with any of them.
*/
import { useCallback, useRef, type AnchorHTMLAttributes, type ReactNode } from 'react';
type PrefetchLinkProps = {
href: string;
prefetch: () => Promise<unknown> | unknown;
children: ReactNode;
/** Tag to wrap with — defaults to plain anchor. Pass NextLink / TanStack Link if desired. */
as?: React.ElementType;
} & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>;
export function PrefetchLink({
href,
prefetch,
children,
as: Component = 'a',
...anchorProps
}: PrefetchLinkProps) {
const prefetchedRef = useRef(false);
const tryPrefetch = useCallback(() => {
if (prefetchedRef.current) return;
// Skip on slow / save-data connections — respect user bandwidth
// @ts-expect-error — Network Information API not in lib.dom
const conn = typeof navigator !== 'undefined' ? navigator.connection : undefined;
if (conn?.saveData) return;
if (conn?.effectiveType === '2g' || conn?.effectiveType === 'slow-2g') return;
prefetchedRef.current = true;
try {
const maybePromise = prefetch();
if (maybePromise && typeof (maybePromise as Promise<unknown>).then === 'function') {
// Swallow prefetch errors — never block navigation on a failed prefetch
(maybePromise as Promise<unknown>).catch(() => {
prefetchedRef.current = false; // allow retry on next hover
});
}
} catch {
prefetchedRef.current = false;
}
}, [prefetch]);
return (
<Component
href={href}
onMouseEnter={tryPrefetch}
onFocus={tryPrefetch}
onPointerDown={tryPrefetch}
onTouchStart={tryPrefetch}
{...anchorProps}
>
{children}
</Component>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Usage example
// ─────────────────────────────────────────────────────────────────────────────
//
// import { useQueryClient } from '@tanstack/react-query';
//
// function ProductLink({ id, name }: { id: string; name: string }) {
// const queryClient = useQueryClient();
// return (
// <PrefetchLink
// href={`/product/${id}`}
// prefetch={() => queryClient.prefetchQuery({
// queryKey: ['product', id],
// queryFn: () => fetchProduct(id),
// staleTime: 60_000,
// })}
// >
// {name}
// </PrefetchLink>
// );
// }
/**
* In-flight request collapser + concurrency limiter.
*
* Embedded patterns:
* - Request collapsing by signature ([[protect-collapse-identical-requests]])
* - Concurrency limit for fan-out ([[protect-concurrency-limit-fanout]])
* - GET-only safe-method dedup; mutations never collapsed
*
* Use cases:
* - Imperative code paths that bypass useQuery (auth refresh, logging, polling)
* - APIs without a query library that still need dedup + concurrency limits
* - Pre-warming a cache where multiple modules might race to the same call
*
* Parameters to tune:
* - DEFAULT_CONCURRENCY: max simultaneous in-flight (6 is a safe default for HTTP/1.1)
* - signatureFn: how to derive a dedup signature from request input
*/
// ─────────────────────────────────────────────────────────────────────────────
// Concurrency limiter — bounded queue, no dependency
// ─────────────────────────────────────────────────────────────────────────────
export function createLimiter(max: number) {
let active = 0;
const queue: Array<() => void> = [];
const next = () => {
if (active >= max || queue.length === 0) return;
active++;
queue.shift()!();
};
return <T,>(fn: () => Promise<T>): Promise<T> =>
new Promise<T>((resolve, reject) => {
queue.push(() =>
fn().then(resolve, reject).finally(() => { active--; next(); })
);
next();
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Request collapser — dedup identical concurrent calls by signature
// ─────────────────────────────────────────────────────────────────────────────
type Signature = string;
const DEFAULT_CONCURRENCY = 6;
export class RequestCollapser {
private inflight = new Map<Signature, Promise<unknown>>();
private limit = createLimiter(DEFAULT_CONCURRENCY);
constructor(opts?: { concurrency?: number }) {
if (opts?.concurrency) this.limit = createLimiter(opts.concurrency);
}
/**
* Collapse identical concurrent calls into a shared promise.
*
* @param signature Unique key for the request. Same signature → shared result.
* @param fn The actual fetch — only invoked when no in-flight match exists.
*/
collapse<T>(signature: Signature, fn: () => Promise<T>): Promise<T> {
const existing = this.inflight.get(signature) as Promise<T> | undefined;
if (existing) return existing;
const p = this.limit(fn).finally(() => this.inflight.delete(signature));
this.inflight.set(signature, p);
return p;
}
/** For mutations / non-idempotent calls — always invokes fn, no dedup. */
enqueue<T>(fn: () => Promise<T>): Promise<T> {
return this.limit(fn);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Wrapped fetch — drop-in replacement for `fetch` with dedup + concurrency
// ─────────────────────────────────────────────────────────────────────────────
const collapser = new RequestCollapser({ concurrency: 6 });
/**
* Fetch with automatic in-flight deduplication for GET requests and concurrency limit.
* Non-GET methods bypass dedup (mutations are not idempotent).
*
* The returned Response is cloned for each caller so they can read the body independently.
*/
export function collapsedFetch(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
const method = (init?.method ?? 'GET').toUpperCase();
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
if (method !== 'GET' && method !== 'HEAD') {
// Non-idempotent — never collapse, but still respect concurrency
return collapser.enqueue(() => fetch(input, init));
}
const signature = `${method} ${url}`;
return collapser
.collapse(signature, () => fetch(input, init))
.then(res => res.clone()); // each caller gets their own readable body
}
// ─────────────────────────────────────────────────────────────────────────────
// Usage example
// ─────────────────────────────────────────────────────────────────────────────
//
// // Two modules independently call this in the same tick → one HTTP request
// async function getConfig() {
// const res = await collapsedFetch('/api/config');
// return res.json();
// }
//
// // Fan-out limited to 6 concurrent — backend sees smooth pressure
// const products = await Promise.all(
// ids.map(id => collapsedFetch(`/api/products/${id}`).then(r => r.json()))
// );
/**
* Library-free data-fetching template.
*
* Implements the same patterns as `use-resource-query.template.tsx` but with
* ZERO library dependencies — only `react` and the standard web platform
* (fetch, AbortController, AbortSignal). Use this when:
* - You're in a tightly bundle-controlled app and can't add TanStack/SWR (~12-40kb)
* - You're inside a third-party widget that mustn't conflict with the host's libraries
* - You're vendoring patterns into a codebase that has banned new dependencies
* - You want to understand exactly what TanStack/SWR are doing under the hood
*
* What's implemented (all referenced rules):
* - Module-level cache with subscription model ([[cache-stale-while-revalidate]])
* - Deterministic JSON key canonicalization ([[cache-deterministic-keys]])
* - In-flight request deduplication by key ([[orch-dedupe-in-flight-requests]])
* - staleTime + gcTime semantics ([[cache-set-stale-time]] / [[cache-tiered-stale-fresh]])
* - AbortSignal forwarding + auto-cancel on unmount ([[resilience-abort-on-unmount]])
* - Bounded timeout per fetch ([[resilience-bounded-timeouts]])
* - Exponential backoff with full jitter on retry ([[protect-jittered-retry-backoff]])
* - 4xx errors do not retry ([[protect-jittered-retry-backoff]])
* - Concurrency-limited fetch wrapper ([[protect-concurrency-limit-fanout]])
*
* What's NOT implemented (out of scope for a single file):
* - Normalized entity store
* - Optimistic mutations with rollback
* - Persistence (localStorage hydration)
* - SSR/hydration boundaries
* - Window-focus refetching
* For any of these, prefer adopting TanStack Query — re-implementing them
* correctly is 10x the code below.
*/
import { useEffect, useReducer, useRef, useSyncExternalStore } from 'react';
// ─────────────────────────────────────────────────────────────────────────────
// 1. CACHE — module-level singleton with pub/sub
// ─────────────────────────────────────────────────────────────────────────────
type CacheEntry<T> = {
status: 'pending' | 'success' | 'error';
data?: T;
error?: unknown;
updatedAt: number; // last successful resolve time
subscribers: Set<() => void>;
inflight?: Promise<T>; // shared in-flight promise for dedup
gcTimer?: ReturnType<typeof setTimeout>;
};
const cache = new Map<string, CacheEntry<unknown>>();
function getEntry<T>(key: string): CacheEntry<T> {
let entry = cache.get(key) as CacheEntry<T> | undefined;
if (!entry) {
entry = { status: 'pending', updatedAt: 0, subscribers: new Set() };
cache.set(key, entry as CacheEntry<unknown>);
}
return entry;
}
function notify(entry: CacheEntry<unknown>) {
entry.subscribers.forEach(cb => cb());
}
// ─────────────────────────────────────────────────────────────────────────────
// 2. KEY CANONICALIZATION — equal-by-value produces equal-by-key
// ─────────────────────────────────────────────────────────────────────────────
export function canonicalKey(parts: unknown[]): string {
return JSON.stringify(parts, function (this: unknown, _key, value) {
if (value === undefined) return undefined;
if (value === null || typeof value !== 'object') return value;
if (Array.isArray(value)) return [...value].sort();
return Object.keys(value as Record<string, unknown>)
.sort()
.reduce<Record<string, unknown>>((acc, k) => {
const v = (value as Record<string, unknown>)[k];
if (v !== undefined) acc[k] = v;
return acc;
}, {});
});
}
// ─────────────────────────────────────────────────────────────────────────────
// 3. CONCURRENCY LIMITER — bounds parallel fetches
// ─────────────────────────────────────────────────────────────────────────────
function createLimiter(max: number) {
let active = 0;
const queue: Array<() => void> = [];
const next = () => {
if (active >= max || queue.length === 0) return;
active++;
queue.shift()!();
};
return <T,>(fn: () => Promise<T>): Promise<T> =>
new Promise<T>((resolve, reject) => {
queue.push(() =>
fn().then(resolve, reject).finally(() => { active--; next(); })
);
next();
});
}
const fetchLimit = createLimiter(6);
// ─────────────────────────────────────────────────────────────────────────────
// 4. RETRY + TIMEOUT WRAPPER
// ─────────────────────────────────────────────────────────────────────────────
export class HttpError extends Error {
constructor(public status: number, public body: string) {
super(`HTTP ${status}`);
this.name = 'HttpError';
}
}
type RunOpts = {
signal?: AbortSignal;
timeoutMs?: number;
maxAttempts?: number;
};
async function runWithRetry<T>(
fn: (signal: AbortSignal) => Promise<T>,
{ signal, timeoutMs = 8000, maxAttempts = 3 }: RunOpts = {}
): Promise<T> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// Merge external cancel + per-attempt timeout
const timeoutSig = AbortSignal.timeout(timeoutMs);
const merged = signal
? AbortSignal.any([signal, timeoutSig])
: timeoutSig;
try {
return await fn(merged);
} catch (err) {
// External cancel — propagate, no retry
if (signal?.aborted) throw err;
// 4xx — don't retry malformed/forbidden requests
if (err instanceof HttpError && err.status >= 400 && err.status < 500) throw err;
// Last attempt — give up
if (attempt === maxAttempts - 1) throw err;
// Full-jitter exponential backoff
const cap = Math.min(30_000, 1000 * 2 ** attempt);
const delay = Math.random() * cap;
await new Promise(r => setTimeout(r, delay));
}
}
throw new Error('unreachable');
}
// ─────────────────────────────────────────────────────────────────────────────
// 5. CORE FETCH — wraps a fetcher with cache + dedup + retry
// ─────────────────────────────────────────────────────────────────────────────
type FetchOpts<T> = {
key: string;
fetcher: (signal: AbortSignal) => Promise<T>;
staleTime?: number;
gcTime?: number;
signal?: AbortSignal;
};
async function fetchAndStore<T>(opts: FetchOpts<T>): Promise<T> {
const entry = getEntry<T>(opts.key);
// In-flight dedup — concurrent callers share the same promise
if (entry.inflight) return entry.inflight;
entry.status = 'pending';
entry.inflight = fetchLimit(() =>
runWithRetry(opts.fetcher, { signal: opts.signal })
)
.then(data => {
entry.status = 'success';
entry.data = data;
entry.error = undefined;
entry.updatedAt = Date.now();
return data;
})
.catch(err => {
entry.status = 'error';
entry.error = err;
throw err;
})
.finally(() => {
entry.inflight = undefined;
notify(entry as CacheEntry<unknown>);
});
return entry.inflight;
}
function isFresh(entry: CacheEntry<unknown>, staleTime: number) {
return entry.status === 'success' && Date.now() - entry.updatedAt < staleTime;
}
// ─────────────────────────────────────────────────────────────────────────────
// 6. PUBLIC HOOK — useResourceQuery
// ─────────────────────────────────────────────────────────────────────────────
export type QueryState<T> = {
data: T | undefined;
error: unknown;
status: 'pending' | 'success' | 'error';
isFetching: boolean;
refetch: () => void;
};
export function useResourceQuery<T>(args: {
keyParts: unknown[];
fetcher: (signal: AbortSignal) => Promise<T>;
staleTime?: number; // default 30s
gcTime?: number; // default 5m
enabled?: boolean; // default true
}): QueryState<T> {
const {
keyParts,
fetcher,
staleTime = 30_000,
gcTime = 5 * 60_000,
enabled = true,
} = args;
const key = canonicalKey(keyParts);
const entry = getEntry<T>(key);
const [, forceRender] = useReducer((n: number) => n + 1, 0);
const fetcherRef = useRef(fetcher);
fetcherRef.current = fetcher;
// Subscribe to cache updates for this key — via useSyncExternalStore for
// concurrent-rendering safety
useSyncExternalStore(
(cb) => {
entry.subscribers.add(cb);
return () => {
entry.subscribers.delete(cb);
// Schedule GC if no subscribers remain
if (entry.subscribers.size === 0) {
entry.gcTimer = setTimeout(() => {
if (entry.subscribers.size === 0) cache.delete(key);
}, gcTime);
}
};
},
() => entry.updatedAt,
() => 0 // SSR snapshot
);
// Trigger a fetch if needed
useEffect(() => {
if (!enabled) return;
if (entry.gcTimer) { clearTimeout(entry.gcTimer); entry.gcTimer = undefined; }
if (isFresh(entry, staleTime)) return;
if (entry.inflight) return;
const ctrl = new AbortController();
fetchAndStore({
key,
fetcher: (sig) => fetcherRef.current(sig),
staleTime,
gcTime,
signal: ctrl.signal,
}).then(forceRender, () => forceRender());
// Cancel this component's fetch on unmount — but only if it's still
// the active inflight (other subscribers may have completed it already)
return () => {
if (entry.subscribers.size === 0) ctrl.abort();
};
}, [key, enabled, staleTime, gcTime]);
const refetch = () => {
if (entry.inflight) return;
const ctrl = new AbortController();
fetchAndStore({
key,
fetcher: (sig) => fetcherRef.current(sig),
staleTime,
gcTime,
signal: ctrl.signal,
}).catch(() => {});
};
return {
data: entry.data,
error: entry.error,
status: entry.status,
isFetching: !!entry.inflight,
refetch,
};
}
// ─────────────────────────────────────────────────────────────────────────────
// 7. IMPERATIVE HELPERS — prefetch and setQueryData equivalents
// ─────────────────────────────────────────────────────────────────────────────
export function prefetchResource<T>(
keyParts: unknown[],
fetcher: (signal: AbortSignal) => Promise<T>,
staleTime = 30_000
): Promise<T> {
const key = canonicalKey(keyParts);
const entry = getEntry<T>(key);
if (isFresh(entry, staleTime) && entry.data !== undefined) {
return Promise.resolve(entry.data);
}
return fetchAndStore({ key, fetcher, staleTime });
}
export function setCacheValue<T>(keyParts: unknown[], data: T): void {
const key = canonicalKey(keyParts);
const entry = getEntry<T>(key);
entry.status = 'success';
entry.data = data;
entry.error = undefined;
entry.updatedAt = Date.now();
notify(entry as CacheEntry<unknown>);
}
export function invalidateCache(predicate: (key: string) => boolean): void {
for (const [key, entry] of cache) {
if (!predicate(key)) continue;
// Mark stale by zeroing updatedAt — next mount refetches
entry.updatedAt = 0;
notify(entry as CacheEntry<unknown>);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// USAGE EXAMPLES
// ─────────────────────────────────────────────────────────────────────────────
//
// // Define a resource fetcher
// type Product = { id: string; name: string; price: number };
//
// const fetchProduct = (id: string) => (signal: AbortSignal) =>
// fetch(`/api/products/${id}`, { signal }).then(async r => {
// if (!r.ok) throw new HttpError(r.status, await r.text());
// return r.json() as Promise<Product>;
// });
//
// // Use in a component — fully library-free
// function ProductCard({ id }: { id: string }) {
// const { data, error, status, refetch } = useResourceQuery({
// keyParts: ['product', id],
// fetcher: fetchProduct(id),
// staleTime: 5 * 60_000,
// });
//
// if (status === 'pending') return <Skeleton />;
// if (status === 'error') return <button onClick={refetch}>Retry</button>;
// return <Card product={data!} />;
// }
//
// // Hover-prefetch — re-use prefetchResource imperatively
// <a
// href={`/product/${id}`}
// onMouseEnter={() => prefetchResource(['product', id], fetchProduct(id))}
// />
declare function Skeleton(): JSX.Element;
declare function Card(props: { product: unknown }): JSX.Element;
/**
* Standardized query hook template.
*
* Embeds the patterns this skill teaches:
* - Key factory ([[cache-shared-key-factory]])
* - Deterministic key serialization ([[cache-deterministic-keys]])
* - AbortSignal forwarding ([[resilience-abort-on-unmount]])
* - Per-endpoint timeout ([[resilience-bounded-timeouts]])
* - Jittered retry ([[protect-jittered-retry-backoff]])
* - Tiered staleTime ([[cache-tiered-stale-fresh]])
* - Optional Suspense via useSuspenseQuery ([[render-suspense-per-section]])
*
* Parameters to fill in:
* - RESOURCE_NAME: singular kebab-case name (e.g. "product", "user", "comment")
* - Resource: TypeScript type for the resource
* - GetParams: parameters for fetching a single resource
* - ListParams: parameters for fetching a list (filters, sort, etc.)
* - apiBase: the endpoint path (e.g. "/api/products")
* - staleTier: one of STALE.realtime / fresh / warm / cold / static
*/
import {
useQuery,
useSuspenseQuery,
useInfiniteQuery,
type QueryClient,
type UseQueryOptions,
} from '@tanstack/react-query';
// ─────────────────────────────────────────────────────────────────────────────
// Configuration tiers
// ─────────────────────────────────────────────────────────────────────────────
export const STALE = {
realtime: 5_000,
fresh: 30_000,
warm: 5 * 60_000,
cold: 60 * 60_000,
static: 24 * 60 * 60_000,
} as const;
const TIMEOUT_MS = 8000;
// ─────────────────────────────────────────────────────────────────────────────
// Resource definition — duplicate this section per resource and rename
// ─────────────────────────────────────────────────────────────────────────────
const RESOURCE_NAME = 'RESOURCE_NAME'; // e.g. 'product'
type Resource = {
id: string;
// ... add fields
};
type GetParams = { id: string };
type ListParams = { /* filters */ };
const apiBase = '/api/RESOURCE_NAME';
// ─────────────────────────────────────────────────────────────────────────────
// Key factory — single source of truth for cache keys
// See [[cache-shared-key-factory]]
// ─────────────────────────────────────────────────────────────────────────────
export const resourceKeys = {
all: [RESOURCE_NAME] as const,
lists: () => [...resourceKeys.all, 'list'] as const,
list: (params: ListParams) => [...resourceKeys.lists(), canonical(params)] as const,
details: () => [...resourceKeys.all, 'detail'] as const,
detail: (id: string) => [...resourceKeys.details(), id] as const,
};
// Canonicalize filter objects so equal-by-value produces equal-by-key
// See [[cache-deterministic-keys]]
function canonical<T extends Record<string, unknown>>(obj: T): T {
return Object.fromEntries(
Object.entries(obj)
.filter(([, v]) => v !== undefined)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => [k, Array.isArray(v) ? [...v].sort() : v])
) as T;
}
// ─────────────────────────────────────────────────────────────────────────────
// Fetch functions — forward AbortSignal, bound timeout, throw on non-2xx
// See [[resilience-abort-on-unmount]] + [[resilience-bounded-timeouts]]
// ─────────────────────────────────────────────────────────────────────────────
async function fetchResource(
{ id }: GetParams,
{ signal }: { signal?: AbortSignal } = {}
): Promise<Resource> {
const merged = signal
? AbortSignal.any([signal, AbortSignal.timeout(TIMEOUT_MS)])
: AbortSignal.timeout(TIMEOUT_MS);
const res = await fetch(`${apiBase}/${encodeURIComponent(id)}`, { signal: merged });
if (!res.ok) throw new HttpError(res.status, await res.text());
return res.json();
}
async function fetchResourceList(
params: ListParams,
{ signal }: { signal?: AbortSignal } = {}
): Promise<{ items: Resource[]; nextCursor: string | null }> {
const merged = signal
? AbortSignal.any([signal, AbortSignal.timeout(TIMEOUT_MS)])
: AbortSignal.timeout(TIMEOUT_MS);
const search = new URLSearchParams(params as Record<string, string>).toString();
const res = await fetch(`${apiBase}?${search}`, { signal: merged });
if (!res.ok) throw new HttpError(res.status, await res.text());
return res.json();
}
export class HttpError extends Error {
constructor(public status: number, public body: string) {
super(`HTTP ${status}: ${body.slice(0, 200)}`);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Hooks
// ─────────────────────────────────────────────────────────────────────────────
export function useResource(
params: GetParams,
options?: Omit<UseQueryOptions<Resource>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: resourceKeys.detail(params.id),
queryFn: ({ signal }) => fetchResource(params, { signal }),
staleTime: STALE.warm,
retry: (attempt, err) => attempt < 2 && err instanceof HttpError && err.status >= 500,
retryDelay: attempt => Math.random() * Math.min(30_000, 1000 * 2 ** attempt),
...options,
});
}
/** Same hook but suspends — use inside <Suspense> + <ErrorBoundary>. */
export function useResourceSuspense(params: GetParams) {
return useSuspenseQuery({
queryKey: resourceKeys.detail(params.id),
queryFn: ({ signal }) => fetchResource(params, { signal }),
staleTime: STALE.warm,
});
}
export function useResourceList(params: ListParams) {
return useQuery({
queryKey: resourceKeys.list(params),
queryFn: ({ signal }) => fetchResourceList(params, { signal }),
staleTime: STALE.fresh,
placeholderData: (prev) => prev,
});
}
/** Infinite/cursor-paginated list. See [[feed-cursor-pagination]]. */
export function useResourceInfinite(params: ListParams) {
return useInfiniteQuery({
queryKey: [...resourceKeys.lists(), 'infinite', canonical(params)] as const,
queryFn: ({ pageParam, signal }) =>
fetchResourceList({ ...params, cursor: pageParam } as ListParams, { signal }),
initialPageParam: null as string | null,
getNextPageParam: (last: { nextCursor: string | null }) => last.nextCursor,
maxPages: 10, // bounded working set — see [[feed-bounded-working-set]]
staleTime: STALE.fresh,
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Prefetch helpers (for route loaders + intent prefetching)
// ─────────────────────────────────────────────────────────────────────────────
export function prefetchResource(qc: QueryClient, id: string) {
return qc.prefetchQuery({
queryKey: resourceKeys.detail(id),
queryFn: ({ signal }) => fetchResource({ id }, { signal }),
staleTime: STALE.warm,
});
}
export function ensureResource(qc: QueryClient, id: string) {
return qc.ensureQueryData({
queryKey: resourceKeys.detail(id),
queryFn: ({ signal }) => fetchResource({ id }, { signal }),
staleTime: STALE.warm,
});
}
{
"version": "0.1.0",
"organization": "Experimental",
"technology": "React Data Fetching & Caching",
"discipline": "distillation",
"type": "library-reference",
"date": "May 2026",
"abstract": "Implementation patterns for React applications that fetch and cache many API requests without overwhelming the backend. 48 rules across 8 categories ordered by execution lifecycle impact: Request Orchestration (parallelism, batching, deduplication, route loaders), Cache Strategy (deterministic keys, normalization, staleTime, stale-while-revalidate, key factories, tiered freshness), Backend Protection (concurrency caps, request collapsing, debounce/throttle, jittered retries, circuit breakers, rate-limit awareness), Prefetch & Hydration (hover/intent prefetch, parallel loader queries, server hydration, idle prefetch, viewport-triggered, budget tiers), Failure Resilience (AbortController, bounded timeouts, scoped error boundaries, stale fallback, mutation idempotency, graceful degradation), Feed & Carousel Patterns (virtualization, cursor pagination, summary/detail split, multi-carousel failure isolation, stable keys, lazy images, bounded working set), Mutation & Invalidation (optimistic updates with rollback, surgical invalidation, setQueryData, cancel-on-mutate), and Component Patterns (stable query keys, fan-out caps, Suspense per section, colocation). Bundled with 6 scaffolding templates including both library-based (TanStack Query) and library-free (pure React + AbortController) implementations: resource query hook, no-deps resource query hook, carousel data loader (single + multi-carousel feed with failure isolation), infinite feed, prefetch link, request collapser.",
"references": [
"https://react.dev/reference/react/Suspense",
"https://tanstack.com/query/latest/docs/framework/react/overview",
"https://swr.vercel.app/",
"https://nextjs.org/docs/app/building-your-application/data-fetching",
"https://tanstack.com/router/latest/docs/framework/react/guide/data-loading",
"https://tanstack.com/virtual/latest",
"https://vercel.com/blog/everything-about-data-fetching-in-nextjs",
"https://github.com/graphql/dataloader",
"https://developer.mozilla.org/en-US/docs/Web/API/AbortController",
"https://datatracker.ietf.org/doc/html/rfc5861",
"https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/",
"https://tkdodo.eu/blog/practical-react-query"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
Categories are ordered by lifecycle position and cascade effect. Problems at the top (orchestration, cache keys) multiply downstream — getting them wrong creates hundreds of redundant backend hits per user. Problems at the bottom (render patterns, mutation invalidation) are localized and addable later.
Impact tier definitions
Used by rule frontmatter and category headings:
| Tier | Meaning | When to assign |
|---|---|---|
| CRITICAL | Cascades through all downstream operations; wrong here = self-inflicted outage or N× backend amplification | Multiplicative bugs (N+1 fan-out, missing concurrency cap, wrong cache key strategy) |
| HIGH | Affects a major user-visible path or backend budget; not multiplicative but compounding | Per-endpoint policy (debounce, timeout, retry-with-jitter), architectural choice (server-fetch, hydration) |
| MEDIUM-HIGH | Important for a specific scenario (long feeds, recommender carousels) but not universal | Domain-specific patterns: virtualization (only matters at scale), prefetch budgeting (only matters on slow networks), summary/detail split (only matters for carousels) |
| MEDIUM | Localized correctness or render efficiency; high frequency, contained blast radius | Per-component patterns: stable keys, scoped Suspense, optimistic mutations |
| LOW-MEDIUM | Micro-optimization on hot paths | Reserved for very specific hot loops; rarely used in this skill |
| LOW | Edge case or expert pattern | Reserved for niche scenarios; rarely used in this skill |
A rule's tier is independent of its category's tier — a CRITICAL category can contain HIGH or MEDIUM rules, and vice versa. The category tier reflects the typical severity of issues in that domain; individual rule tiers reflect the specific impact of each pattern.
---
1. Request Orchestration (orch)
Impact: CRITICAL Description: How concurrent fetches are coordinated — parallelism vs waterfalls, batching of N+1 fan-out, in-flight deduplication, and lifting fetches into route loaders. Wrong orchestration multiplies latency (sequential awaits) and multiplies backend load (every child component independently fetching). The single biggest source of backend overload from React apps.
2. Cache Strategy (cache)
Impact: CRITICAL Description: How fetched data is keyed, stored, and shared across components — deterministic cache keys, normalized graph cache for shared entities (the same product appearing in three carousels), stale-while-revalidate semantics, staleTime, and selector-scoped subscriptions. A bad key strategy creates a cache miss for every user; a flat cache forces N requests for the same entity reused N times.
3. Backend Protection (protect)
Impact: CRITICAL Description: Guardrails that prevent the client from overwhelming the backend — concurrency caps on fan-out, request collapsing for identical in-flight calls, debounce/throttle on user-driven triggers, and stampede protection (jittered retries, cache-warming locks). Without these, a refresh, a viral page, or a brief backend slowdown becomes a self-DDoS.
4. Prefetch & Hydration (prefetch)
Impact: HIGH Description: Eager fetching strategies that start requests before the component needs them — route loaders, hover/intent prefetch, idle-time and viewport-triggered prefetch, and server-rendered cache hydration. Done right, this shifts latency out of the user's critical path; done wrong, it doubles backend load by prefetching data the user never views.
5. Failure Resilience (resilience)
Impact: HIGH Description: Patterns that keep the app working when fetches fail — exponential backoff with jitter, circuit breakers, request cancellation via AbortController, scoped error boundaries, and stale-cache fallback. Naive retries turn transient blips into outages by hammering the recovering service; missing cancellation leaks completed requests after navigation.
6. Feed & Carousel Patterns (feed)
Impact: MEDIUM-HIGH Description: Patterns specific to feeds and recommender carousels at scale — virtualization, cursor-based pagination, infinite query dedup, splitting summary fetches from detail fetches, and viewport-triggered detail loading. These collapse hundreds of off-screen renders and hundreds of unviewed-item detail fetches into a bounded working set.
7. Mutation & Invalidation (mutate)
Impact: MEDIUM Description: How writes update the cache — optimistic updates with rollback, surgical invalidation (target specific keys, not entire trees), setQueryData instead of refetch when the result is known, and mutation idempotency. Wrong invalidation triggers an avalanche of refetches; over-aggressive invalidation defeats the cache.
8. Component Patterns (render)
Impact: MEDIUM Description: Component-level patterns that prevent render-driven re-fetches — stable query keys, scoped Suspense boundaries, fan-out caps inside map(), selector hooks that subscribe to only the rendered fields, and co-locating queries near the components that consume them. These collapse re-render storms and prevent components from accidentally refetching on every parent update.
Build Deterministic Cache Keys
Cache keys are compared structurally. { id: 1, type: 'A' } and { type: 'A', id: 1 } look equal but their JSON serializations differ — a serialization-based cache would store them as two entries. New object literals on every render produce a new key on every render, so the cache never hits. Canonicalize: sort keys, drop undefined values, strip irrelevant fields.
TanStack Query handles object key ordering automatically but does not strip undefined or sort arrays — if you pass user-controlled filters, normalize them before they become a key.
Incorrect (filter object built inline on every render, undefined fields, unsorted IDs):
function ProductList({ category, minPrice }: Filters) {
// New object literal each render → new key reference each render
const filters = { category, minPrice, ids: selectedIds };
// selectedIds order varies; minPrice may be undefined when not set
const { data } = useQuery({
queryKey: ['products', filters],
queryFn: () => fetchProducts(filters),
});
// Result: ['products', {category: 'A', minPrice: undefined, ids: [3,1,2]}] ≠
// ['products', {category: 'A', ids: [1,2,3]}] — cache misses
}Correct (canonicalize: strip undefined, sort arrays, fixed shape):
function canonicalKey<T extends object>(obj: T): T {
return Object.fromEntries(
Object.entries(obj)
.filter(([, v]) => v !== undefined)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => [k, Array.isArray(v) ? [...v].sort() : v])
) as T;
}
function ProductList({ category, minPrice }: Filters) {
const filters = useMemo(
() => canonicalKey({ category, minPrice, ids: selectedIds }),
[category, minPrice, selectedIds]
);
const { data } = useQuery({
queryKey: ['products', filters],
queryFn: () => fetchProducts(filters),
});
}Tag-style alternative (avoid serializing complex filters entirely):
// Hash the filters once, use the hash as the key
const filterHash = useMemo(() => hashFilters(filters), [filters]);
useQuery({ queryKey: ['products', filterHash], queryFn: () => fetchProducts(filters) });Warning: never include Date.now(), Math.random(), or non-serializable values (functions, class instances) in a cache key — every render produces a fresh key and the cache becomes a memory leak.
Reference: TanStack Query — Query Keys
Normalize Shared Entities Across Views
If a product appears in three carousels and a search result on the same page, a flat (query-keyed) cache stores it four times — and updates to "is in cart" propagate to only the one the user clicked. A normalized cache stores each entity once by ID; views hold references. Update once, all four views re-render with the new state.
This is what Relay, Apollo, RTK Query, and Normy do automatically. TanStack Query is flat by default — for high entity reuse, either layer Normy on top or denormalize-then-update via setQueryData after mutations.
Incorrect (flat cache: same product cached four times):
// Each of these queries stores its own copy of overlapping product objects
useQuery({ queryKey: ['carousel', 'trending'], queryFn: fetchTrendingProducts });
useQuery({ queryKey: ['carousel', 'recently-viewed'], queryFn: fetchRecentProducts });
useQuery({ queryKey: ['carousel', 'recommended'], queryFn: fetchRecommendations });
useQuery({ queryKey: ['search', term], queryFn: () => searchProducts(term) });
// User mutates "add to favorites" on product #42 in trending
queryClient.setQueryData(['carousel', 'trending'], updateFavorite(/*...*/));
// → only the trending carousel re-renders; the same product in 'recently-viewed' stays staleCorrect (normalize: store product once, queries hold IDs):
// Normalized store (Zustand/Jotai/custom) keyed by entity type + id
type Store = {
products: Map<string, Product>;
setProducts: (ps: Product[]) => void;
};
// Query stores only the IDs; rendering reads details from the store
const { data: ids } = useQuery({
queryKey: ['carousel', 'trending'],
queryFn: async () => {
const products = await fetchTrendingProducts();
productStore.setProducts(products); // dump entities into normalized store
return products.map(p => p.id); // query stores just the order
},
});
function ProductCard({ id }: { id: string }) {
const product = useProductStore(s => s.products.get(id)); // re-renders when THIS product changes
return <Card product={product!} />;
}
// One mutation, all four views update:
function favoriteProduct(id: string) {
productStore.setProducts([{ ...productStore.products.get(id)!, isFavorite: true }]);
}Alternative (Normy as a drop-in layer):
import { createQueryNormalizer } from '@normy/react-query';
// Normy reads response shapes, indexes by `id`, and surgically updates all queries
// that contain entities with that id. Zero per-query code.When NOT to normalize: when entities are rarely shared between views (e.g. a settings page with unique data per route). The normalization tax isn't worth it.
Reference: Normy — Automatic Normalization
Use select to Subscribe to a Subset of Cache Data
A component subscribed to useQuery({ queryKey: ['user', id] }) re-renders any time any field of the user object changes — even if it only displays user.name. In a feed where each row subscribes to a chunky user object, an unrelated update to user.lastSeenAt re-renders every row. select transforms the cache value and the component re-renders only when the selected slice changes.
Same pattern in Zustand, Redux's useSelector, Jotai's atoms. The principle: subscribe to the minimum slice you render.
Incorrect (subscribe to whole user, re-render on any field change):
function CommentAvatar({ authorId }: { authorId: string }) {
// Re-renders any time *anything* on the user changes — name, bio, lastSeenAt, isOnline...
const { data: user } = useQuery({
queryKey: ['user', authorId],
queryFn: () => fetchUser(authorId),
});
return <Avatar src={user?.avatarUrl} />; // we only need avatarUrl!
}Correct (select narrows the subscription to the rendered field):
function CommentAvatar({ authorId }: { authorId: string }) {
const { data: avatarUrl } = useQuery({
queryKey: ['user', authorId],
queryFn: () => fetchUser(authorId),
select: user => user.avatarUrl, // re-render only when avatarUrl changes
});
return <Avatar src={avatarUrl} />;
}Multiple consumers of the same query, each selecting different fields:
function useUserAvatar(id: string) {
return useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id), select: u => u.avatarUrl });
}
function useUserName(id: string) {
return useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id), select: u => u.name });
}
// One fetch, two narrow subscriptions — name update doesn't re-render avatar consumers.Implementation note: select must produce a stable reference for the same input. Returning { a, b } on every call creates a new object each time and defeats memoization — return primitives, or wrap the projector in useCallback and produce equal results.
Reference: TanStack Query — Render Optimizations
Set staleTime to Suppress Redundant Refetches
By default, TanStack Query treats data as "instantly stale" — a refetch fires every time the query mounts, every time a component remounts, every time the window regains focus. For data that rarely changes (a product catalog, a user profile, a list of countries), this is a 50x amplification of backend load over what's needed. staleTime tells the cache "trust this data for N seconds — don't refetch within that window."
Pick staleTime based on how often the underlying data changes, not how often the user wants to see updates. A product's name changes monthly; a stock count changes per-second.
Incorrect (default staleTime: refetches on every mount and focus):
function ProductCard({ id }: { id: string }) {
// staleTime: 0 by default
// → if the user opens this card in 10 components, focuses the tab,
// navigates away and back, you'll see ~12 refetches of /products/:id
const { data } = useQuery({
queryKey: ['product', id],
queryFn: () => fetchProduct(id),
});
}Correct (staleTime tuned to data volatility):
function ProductCard({ id }: { id: string }) {
const { data } = useQuery({
queryKey: ['product', id],
queryFn: () => fetchProduct(id),
staleTime: 5 * 60_000, // catalog data — fine for 5 minutes
gcTime: 30 * 60_000, // keep in memory for 30 min after last use
});
}
// Different staleTime for different data classes — see [[cache-tiered-stale-fresh]]
function StockBadge({ productId }: { productId: string }) {
const { data } = useQuery({
queryKey: ['stock', productId],
queryFn: () => fetchStock(productId),
staleTime: 10_000, // inventory — refresh every 10s
});
}Global defaults (set once, override per-query when needed):
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // 30s default — sensible for most app data
gcTime: 5 * 60_000,
refetchOnWindowFocus: false, // opt-in per query
},
},
});Warning: staleTime: Infinity for shared mutable data leaks staleness across users. Reserve it for truly immutable data (a list of country codes).
Reference: TanStack Query — Important Defaults
Centralize Cache Keys in a Key Factory
Key drift is a silent killer: a useQuery reads from ['products', filters] and a mutation invalidates ['product', filters] (singular vs plural). The mutation succeeds, the cache appears intact, the UI shows stale data, and you spend an afternoon debugging "cache invalidation." Define keys once in a typed factory and import them everywhere — reads, writes, prefetches, invalidations all reference the same source of truth.
The factory also gives you free hierarchical invalidation: invalidating productKeys.all invalidates every key starting with ['products', ...].
Incorrect (keys defined inline, easy to drift):
// somewhere in a component
useQuery({ queryKey: ['products', filters], queryFn: () => fetchProducts(filters) });
// in a mutation, six files away
queryClient.invalidateQueries({ queryKey: ['product'] }); // ❌ typo, wrong key
// invalidation looks fine in code review; runtime: nothing invalidatesCorrect (one source of truth):
// src/queries/product-keys.ts
export const productKeys = {
all: ['products'] as const,
lists: () => [...productKeys.all, 'list'] as const,
list: (filters: ProductFilters) => [...productKeys.lists(), filters] as const,
details: () => [...productKeys.all, 'detail'] as const,
detail: (id: string) => [...productKeys.details(), id] as const,
};
// Reads
useQuery({ queryKey: productKeys.list(filters), queryFn: () => fetchProducts(filters) });
useQuery({ queryKey: productKeys.detail(id), queryFn: () => fetchProduct(id) });
// Mutation — surgical invalidation, no string typos possible
useMutation({
mutationFn: updateProduct,
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: productKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: productKeys.lists() }); // all list views
},
});
// Logout — nuke all product queries
queryClient.removeQueries({ queryKey: productKeys.all });Benefits:
- Type safety: TypeScript catches misuse at compile time
- Refactor confidence: rename a key, every reference updates with editor rename
- Hierarchical invalidation: invalidate at any level (
all>lists()>list(filters)) - Single grep location to audit cache structure
Reference: TanStack Query — Effective React Query Keys
Use Stale-While-Revalidate for Instant Renders
The SWR pattern (RFC 5861): when stale data exists, return it instantly and fetch fresh data in the background. The user sees the page immediately, then the cards smoothly update if the fresh data differs. The alternative — showing a skeleton while refetching — punishes the user for already having the data.
Both SWR and TanStack Query implement this; it's the default behavior when staleTime has elapsed but gcTime hasn't. The pattern's superpower: navigating back to a list you've seen renders the list immediately rather than the loading state.
Incorrect (block on revalidation — back-button shows skeleton):
function ProductList() {
const { data, isLoading } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
// No cached data exposed during revalidation
});
// After staleTime elapses, isLoading flips true on next mount even though we have data
if (isLoading) return <Skeleton />; // jarring flicker on every revisit
return <List products={data!} />;
}Correct (render stale instantly, fade in fresh):
function ProductList() {
const { data, isFetching, isPlaceholderData } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
staleTime: 60_000, // 1 min "fresh" window
placeholderData: keepPreviousData, // for paginated/filtered queries
});
// First mount: data is undefined → show skeleton
// Subsequent mounts: data is the cached value, isFetching may be true → render instantly
if (!data) return <Skeleton />;
return (
<>
<List products={data} className={isFetching ? 'opacity-90' : ''} />
{isFetching && <RefreshIndicator />}
</>
);
}For filtered/paginated lists (keep previous results during refetch):
const { data, isPlaceholderData } = useQuery({
queryKey: ['products', { filter }],
queryFn: () => fetchProducts(filter),
placeholderData: keepPreviousData, // when filter changes, show old results until new ones land
});
// Filtering doesn't flash an empty state; only after new data resolves does the list update.Server-side equivalent (HTTP header):
Cache-Control: public, max-age=0, stale-while-revalidate=600
# Browser/CDN serves stale data instantly and revalidates in the background up to 10 minReference: Vercel — Stale-While-Revalidate | RFC 5861
Tier staleTime by Data Volatility
One global staleTime is a compromise — too high for inventory (shows out-of-date stock), too low for the country list (refetched needlessly). Classify your data by volatility class and assign each class a staleTime. Real-time data (stock, price) gets seconds; user profile gets minutes; catalog/lookup data gets hours.
This tiering is also what HTTP caching does well — Cache-Control: max-age per endpoint type. Mirror that policy in your client cache.
Incorrect (one global staleTime — fights everything):
new QueryClient({
defaultOptions: { queries: { staleTime: 30_000 } },
});
// Inventory shown 30s stale → user adds to cart, gets "out of stock" at checkout
// Country list refetched every 30s → 100k pointless requests/day across usersCorrect (tiered, per data class):
// src/queries/stale-tiers.ts
export const STALE = {
realtime: 5_000, // 5s — stock, live price, online status
fresh: 30_000, // 30s — feed, notifications, cart contents
warm: 5 * 60_000, // 5m — user profile, settings, preferences
cold: 60 * 60_000, // 1h — product details, posts, comments
static: 24 * 60 * 60_000, // 24h — country list, categories, tag taxonomy
} as const;
useQuery({ queryKey: ['stock', id], queryFn: () => fetchStock(id), staleTime: STALE.realtime });
useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id), staleTime: STALE.warm });
useQuery({ queryKey: ['countries'], queryFn: fetchCountries, staleTime: STALE.static });Pair with server Cache-Control headers for full-stack consistency:
// API route handler
response.headers.set('Cache-Control', 'public, max-age=5, stale-while-revalidate=30'); // stock
response.headers.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=600'); // user
response.headers.set('Cache-Control', 'public, max-age=86400, immutable'); // countriesHow to pick a tier: look at the underlying source of truth. If it's a metric that updates per-second, use realtime. If it's a user-editable field, warm to cold. If it's reference data deployed with the app, static.
Reference: TkDodo — staleTime vs gcTime
Bound the In-Memory Working Set on Long Feeds
A user who scrolls through 50 pages of a feed accumulates 50 pages × 30 items = 1500 cached items, even with virtualization. The DOM is bounded by virtualization, but the JS heap is not — image objects, cached query results, and normalized entity records grow without limit. On mobile, this triggers OOM kills; on desktop, GC pauses cause scroll jank.
Bound the cache: after page N+5, drop the oldest pages from the infinite-query cache and the entity store. The user can always re-scroll; the entities they care about (favorites, viewed items) are kept separately.
Incorrect (unbounded growth — JS heap balloons on long scroll):
function Feed() {
const { data } = useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetchFeedPage(pageParam),
initialPageParam: null,
getNextPageParam: last => last.nextCursor,
// No maxPages — pages accumulate forever
});
}Correct (bounded with maxPages — old pages drop as new ones load):
function Feed() {
const { data } = useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetchFeedPage(pageParam),
initialPageParam: null,
getNextPageParam: last => last.nextCursor,
getPreviousPageParam: first => first.prevCursor,
maxPages: 8, // keep at most 8 pages × 30 items = 240 items in memory
});
// When fetchNextPage exceeds maxPages, the oldest page is dropped from data.pages
// If the user scrolls back, fetchPreviousPage refetches it
}Pair virtualization with cache eviction (memory-bounded scroll without losing position):
function BoundedFeed() {
const parentRef = useRef<HTMLDivElement>(null);
const { data, fetchNextPage, fetchPreviousPage, hasNextPage, hasPreviousPage } =
useInfiniteQuery({ /* ...with maxPages: 8, getPreviousPageParam ... */ });
const allItems = data?.pages.flatMap(p => p.items) ?? [];
const virtualizer = useVirtualizer({
count: allItems.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 120,
overscan: 5,
// When the user scrolls UP past the start of cached pages, fetch previous
onChange: (instance) => {
const first = instance.getVirtualItems()[0];
if (first && first.index < 5 && hasPreviousPage) fetchPreviousPage();
},
});
}For normalized entity stores (cap by recency + pinning):
// Pseudocode: LRU eviction with pinned items
class EntityStore<T extends { id: string }> {
private items = new Map<string, T>();
private order: string[] = []; // most-recent first
private pinned = new Set<string>(); // never evict (favorites, recently-viewed)
constructor(private maxSize: number) {}
set(id: string, value: T) {
this.items.set(id, value);
this.order = [id, ...this.order.filter(i => i !== id)];
this.evict();
}
pin(id: string) { this.pinned.add(id); }
private evict() {
while (this.order.length > this.maxSize) {
const candidate = [...this.order].reverse().find(id => !this.pinned.has(id));
if (!candidate) break;
this.items.delete(candidate);
this.order = this.order.filter(i => i !== candidate);
}
}
}Mobile-specific consideration: iOS Safari kills tabs at ~1GB JS heap. Native apps see OS-level memory warnings at ~150MB. Bound the working set aggressively on mobile: maxPages: 4 instead of 8, no large blurred-image placeholders.
Image-specific cleanup: even when items drop from the data cache, the browser may keep image bitmaps. For very long-running feeds, periodically clear URL.revokeObjectURL for blob: URLs you created, and avoid keeping references to image elements.
Reference: TanStack Query — maxPages
Use Cursor Pagination over Offset
Offset pagination (?page=2&size=20 or LIMIT 20 OFFSET 40) has two problems at scale: (1) the backend re-scans rows 0-39 just to return rows 40-59 — OFFSET 100000 is brutal on the database; (2) when new items are inserted between page fetches (very common in feeds), the user sees duplicate or skipped items because page boundaries shift. Cursor pagination uses an opaque cursor pointing to "the item after which to continue" — stable across inserts, indexable, fast.
Use cursors for any feed where new items can appear between page fetches.
Incorrect (offset pagination — duplicates and skips on inserts):
async function fetchFeedPage(page: number): Promise<{ items: Post[]; nextPage: number | null }> {
const res = await fetch(`/api/feed?page=${page}&size=20`);
return res.json();
}
// Backend: SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET ${page*20}
// User loads page 1 (newest 20 posts), then 5 new posts come in,
// then user loads page 2 — they see the last 5 from page 1 againCorrect (cursor pagination — stable across inserts):
type FeedPage = { items: Post[]; nextCursor: string | null };
async function fetchFeedPage(cursor: string | null): Promise<FeedPage> {
const params = new URLSearchParams({ size: '20' });
if (cursor) params.set('cursor', cursor);
const res = await fetch(`/api/feed?${params}`);
return res.json();
}
// Backend: SELECT * FROM posts WHERE created_at < ${cursor_date} ORDER BY created_at DESC LIMIT 20
// New inserts at the top don't shift the cursor's anchor pointWith TanStack Query's useInfiniteQuery:
function Feed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetchFeedPage(pageParam),
initialPageParam: null as string | null,
getNextPageParam: lastPage => lastPage.nextCursor,
});
const allItems = data?.pages.flatMap(p => p.items) ?? [];
// Items deduplicate cleanly across pages because cursors don't overlap
}Backend implementation patterns:
-- Cursor as a composite: (created_at, id) for tie-breaking
SELECT * FROM posts
WHERE (created_at, id) < ($cursorTime, $cursorId)
ORDER BY created_at DESC, id DESC
LIMIT 21; -- fetch one extra to detect "has next"
-- Encode cursor opaquely so clients can't manipulate it:
-- cursor = base64({ created_at, id })For real-time feeds (poll the "head" cursor):
// Periodically check for new items above the top — a "newer than cursor X" query
const { data: newItems } = useQuery({
queryKey: ['feed', 'newer-than', headCursor],
queryFn: () => fetchNewerThan(headCursor),
refetchInterval: 30_000,
enabled: !!headCursor,
});
// Surface as "5 new posts — tap to view" badge instead of inserting mid-scrollWhen offset is fine:
- Stable archives (blog post lists that don't change)
- Admin tables with deterministic sort + low insert rate
- Small data sets where
OFFSETperformance doesn't matter
Pitfall (don't expose internal DB IDs as cursors): if your cursor is ?cursor=12345, users learn that posts are sequential integers and probe gaps. Use opaque, signed cursors.
Reference: Use The Index, Luke — Pagination | TanStack Query — Infinite Queries
Defer Off-Screen Feed Images with Explicit Dimensions
A feed of 100 items each with a 200kb hero image means a 20MB download if everything loads up front — but most users scroll through ~10 items. Native loading="lazy" defers off-screen images until the user scrolls near them. Explicit width and height (or aspect-ratio) prevent layout shift when each image arrives — without dimensions, the page jumps as images load, breaking infinite scroll.
<img loading="lazy"> is supported in all evergreen browsers. For finer control, combine IntersectionObserver with a low-quality placeholder.
Incorrect (eager-load all images, no dimensions, layout shifts as they arrive):
function FeedItem({ item }: { item: Item }) {
return (
<article>
<img src={item.heroUrl} alt={item.title} />
{/* No width/height — height is 0 until image loads, then expands and pushes everything */}
<h3>{item.title}</h3>
</article>
);
}
// 100 items × 200kb = 20MB download on mount; CLS score destroyedCorrect (lazy-loaded with explicit aspect ratio):
function FeedItem({ item }: { item: Item }) {
return (
<article>
<img
src={item.heroUrl}
alt={item.title}
width={1200}
height={630}
loading="lazy"
decoding="async"
style={{ aspectRatio: '1200 / 630', objectFit: 'cover', width: '100%', height: 'auto' }}
/>
<h3>{item.title}</h3>
</article>
);
}
// Only images near viewport download; layout stable as images arriveWith Next.js Image (optimized + lazy + responsive):
import Image from 'next/image';
function FeedItem({ item }: { item: Item }) {
return (
<article>
<Image
src={item.heroUrl}
alt={item.title}
width={1200}
height={630}
sizes="(max-width: 768px) 100vw, 720px" // serve appropriately sized image
loading="lazy"
placeholder="blur"
blurDataURL={item.heroBlur} // tiny inline placeholder (~100 bytes)
/>
</article>
);
}For the first 1-3 items above the fold, eager-load:
{items.map((item, i) => (
<FeedItem
key={item.id}
item={item}
priority={i < 2} // first two get loading="eager" and fetchpriority="high"
/>
))}For carousels (off-screen but DOM-rendered):
// Native loading="lazy" only triggers on viewport intersection;
// horizontally-scrolled carousel items count as "in viewport" even when scrolled away
// → use IntersectionObserver on the carousel container insteadAvoid CLS pitfalls:
| Anti-pattern | Fix |
|---|---|
<img src="..." /> with no dimensions | Add width + height attributes |
| Skeleton without matching dimensions | Skeleton must occupy the same space as the loaded image |
| Late-arriving CSS that shrinks image | Set dimensions in HTML, not just CSS |
| Cross-origin images blocked by CORS while measuring | Use crossorigin="anonymous" if you'll need bitmap access |
Reference: MDN — Lazy loading | web.dev — Cumulative Layout Shift
Isolate Failure Across a Feed of Carousels
A homepage feed of 6 carousels (trending, recommended, recently-viewed, "people also bought", seasonal, personalized) means 6 independent recommender pipelines. At any given moment, one of them is degraded — a model is being redeployed, a data source is rate-limited, a personalization service is on call. Without isolation, one failing carousel cascades to break the whole feed (one Suspense root → all wait for the slowest; one ErrorBoundary root → one failure crashes everything).
The pattern: wrap each carousel in its own ErrorBoundary + Suspense, tier carousels by importance (critical, important, decorative), and apply tier-appropriate fallbacks. Combine with bounded concurrency at the fetch layer so 6 parallel summary fetches don't drown the user-initiated request happening at the same time.
This is the multi-section version of [[resilience-scoped-error-boundaries]] and [[render-suspense-per-section]], with the failure-tier logic from [[resilience-graceful-degradation]].
Incorrect (single boundary — one failure kills the entire feed):
function HomepageFeed() {
return (
<ErrorBoundary fallback={<FullPageError />}>
<Suspense fallback={<FullFeedSkeleton />}>
<ContinueWatchingCarousel />
<RecommendedCarousel />
<TrendingCarousel />
<RecentlyViewedCarousel /> {/* this one errors */}
<SeasonalCarousel />
{/* → entire homepage now shows FullPageError because one decorative
carousel had a 500. User sees nothing. */}
</Suspense>
</ErrorBoundary>
);
}Correct (per-carousel isolation with tier-appropriate fallbacks):
type Tier = 'critical' | 'important' | 'decorative';
const FEED: Array<{ kind: string; title: string; tier: Tier }> = [
{ kind: 'continue-watching', title: 'Continue watching', tier: 'critical' },
{ kind: 'recommended', title: 'Recommended for you', tier: 'important' },
{ kind: 'trending', title: 'Trending now', tier: 'important' },
{ kind: 'recently-viewed', title: 'Recently viewed', tier: 'decorative' },
{ kind: 'seasonal', title: 'Seasonal picks', tier: 'decorative' },
];
function HomepageFeed() {
return (
<div className="flex flex-col gap-12">
{FEED.map((cfg, i) => (
<ErrorBoundary
key={cfg.kind}
onError={(e) => reportError({ section: cfg.kind, tier: cfg.tier, error: e })}
fallbackRender={({ resetErrorBoundary }) => {
if (cfg.tier === 'decorative') return null; // silent
if (cfg.tier === 'important') return <MiniError onRetry={resetErrorBoundary} />;
return <FullError title={cfg.title} onRetry={resetErrorBoundary} />;
}}
>
<Suspense fallback={<CarouselSkeleton title={cfg.title} />}>
{i < 2
? <Carousel kind={cfg.kind} /> // above the fold — eager
: <DeferredCarousel kind={cfg.kind} />} // below the fold — viewport-triggered
</Suspense>
</ErrorBoundary>
))}
</div>
);
}Eager and deferred carousel variants:
// Above-the-fold — suspends on first fetch
function Carousel({ kind }: { kind: string }) {
const { data } = useSuspenseQuery({
queryKey: ['carousel', kind, 'summaries'],
queryFn: ({ signal }) => fetchCarouselSummaries(kind, { signal }),
staleTime: 5 * 60_000,
});
return <Track items={data} />;
}
// Below-the-fold — defer mounting (and fetching) until in viewport
function DeferredCarousel({ kind }: { kind: string }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const obs = new IntersectionObserver(
([e]) => e.isIntersecting && setVisible(true),
{ rootMargin: '300px' }
);
if (ref.current) obs.observe(ref.current);
return () => obs.disconnect();
}, []);
if (!visible) return <div ref={ref} style={{ height: 280 }} />; // reserve space
return <div ref={ref}><Carousel kind={kind} /></div>;
}Tier fallbacks table:
| Tier | On failure | Why |
|---|---|---|
critical | Full error UI with retry; visible | User came specifically for this content (Continue Watching) — they need to know it failed |
important | Minimal retry placeholder | Visible degradation maintains trust without breaking layout |
decorative | null (silently hidden) | Failure shouldn't punish the user for absent ad-side rails |
Concurrency guardrail at the fetch layer:
Six carousels mounting at once means six parallel summary requests, plus dozens of detail prefetches if items hover-prefetch. Cap parallelism so the feed degrades to "smooth but a bit slower" instead of "all timing out":
// At fetch wiring time — see [[protect-concurrency-limit-fanout]]
import { collapsedFetch } from './request-collapser';
// collapsedFetch caps at 6 concurrent requests + dedupes identical GETs
async function fetchCarouselSummaries(kind: string, init: { signal: AbortSignal }) {
const res = await collapsedFetch(`/api/carousels/${kind}/summaries`, init);
return res.json();
}Render-priority ordering matters:
Even with per-section Suspense, render order in the JSX determines reveal order in the SSR streaming case. Put critical carousels first — they get the first reserved layout slot and the first streamable chunk. Decorative carousels last, so their slow data sources don't push the critical ones below the fold.
Observability rule: silent failures are not invisible failures. Decorative carousels rendering null on failure must still emit a structured error to your logging stack (onError={reportError}) so an outage is detectable. A degraded recommender that quietly serves zero items for a week is a silent revenue leak.
When NOT to use this pattern:
- Single-carousel pages — the overhead of tiering buys nothing
- Synchronously-loaded feeds where every carousel ships in the initial HTML payload — Suspense isn't doing meaningful work
- Critical compliance flows where ANY missing data is a hard fail — there
tier: criticalisn't strong enough; refuse to render the page at all
Pair with [[feed-split-summary-from-detail]]: each carousel's summary fetch returns lightweight payloads; viewport-triggered detail fetches keep working-set bounded across the whole feed.
Reference: react-error-boundary | Vercel — Streaming with Suspense
Split Carousel Summaries from Item Details
A recommender carousel needs very little per item to render: ID, thumbnail URL, title, often a price. But the same backend endpoint often returns the full product object — description, variants, reviews, related products — bloating the payload. Split the API: one endpoint returns lightweight summaries (10x smaller); detail endpoints fetch the rest on hover, click, or viewport.
For carousel-heavy pages, this is the difference between a 200kb response and a 20kb response — and the difference between fetching 30 detail-laden objects for items 90% of users never click.
Incorrect (carousel fetches full objects up front):
function RecommendedCarousel() {
const { data } = useQuery({
queryKey: ['recs', 'full'],
queryFn: fetchFullRecommendations, // returns full product objects: 80kb for 30 items
});
return (
<Carousel>
{data?.map(p => (
<CarouselCard key={p.id} thumbnail={p.thumbnail} title={p.title} />
// Each card uses 3 fields out of ~25 — the other 22 fields are wasted bytes
))}
</Carousel>
);
}Correct (summary + on-demand detail):
// Summary type: just what the card renders
type ProductSummary = { id: string; thumbnail: string; title: string; price: number };
function RecommendedCarousel() {
const { data: summaries } = useQuery({
queryKey: ['recs', 'summary'],
queryFn: fetchRecommendationsSummary, // returns ProductSummary[]: 5kb for 30 items
});
return (
<Carousel>
{summaries?.map(s => (
<CarouselCard
key={s.id}
summary={s}
// Pre-fetch full detail on hover; quick-look popover uses it instantly
onMouseEnter={() => queryClient.prefetchQuery({
queryKey: ['product', s.id],
queryFn: () => fetchProduct(s.id),
staleTime: 60_000,
})}
/>
))}
</Carousel>
);
}
// When the user clicks for a quick-look
function QuickLook({ productId }: { productId: string }) {
const { data: product } = useQuery({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId),
});
return <DetailView product={product} />;
}API endpoint design pattern:
GET /api/recommendations
→ returns [{ id, thumbnail, title, price }, ...] # 5kb for 30 items
GET /api/products/:id
→ returns full product object # 4kb for one item
GET /api/products?ids=a,b,c
→ returns full product objects, bulk # 4kb × N for batched detailFor carousels with viewport-triggered detail (only items the user sees get detailed):
function CarouselCard({ summary }: { summary: ProductSummary }) {
const cardRef = useRef<HTMLDivElement>(null);
const [inViewport, setInViewport] = useState(false);
useEffect(() => {
const obs = new IntersectionObserver(([e]) => setInViewport(e.isIntersecting));
if (cardRef.current) obs.observe(cardRef.current);
return () => obs.disconnect();
}, []);
// Only fetch detail for items the user actually scrolls past
const { data: detail } = useQuery({
queryKey: ['product', summary.id],
queryFn: () => fetchProduct(summary.id),
enabled: inViewport,
staleTime: 60_000,
});
return <div ref={cardRef}>{/* render summary; show detail extras when present */}</div>;
}When NOT to split: if the summary is already most of the object (a tweet has very few fields beyond the visible content), the split costs more than it saves.
Cache normalization angle: when summaries and details overlap on the same fields (title, thumbnail), pair this with [[cache-normalize-shared-entities]] so fetching the detail upgrades the summary in place rather than holding two copies.
Reference: GraphQL — Fragments and Field Selection | Vercel — Optimizing Data Fetching
Use Stable Item Keys Across Paginated Pages
In a paginated/infinite feed, every time a new page arrives the parent re-renders with a longer items array. If item keys are stable (item.id), React reconciles in O(N+1) — existing rows reuse their fibers, only the new ones mount. If keys are index-based (key={i}), every row "moves" when the array grows from prepending or when virtual indices shift, triggering a full unmount/remount of every visible row.
Mount/unmount cycles re-trigger effects (refetches), re-create child query subscriptions, and reset internal state (form inputs, scroll positions inside items). For high-frequency feeds, key instability is a silent 5-10× cost multiplier on scroll updates.
Incorrect (index keys — entire list unmounts/remounts on new pages):
function Feed() {
const { data } = useInfiniteQuery({/* ... */});
const items = data?.pages.flatMap(p => p.items) ?? [];
return items.map((item, i) => <FeedRow key={i} item={item} />);
// After a "prepend new items" event, every existing row sees a different key
// → every row remounts → every embedded useQuery refires → every form resets
}Correct (entity ID keys — stable across mutations):
function Feed() {
const { data } = useInfiniteQuery({/* ... */});
const items = data?.pages.flatMap(p => p.items) ?? [];
return items.map(item => <FeedRow key={item.id} item={item} />);
// Even when 5 new items are prepended, the existing rows keep their fibers
}For items without natural IDs (e.g., aggregated rows in a report):
// Generate a stable composite key on the data side, not in render
const itemsWithKeys = useMemo(
() => items.map(item => ({ ...item, key: `${item.userId}-${item.date}` })),
[items]
);
// In render:
itemsWithKeys.map(item => <Row key={item.key} item={item} />);De-duplicate items across pages (cursor pagination can produce repeats at page edges):
const items = useMemo(() => {
const all = data?.pages.flatMap(p => p.items) ?? [];
// Dedup by id, keep first occurrence
const seen = new Set<string>();
return all.filter(item => seen.has(item.id) ? false : (seen.add(item.id), true));
}, [data]);Symptoms of unstable keys (debug checklist):
- Forms inside list items lose their input on every refresh
- Embedded queries refetch on every page load
- Animations restart on every parent update
- React DevTools shows mount/unmount on rows that visually stayed in place
Don't use random IDs: key={Math.random()} or key={crypto.randomUUID()} generated in render means every render produces new keys → every row remounts every render. This is the most extreme version of the bug.
Reference: React — Keeping list items in order with key
Related skills
FAQ
What does react-fetch-cache-patterns do?
react-fetch-cache-patterns is a Claude Code skill for frontend development.
When should I use react-fetch-cache-patterns?
When you need to helps with frontend development tasks during AI-assisted development., or when react-fetch-cache-patterns is a claude code skill for frontend development.
What are the main capabilities?
react-fetch-cache-patterns; Frontend Development; AI-coding skill.