| name | react-impl-data-fetching |
| description | Use when fetching data from APIs, managing server state, implementing loading and error states, or choosing a data fetching strategy. Prevents the common mistake of fetching in useEffect without proper caching or race condition handling. Covers TanStack Query, useQuery, useMutation, Suspense, React 19 use() hook, caching, optimistic updates, pagination. Keywords: TanStack Query, useQuery, useMutation, fetch, use(), server state, Axios, API call, fetch data, loading spinner, how to call API, server state, caching API..
|
| license | MIT |
| compatibility | Designed for Claude Code. Requires React 18.x or 19.x with TypeScript. |
| metadata | {"author":"OpenAEC-Foundation","version":"1.0"} |
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,
gcTime: 1000 * 60 * 30,
retry: 3,
refetchOnWindowFocus: true,
},
},
});
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],
queryFn: () => fetchUser(userId),
enabled: userId > 0,
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 30,
select: (data) => data.name,
placeholderData: { id: 0, name: 'Loading...', email: '' },
});
if (isLoading) ;
(isError) ;
;
}
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 | Show while loading (no Suspense trigger) |
retry | number | boolean | Number of retry attempts on failure |
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: () => {
},
});
function handleSubmit(e: React.FormEvent<>) {
e.();
formData = (e.);
mutation.({
: formData.() ,
: formData.() ,
});
}
(
);
}
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,
});
return (
<ul>
{data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
function UsersPage() {
return (
<ErrorBoundary fallback={<p>Failed to load users.</p>}>
<Suspense fallback={<Skeleton />}>
<UserList />
</Suspense>
</>
);
}
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';
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));
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();
queryClient.invalidateQueries({ queryKey: ['user', userId] });
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries();
Prefetching
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) => {
await queryClient.cancelQueries({ queryKey: ['user', newUser.id] });
const previousUser = queryClient.getQueryData<User>(['user', newUser.id]);
queryClient.setQueryData(['user', newUser.id], newUser);
return { previousUser };
},
onError: (_err, newUser, context) => {
if (context?.) {
queryClient.([, newUser.], context.);
}
},
: {
queryClient.({ : [, variables.] });
},
});
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'}
);
}
Error Handling for Data Fetching
Query-Level Error Handling
const { data, error, isError } = useQuery({
queryKey: ['user', userId],
queryFn: fetchUser,
retry: 2,
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
Official Sources