
React Impl Data Fetching
- 11 installs
- 6 repo stars
- Updated July 8, 2026
- openaec-foundation/react-claude-skill-package
Helps with frontend development tasks.
About
react-impl-data-fetching is a Claude Code skill for frontend development. It helps solo builders move faster with AI-assisted development.
- react-impl-data-fetching
- Frontend Development
- AI-coding skill
React Impl Data Fetching by the numbers
- 11 all-time installs (skills.sh)
- Ranked #1,658 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/openaec-foundation/react-claude-skill-package --skill react-impl-data-fetchingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 11 |
|---|---|
| repo stars | ★ 6 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/react-claude-skill-package ↗ |
What it does
Helps with frontend development tasks.
Files
react-impl-data-fetching
Quick Reference
Data Fetching Strategy Decision Tree
Need to fetch data from an API?
├── Server state (remote data, shared, async)?
│ ├── YES → Use TanStack Query (RECOMMENDED)
│ │ ├── Read data → useQuery / useSuspenseQuery
│ │ ├── Write data → useMutation + invalidateQueries
│ │ └── Paginated → useInfiniteQuery
│ └── Using React Router? → Loader functions (route-level)
├── React 19 with Suspense architecture?
│ └── use() hook for reading cached promises
└── Simple one-off fetch (rare)?
└── useEffect with cleanup (LAST RESORT — see anti-patterns)TanStack Query Setup
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes before data is considered stale
gcTime: 1000 * 60 * 30, // 30 minutes before inactive data is garbage collected
retry: 3, // Retry failed requests 3 times
refetchOnWindowFocus: true, // Refetch when user returns to tab
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router />
</QueryClientProvider>
);
}Critical Warnings
NEVER fetch data in useEffect without a cleanup flag -- race conditions cause stale responses to overwrite fresh ones. Use TanStack Query instead.
NEVER manage server state with useState + useEffect -- you lose caching, deduplication, background refresh, and error retry for free.
NEVER create a new promise inside a component's render body when using React 19 use() -- the promise is recreated every render, causing infinite loops. ALWAYS cache the promise outside render.
NEVER call queryClient.invalidateQueries() without awaiting mutation completion -- invalidation before the server processes the mutation returns stale data.
ALWAYS wrap your app in QueryClientProvider with a QueryClient instance created OUTSIDE the component -- creating it inside causes a new client every render, destroying all cache.
ALWAYS use array-based queryKey values -- TanStack Query uses structural sharing for cache matching. Include all variables the query depends on.
---
useQuery: Reading Server Data
import { useQuery } from '@tanstack/react-query';
interface User {
id: number;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: number }) {
const { data, isLoading, isError, error, isFetching } = useQuery<User>({
queryKey: ['user', userId], // Cache key (MUST include all variables)
queryFn: () => fetchUser(userId), // Fetch function (MUST return a promise)
enabled: userId > 0, // Only fetch when condition is true
staleTime: 1000 * 60 * 5, // Data fresh for 5 minutes
gcTime: 1000 * 60 * 30, // Keep in cache 30 minutes after unmount
select: (data) => data.name, // Transform response (only re-renders on change)
placeholderData: { id: 0, name: 'Loading...', email: '' },
});
if (isLoading) return <Skeleton />;
if (isError) return <ErrorMessage error={error} />;
return <div>{data.name}</div>;
}Key useQuery Options
| Option | Type | Purpose |
|---|---|---|
queryKey | unknown[] | Unique cache key -- include ALL dependent variables |
queryFn | () => Promise<T> | Function that fetches data |
enabled | boolean | Disable query until condition is met |
staleTime | number | Milliseconds before data is considered stale |
gcTime | number | Milliseconds before inactive cache is garbage collected |
select | (data: T) => U | Transform or select from cached data |
placeholderData | `T \ | (prev) => T` |
retry | `number \ | boolean` |
refetchInterval | number | Poll interval in milliseconds |
---
useMutation: Writing Server Data
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreateUserForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newUser: { name: string; email: string }) =>
fetch('/api/users', {
method: 'POST',
body: JSON.stringify(newUser),
headers: { 'Content-Type': 'application/json' },
}).then((res) => res.json()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: (error) => {
console.error('Failed to create user:', error);
},
onSettled: () => {
// Runs after success OR error -- use for cleanup
},
});
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
mutation.mutate({
name: formData.get('name') as string,
email: formData.get('email') as string,
});
}
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create User'}
</button>
{mutation.isError && <p>Error: {mutation.error.message}</p>}
</form>
);
}---
Suspense Integration
useSuspenseQuery (TanStack Query v5+)
import { useSuspenseQuery } from '@tanstack/react-query';
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
function UserList() {
const { data } = useSuspenseQuery<User[]>({
queryKey: ['users'],
queryFn: fetchUsers,
});
// data is ALWAYS defined -- loading/error handled by Suspense/ErrorBoundary
return (
<ul>
{data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
// Parent handles loading and error states declaratively
function UsersPage() {
return (
<ErrorBoundary fallback={<p>Failed to load users.</p>}>
<Suspense fallback={<Skeleton />}>
<UserList />
</Suspense>
</ErrorBoundary>
);
}ALWAYS wrap useSuspenseQuery components in both <Suspense> and <ErrorBoundary> -- useSuspenseQuery throws promises (for Suspense) and errors (for ErrorBoundary).
React 19 use() Hook
import { use, Suspense, cache } from 'react';
// CORRECT: Cache the promise OUTSIDE render
const fetchUser = cache(async (id: number): Promise<User> => {
const res = await fetch(`/api/users/${id}`);
return res.json();
});
function UserProfile({ userId }: { userId: number }) {
const user = use(fetchUser(userId)); // Suspends until resolved
return <div>{user.name}</div>;
}
function App() {
return (
<Suspense fallback={<Skeleton />}>
<UserProfile userId={1} />
</Suspense>
);
}React 18: use() is NOT available. Use useSuspenseQuery from TanStack Query for Suspense-based data fetching.
React 19: use() can read promises and context. ALWAYS ensure the promise is cached (via cache(), useMemo, or module scope) to prevent re-creation on every render.
---
Caching Strategy
staleTime vs gcTime
| Setting | Controls | Default | Recommendation |
|---|---|---|---|
staleTime | How long data is "fresh" (no refetch) | 0 (always stale) | Set per query based on data volatility |
gcTime | How long inactive cache is kept in memory | 5 min | ALWAYS >= staleTime |
Query Invalidation
const queryClient = useQueryClient();
// Invalidate a specific query
queryClient.invalidateQueries({ queryKey: ['user', userId] });
// Invalidate all queries starting with 'users'
queryClient.invalidateQueries({ queryKey: ['users'] });
// Invalidate everything
queryClient.invalidateQueries();Prefetching
// Prefetch on hover for instant navigation
function UserLink({ userId }: { userId: number }) {
const queryClient = useQueryClient();
function handleMouseEnter() {
queryClient.prefetchQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 1000 * 60 * 5,
});
}
return (
<Link to={`/users/${userId}`} onMouseEnter={handleMouseEnter}>
View Profile
</Link>
);
}---
Optimistic Updates
const queryClient = useQueryClient();
const updateUser = useMutation({
mutationFn: (updatedUser: User) =>
fetch(`/api/users/${updatedUser.id}`, {
method: 'PUT',
body: JSON.stringify(updatedUser),
headers: { 'Content-Type': 'application/json' },
}).then((res) => res.json()),
onMutate: async (newUser) => {
// Cancel outgoing refetches to avoid overwriting optimistic update
await queryClient.cancelQueries({ queryKey: ['user', newUser.id] });
// Snapshot previous value for rollback
const previousUser = queryClient.getQueryData<User>(['user', newUser.id]);
// Optimistically update cache
queryClient.setQueryData(['user', newUser.id], newUser);
return { previousUser };
},
onError: (_err, newUser, context) => {
// Rollback on error
if (context?.previousUser) {
queryClient.setQueryData(['user', newUser.id], context.previousUser);
}
},
onSettled: (_data, _error, variables) => {
// ALWAYS refetch after mutation to ensure server truth
queryClient.invalidateQueries({ queryKey: ['user', variables.id] });
},
});---
Pagination with useInfiniteQuery
import { useInfiniteQuery } from '@tanstack/react-query';
interface PaginatedResponse {
items: User[];
nextCursor: string | null;
}
function UserListPaginated() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery<PaginatedResponse>({
queryKey: ['users'],
queryFn: ({ pageParam }) =>
fetch(`/api/users?cursor=${pageParam}`).then((res) => res.json()),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.items.map((user) => (
<UserCard key={user.id} user={user} />
))}
</div>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : hasNextPage ? 'Load More' : 'No more users'}
</button>
</div>
);
}---
Error Handling for Data Fetching
Query-Level Error Handling
const { data, error, isError } = useQuery({
queryKey: ['user', userId],
queryFn: fetchUser,
retry: 2, // Retry twice before surfacing error
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
if (isError) {
return <p>Error: {error.message}</p>;
}Declarative Error Boundaries
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
function DataSection() {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary }) => (
<div>
<p>Something went wrong.</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)}
>
<Suspense fallback={<Skeleton />}>
<UserList />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}---
Reference Links
- references/examples.md -- Complete data fetching patterns with TanStack Query and Suspense
- references/patterns.md -- Caching strategies, error handling, and advanced query patterns
- references/anti-patterns.md -- Data fetching mistakes and why they fail
Official Sources
- https://tanstack.com/query/latest/docs/framework/react/overview
- https://tanstack.com/query/latest/docs/framework/react/reference/useQuery
- https://tanstack.com/query/latest/docs/framework/react/reference/useMutation
- https://tanstack.com/query/latest/docs/framework/react/reference/useInfiniteQuery
- https://react.dev/reference/react/use
- https://react.dev/reference/react/Suspense
- https://react.dev/learn/you-might-not-need-an-effect
Data Fetching Anti-Patterns
Anti-Pattern 1: useEffect for Data Fetching Without Cleanup
// WRONG: Race condition -- stale responses overwrite fresh ones
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<Result[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setIsLoading(true);
fetchResults(query).then((data) => {
setResults(data); // Stale response may arrive AFTER a newer one
setIsLoading(false);
});
}, [query]);
return <ResultList results={results} />;
}WHY this fails: User types "react" quickly. Requests fire for "r", "re", "rea", "reac", "react". Responses arrive out of order. The response for "re" arrives last and overwrites the correct "react" results.
Additional problems with useEffect fetching:
- No request deduplication (5 components fetching the same data = 5 requests)
- No caching (navigating away and back refetches everything)
- No background refresh (data goes stale silently)
- No retry logic (one failure = permanent error state)
- No prefetching capability
- Requires manual loading/error state management
// CORRECT: Use TanStack Query
function SearchResults({ query }: { query: string }) {
const { data: results, isLoading } = useQuery({
queryKey: ['search', query],
queryFn: () => fetchResults(query),
enabled: query.length > 0,
});
return <ResultList results={results ?? []} />;
}---
Anti-Pattern 2: Managing Server State with useState
// WRONG: Manual server state management
function UserDashboard() {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
setError(null);
fetch('/api/users')
.then((res) => {
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
})
.then((data) => {
if (!cancelled) {
setUsers(data);
setIsLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err.message);
setIsLoading(false);
}
});
return () => { cancelled = true; };
}, []);
// ... 20+ lines just for loading/error/data handling
}WHY this fails:
- 15+ lines of boilerplate for EVERY data fetch
- Data is stale after first load (no background refresh)
- No cache sharing between components (each component fetches independently)
- Manual cancellation logic is error-prone
- No retry mechanism
- Loading state resets on every refetch
// CORRECT: TanStack Query handles ALL of this
function UserDashboard() {
const { data: users, isLoading, isError, error } = useQuery({
queryKey: ['users'],
queryFn: async () => {
const res = await fetch('/api/users');
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
},
});
if (isLoading) return <Skeleton />;
if (isError) return <p>Error: {error.message}</p>;
return <UserList users={users} />;
}---
Anti-Pattern 3: Creating Promises in Render (React 19 use())
// WRONG: New promise created every render = infinite loop
function UserProfile({ userId }: { userId: number }) {
// This creates a NEW promise on every render
const userPromise = fetch(`/api/users/${userId}`).then((r) => r.json());
const user = use(userPromise); // Suspends, re-renders, creates new promise, suspends again...
return <div>{user.name}</div>;
}WHY this fails: use() suspends the component. When it resumes, the component re-renders. The re-render creates a new promise (different reference). React sees a new promise and suspends again. Infinite loop.
// CORRECT: Cache the promise outside render
import { cache } from 'react';
const fetchUser = cache(async (id: number): Promise<User> => {
const res = await fetch(`/api/users/${id}`);
return res.json();
});
function UserProfile({ userId }: { userId: number }) {
const user = use(fetchUser(userId)); // Same promise reference on re-render
return <div>{user.name}</div>;
}---
Anti-Pattern 4: QueryClient Inside Component
// WRONG: New QueryClient every render, cache destroyed
function App() {
const queryClient = new QueryClient(); // Created on EVERY render
return (
<QueryClientProvider client={queryClient}>
<Router />
</QueryClientProvider>
);
}WHY this fails: Every render creates a new QueryClient, which means a new empty cache. All queries refetch. All cached data is lost. Performance is terrible.
// CORRECT: Create QueryClient outside the component
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router />
</QueryClientProvider>
);
}
// ALSO CORRECT: useState for lazy initialization (useful for SSR)
function App() {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
<Router />
</QueryClientProvider>
);
}---
Anti-Pattern 5: Invalidating Before Mutation Completes
// WRONG: Invalidation races with the mutation
const mutation = useMutation({
mutationFn: updateUser,
onMutate: () => {
// Invalidating HERE runs BEFORE the server processes the update
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});WHY this fails: onMutate fires BEFORE the mutation request is sent. The refetch triggered by invalidation returns the OLD data because the server has not processed the update yet.
// CORRECT: Invalidate in onSuccess or onSettled
const mutation = useMutation({
mutationFn: updateUser,
onSuccess: () => {
// Server has processed the update -- safe to refetch
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});---
Anti-Pattern 6: Missing Variables in queryKey
// WRONG: queryKey does not include the filter
function ProductList({ category }: { category: string }) {
const { data } = useQuery({
queryKey: ['products'], // Same key for ALL categories
queryFn: () => fetchProducts(category),
});
// Switching category does NOT trigger a refetch -- cache returns wrong data
}WHY this fails: TanStack Query uses the queryKey to determine cache identity. If the key does not change, the query does not refetch. Switching from "electronics" to "clothing" returns the cached "electronics" data.
// CORRECT: Include ALL variables in queryKey
function ProductList({ category }: { category: string }) {
const { data } = useQuery({
queryKey: ['products', category], // Different key per category
queryFn: () => fetchProducts(category),
});
}---
Anti-Pattern 7: Fetching in Event Handlers Without Mutation
// WRONG: Manual fetch in event handler, bypassing cache
async function handleDelete(userId: number) {
await fetch(`/api/users/${userId}`, { method: 'DELETE' });
// Cache is now stale but TanStack Query does not know
// UI still shows the deleted user
window.location.reload(); // Desperate reload to fix stale UI
}// CORRECT: Use useMutation for all write operations
const deleteMutation = useMutation({
mutationFn: (userId: number) =>
fetch(`/api/users/${userId}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
function handleDelete(userId: number) {
deleteMutation.mutate(userId);
}---
Anti-Pattern 8: Not Handling Loading and Error States
// WRONG: Assumes data is always available
function UserProfile({ userId }: { userId: number }) {
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
// TypeError: Cannot read property 'name' of undefined
return <h1>{data.name}</h1>;
}// CORRECT: Handle all states
function UserProfile({ userId }: { userId: number }) {
const { data, isLoading, isError, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
if (isLoading) return <Skeleton />;
if (isError) return <ErrorMessage error={error} />;
return <h1>{data.name}</h1>;
}
// ALSO CORRECT: Use useSuspenseQuery (data is always defined)
function UserProfile({ userId }: { userId: number }) {
const { data } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
// data is ALWAYS defined -- Suspense handles loading, ErrorBoundary handles errors
return <h1>{data.name}</h1>;
}---
Anti-Pattern 9: Waterfall Requests
// WRONG: Sequential fetching creates a waterfall
function Dashboard() {
const { data: user } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
});
// This does not start until user is loaded -- even though they are independent
const { data: posts } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
enabled: !!user, // Unnecessary dependency
});
const { data: notifications } = useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
enabled: !!posts, // Unnecessary dependency
});
}WHY this fails: Each query waits for the previous one. Total time = fetch1 + fetch2 + fetch3. These queries are independent and should run in parallel.
// CORRECT: Independent queries fire in parallel
function Dashboard() {
const userQuery = useQuery({ queryKey: ['user'], queryFn: fetchUser });
const postsQuery = useQuery({ queryKey: ['posts'], queryFn: fetchPosts });
const notifQuery = useQuery({ queryKey: ['notifications'], queryFn: fetchNotifications });
// Total time = max(fetch1, fetch2, fetch3) instead of sum
}
// Use `enabled` ONLY when a query truly depends on another query's result---
Anti-Pattern 10: Forgetting AbortSignal for Cancellation
// WRONG: Request continues even after component unmount or key change
const { data } = useQuery({
queryKey: ['search', query],
queryFn: async () => {
const res = await fetch(`/api/search?q=${query}`);
return res.json();
},
});// CORRECT: Pass signal for automatic cancellation
const { data } = useQuery({
queryKey: ['search', query],
queryFn: async ({ signal }) => {
const res = await fetch(`/api/search?q=${query}`, { signal });
if (!res.ok) throw new Error('Search failed');
return res.json();
},
});WHY this matters: Without the signal, changing the search term fires a new request but the old one continues running. The old response may arrive after the new one and TanStack Query has no way to cancel it. With signal, the browser aborts the previous request automatically.
---
Summary Table
| Anti-Pattern | Problem | Solution |
|---|---|---|
| useEffect for fetching | Race conditions, no cache, no dedup | TanStack Query useQuery |
| useState for server state | Stale data, no background refresh | TanStack Query useQuery |
| Promise created in render | Infinite loop with use() | cache() or module-scope promise |
| QueryClient inside component | Cache destroyed every render | Create outside component |
| Invalidate before mutation | Refetch returns old data | Invalidate in onSuccess |
| Missing variables in queryKey | Wrong cached data returned | Include ALL variables in key |
| Manual fetch for writes | Cache goes stale, UI inconsistent | useMutation + invalidation |
| No loading/error handling | Runtime errors on undefined data | Check isLoading/isError or use Suspense |
| Waterfall requests | Slow page loads | Parallel independent queries |
| Missing AbortSignal | Wasted requests, potential stale data | Pass signal to fetch |
Data Fetching Examples
Complete TanStack Query Setup
Installation
npm install @tanstack/react-query
# Optional but recommended: DevTools
npm install @tanstack/react-query-devtoolsApp-Level Provider with DevTools
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// ALWAYS create QueryClient OUTSIDE the component
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 30,
retry: 3,
refetchOnWindowFocus: true,
},
mutations: {
retry: 1,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}---
Basic CRUD with TanStack Query
Fetch a Single Resource
import { useQuery } from '@tanstack/react-query';
interface Product {
id: number;
name: string;
price: number;
category: string;
}
async function fetchProduct(id: number): Promise<Product> {
const res = await fetch(`/api/products/${id}`);
if (!res.ok) throw new Error(`Failed to fetch product: ${res.statusText}`);
return res.json();
}
function ProductDetail({ productId }: { productId: number }) {
const {
data: product,
isLoading,
isError,
error,
} = useQuery<Product>({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId),
enabled: productId > 0,
});
if (isLoading) return <ProductSkeleton />;
if (isError) return <p>Error: {error.message}</p>;
return (
<article>
<h1>{product.name}</h1>
<p>${product.price.toFixed(2)}</p>
<span>{product.category}</span>
</article>
);
}Fetch a List with Filtering
interface ProductFilters {
category?: string;
minPrice?: number;
maxPrice?: number;
}
function useProducts(filters: ProductFilters) {
return useQuery<Product[]>({
// ALWAYS include filter values in queryKey for correct caching
queryKey: ['products', filters],
queryFn: async () => {
const params = new URLSearchParams();
if (filters.category) params.set('category', filters.category);
if (filters.minPrice) params.set('minPrice', String(filters.minPrice));
if (filters.maxPrice) params.set('maxPrice', String(filters.maxPrice));
const res = await fetch(`/api/products?${params}`);
if (!res.ok) throw new Error('Failed to fetch products');
return res.json();
},
});
}
function ProductList() {
const [filters, setFilters] = useState<ProductFilters>({ category: 'all' });
const { data: products, isLoading } = useProducts(filters);
return (
<div>
<FilterBar value={filters} onChange={setFilters} />
{isLoading ? (
<Skeleton count={6} />
) : (
<ul>
{products?.map((p) => (
<li key={p.id}>{p.name} - ${p.price}</li>
))}
</ul>
)}
</div>
);
}Create a Resource
import { useMutation, useQueryClient } from '@tanstack/react-query';
function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newProduct: Omit<Product, 'id'>): Promise<Product> => {
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newProduct),
});
if (!res.ok) throw new Error('Failed to create product');
return res.json();
},
onSuccess: () => {
// Invalidate all product queries to refetch with new data
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
}
function CreateProductForm() {
const createProduct = useCreateProduct();
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
createProduct.mutate({
name: formData.get('name') as string,
price: Number(formData.get('price')),
category: formData.get('category') as string,
});
}
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="Product name" required />
<input name="price" type="number" step="0.01" required />
<input name="category" placeholder="Category" required />
<button type="submit" disabled={createProduct.isPending}>
{createProduct.isPending ? 'Creating...' : 'Create Product'}
</button>
{createProduct.isError && <p>Error: {createProduct.error.message}</p>}
{createProduct.isSuccess && <p>Product created!</p>}
</form>
);
}Update a Resource with Optimistic Update
function useUpdateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (product: Product): Promise<Product> => {
const res = await fetch(`/api/products/${product.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(product),
});
if (!res.ok) throw new Error('Failed to update product');
return res.json();
},
onMutate: async (updatedProduct) => {
await queryClient.cancelQueries({ queryKey: ['product', updatedProduct.id] });
const previous = queryClient.getQueryData<Product>(['product', updatedProduct.id]);
queryClient.setQueryData(['product', updatedProduct.id], updatedProduct);
return { previous };
},
onError: (_err, updatedProduct, context) => {
if (context?.previous) {
queryClient.setQueryData(['product', updatedProduct.id], context.previous);
}
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: ['product', variables.id] });
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
}Delete a Resource
function useDeleteProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (productId: number): Promise<void> => {
const res = await fetch(`/api/products/${productId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete product');
},
onSuccess: (_data, productId) => {
// Remove from individual cache
queryClient.removeQueries({ queryKey: ['product', productId] });
// Refetch list
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
}---
Suspense-Based Data Fetching
With TanStack Query (useSuspenseQuery)
import { useSuspenseQuery } from '@tanstack/react-query';
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
function UserProfile({ userId }: { userId: number }) {
// data is ALWAYS defined -- Suspense handles loading, ErrorBoundary handles errors
const { data: user } = useSuspenseQuery<User>({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
function UserPosts({ userId }: { userId: number }) {
const { data: posts } = useSuspenseQuery<Post[]>({
queryKey: ['posts', userId],
queryFn: () => fetchUserPosts(userId),
});
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
// Parallel Suspense: both queries suspend independently
function UserPage({ userId }: { userId: number }) {
return (
<ErrorBoundary fallback={<p>Failed to load user data.</p>}>
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile userId={userId} />
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<UserPosts userId={userId} />
</Suspense>
</ErrorBoundary>
);
}With React 19 use() Hook
import { use, Suspense, cache } from 'react';
// Cache the fetch function so the same promise is returned for the same args
const fetchUser = cache(async (id: number): Promise<User> => {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
});
function UserCard({ userId }: { userId: number }) {
const user = use(fetchUser(userId));
return <div>{user.name} ({user.email})</div>;
}
function App() {
return (
<Suspense fallback={<p>Loading user...</p>}>
<UserCard userId={1} />
</Suspense>
);
}---
Infinite Scroll / Load More
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
import { useEffect } from 'react';
interface Page {
items: Product[];
nextCursor: string | null;
totalCount: number;
}
function InfiniteProductList() {
const { ref, inView } = useInView();
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
error,
} = useInfiniteQuery<Page>({
queryKey: ['products', 'infinite'],
queryFn: async ({ pageParam }): Promise<Page> => {
const res = await fetch(`/api/products?cursor=${pageParam}&limit=20`);
if (!res.ok) throw new Error('Failed to fetch products');
return res.json();
},
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
// Auto-fetch next page when sentinel enters viewport
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
if (isLoading) return <Skeleton count={10} />;
if (isError) return <p>Error: {error.message}</p>;
const allProducts = data.pages.flatMap((page) => page.items);
return (
<div>
<ul>
{allProducts.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
{/* Sentinel element for intersection observer */}
<div ref={ref}>
{isFetchingNextPage && <Spinner />}
</div>
</div>
);
}---
Dependent Queries
function UserDashboard({ userId }: { userId: number }) {
// First query: fetch user
const { data: user } = useQuery<User>({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
// Second query: fetch user's projects (depends on user.teamId)
const { data: projects } = useQuery<Project[]>({
queryKey: ['projects', user?.teamId],
queryFn: () => fetchTeamProjects(user!.teamId),
// ONLY runs when user is loaded and teamId is available
enabled: !!user?.teamId,
});
if (!user) return <Skeleton />;
return (
<div>
<h1>{user.name}</h1>
{projects ? (
<ProjectList projects={projects} />
) : (
<p>Loading projects...</p>
)}
</div>
);
}---
Custom Query Hook Pattern
ALWAYS extract queries into custom hooks for reusability and testability.
// hooks/useUser.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: number) => [...userKeys.details(), id] as const,
};
export function useUser(userId: number) {
return useQuery<User>({
queryKey: userKeys.detail(userId),
queryFn: () => fetchUser(userId),
enabled: userId > 0,
});
}
export function useUsers(filters: UserFilters = {}) {
return useQuery<User[]>({
queryKey: userKeys.list(filters),
queryFn: () => fetchUsers(filters),
});
}
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateUser,
onSuccess: (data) => {
queryClient.setQueryData(userKeys.detail(data.id), data);
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
});
}---
Polling and Real-Time Data
function LiveDashboard() {
const { data: metrics } = useQuery<DashboardMetrics>({
queryKey: ['dashboard', 'metrics'],
queryFn: fetchDashboardMetrics,
refetchInterval: 5000, // Poll every 5 seconds
refetchIntervalInBackground: false, // Stop polling when tab is hidden
});
return <MetricsDisplay data={metrics} />;
}---
Prefetching on Route Change
import { useQueryClient } from '@tanstack/react-query';
function Navigation() {
const queryClient = useQueryClient();
function prefetchUserProfile(userId: number) {
queryClient.prefetchQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 1000 * 60 * 5,
});
}
return (
<nav>
{users.map((user) => (
<Link
key={user.id}
to={`/users/${user.id}`}
onMouseEnter={() => prefetchUserProfile(user.id)}
>
{user.name}
</Link>
))}
</nav>
);
}Data Fetching Patterns — Caching and Error Handling
Caching Strategies
Understanding staleTime and gcTime
Timeline of a query lifecycle:
[Mount]──fresh──[staleTime]──stale──[Unmount]──inactive──[gcTime]──[Garbage Collected]
│
Background refetch on:
- Window focus
- Network reconnect
- refetchInterval
- Manual invalidation| Setting | What It Controls | Default | Effect |
|---|---|---|---|
staleTime: 0 | Data is immediately stale | Yes | Refetches on every mount, focus, reconnect |
staleTime: Infinity | Data never goes stale | No | NEVER refetches automatically |
staleTime: 60_000 | Fresh for 1 minute | No | No refetch within 1 minute of last fetch |
gcTime: 300_000 | Cache lives 5 min after unmount | Yes | Inactive cache removed after 5 minutes |
gcTime: 0 | Cache removed immediately on unmount | No | No cache benefit between navigations |
gcTime: Infinity | Cache lives forever | No | Memory grows unbounded -- use with caution |
Setting Defaults per Query Type
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 min default
gcTime: 1000 * 60 * 30, // 30 min default
},
},
});
// Override per query for different data volatility
const { data: config } = useQuery({
queryKey: ['app-config'],
queryFn: fetchAppConfig,
staleTime: Infinity, // Config rarely changes
});
const { data: notifications } = useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
staleTime: 1000 * 30, // 30 seconds -- notifications change frequently
});---
Query Key Strategies
Hierarchical Keys
ALWAYS structure query keys hierarchically so invalidation works at the right granularity.
// Key factory pattern (RECOMMENDED)
const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const,
list: (filters: TodoFilters) => [...todoKeys.lists(), filters] as const,
details: () => [...todoKeys.all, 'detail'] as const,
detail: (id: number) => [...todoKeys.details(), id] as const,
};
// Usage
useQuery({ queryKey: todoKeys.detail(5), ... });
useQuery({ queryKey: todoKeys.list({ status: 'done' }), ... });
// Invalidation at different levels
queryClient.invalidateQueries({ queryKey: todoKeys.all }); // ALL todo queries
queryClient.invalidateQueries({ queryKey: todoKeys.lists() }); // All todo lists
queryClient.invalidateQueries({ queryKey: todoKeys.detail(5) }); // Single todoKey Matching Rules
TanStack Query uses prefix matching for invalidation:
| queryKey | invalidateQueries({ queryKey: ['todos'] }) matches? |
|---|---|
['todos'] | Yes |
['todos', 'list'] | Yes |
['todos', 'detail', 5] | Yes |
['users'] | No |
---
Error Handling Patterns
Pattern 1: Per-Query Error Handling
function UserProfile({ userId }: { userId: number }) {
const { data, isError, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
retry: (failureCount, error) => {
// Do NOT retry 404s
if (error instanceof Response && error.status === 404) return false;
return failureCount < 3;
},
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
if (isError) {
return <InlineError message={error.message} />;
}
return <div>{data?.name}</div>;
}Pattern 2: Global Error Handler
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 3,
},
},
queryCache: new QueryCache({
onError: (error, query) => {
// Global error handler for all queries
if (error instanceof Response && error.status === 401) {
redirectToLogin();
}
console.error(`Query ${query.queryKey} failed:`, error);
},
}),
mutationCache: new MutationCache({
onError: (error) => {
toast.error(`Operation failed: ${error.message}`);
},
}),
});Pattern 3: Error Boundaries with Query Reset
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
function PageWithErrorRecovery() {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ error, resetErrorBoundary }) => (
<div role="alert">
<h2>Something went wrong</h2>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)}
>
<Suspense fallback={<PageSkeleton />}>
<PageContent />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}Pattern 4: Typed Error Handling
class ApiError extends Error {
constructor(
message: string,
public status: number,
public code: string,
) {
super(message);
this.name = 'ApiError';
}
}
async function fetchWithErrorHandling<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new ApiError(
body.message || res.statusText,
res.status,
body.code || 'UNKNOWN',
);
}
return res.json();
}
// Usage with typed error
const { error } = useQuery<User, ApiError>({
queryKey: ['user', userId],
queryFn: () => fetchWithErrorHandling<User>(`/api/users/${userId}`),
});
if (error) {
switch (error.status) {
case 404: return <NotFound />;
case 403: return <Forbidden />;
default: return <GenericError message={error.message} />;
}
}---
Loading State Patterns
Pattern 1: Skeleton Loading (Non-Suspense)
function UserCard({ userId }: { userId: number }) {
const { data, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
if (isLoading) return <UserCardSkeleton />;
return (
<div>
<h2>{data!.name}</h2>
<p>{data!.email}</p>
</div>
);
}Pattern 2: Previous Data While Loading (placeholderData)
import { keepPreviousData } from '@tanstack/react-query';
function ProductList({ page }: { page: number }) {
const { data, isPlaceholderData } = useQuery({
queryKey: ['products', page],
queryFn: () => fetchProducts(page),
placeholderData: keepPreviousData, // Show previous page while loading next
});
return (
<div style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
{data?.map((p) => <ProductCard key={p.id} product={p} />)}
</div>
);
}Pattern 3: Background Refetch Indicator
function DataList() {
const { data, isFetching, isLoading } = useQuery({
queryKey: ['items'],
queryFn: fetchItems,
});
return (
<div>
{/* Show spinner only on first load */}
{isLoading && <FullPageSpinner />}
{/* Show subtle indicator for background refetch */}
{isFetching && !isLoading && <RefetchIndicator />}
{data?.map((item) => <ItemRow key={item.id} item={item} />)}
</div>
);
}---
Parallel and Sequential Queries
Parallel Queries
function Dashboard() {
// These three queries fire simultaneously
const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
const postsQuery = useQuery({ queryKey: ['posts'], queryFn: fetchPosts });
const statsQuery = useQuery({ queryKey: ['stats'], queryFn: fetchStats });
const isLoading = usersQuery.isLoading || postsQuery.isLoading || statsQuery.isLoading;
if (isLoading) return <DashboardSkeleton />;
return (
<div>
<UserWidget users={usersQuery.data!} />
<PostWidget posts={postsQuery.data!} />
<StatsWidget stats={statsQuery.data!} />
</div>
);
}Dynamic Parallel Queries with useQueries
import { useQueries } from '@tanstack/react-query';
function UserAvatars({ userIds }: { userIds: number[] }) {
const queries = useQueries({
queries: userIds.map((id) => ({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
staleTime: 1000 * 60 * 5,
})),
});
const isLoading = queries.some((q) => q.isLoading);
if (isLoading) return <AvatarSkeleton count={userIds.length} />;
return (
<div>
{queries.map((q, i) => (
<Avatar key={userIds[i]} user={q.data!} />
))}
</div>
);
}---
Window Focus and Network Refetching
Default Behavior
TanStack Query automatically refetches stale queries when:
- The browser window regains focus (
refetchOnWindowFocus: true) - The network reconnects (
refetchOnReconnect: true) - A component using the query mounts (
refetchOnMount: true)
Customizing Refetch Behavior
const { data } = useQuery({
queryKey: ['dashboard'],
queryFn: fetchDashboard,
refetchOnWindowFocus: 'always', // Refetch even if not stale
refetchOnReconnect: true, // Refetch on network restore
refetchOnMount: false, // Do NOT refetch when component mounts
refetchInterval: 30_000, // Poll every 30 seconds
refetchIntervalInBackground: false, // Stop polling when tab not visible
});---
Query Cancellation
const { data } = useQuery({
queryKey: ['search', searchTerm],
queryFn: async ({ signal }) => {
// Pass AbortSignal to fetch for automatic cancellation
const res = await fetch(`/api/search?q=${searchTerm}`, { signal });
if (!res.ok) throw new Error('Search failed');
return res.json();
},
});TanStack Query automatically cancels in-flight queries when:
- The component unmounts
- The query key changes (new search triggers cancellation of previous)
- Manual cancellation via
queryClient.cancelQueries()
ALWAYS pass the signal parameter from queryFn context to your fetch calls to enable automatic cancellation.