React 64 ⚛️ Fetching and Caching Data with useQuery
The useQuery hook is the entry point to every data-fetching component in a TanStack Query application. It takes a query key, a query function, and a set of options, and it returns the data, the loading state, the error state, and a set of methods for refetching and cache manipulation. The hook handles the caching, the deduplication, the background refetching, and the retry logic, so the component only worries about rendering the data it receives. This chapter covers the hook’s API, the query key design, the state flags, and the patterns that make the caching predictable.
Key point: The queryKey is the cache identifier. Two components that use the same key share the same cache entry, the same data, and the same refetch. Two components that use different keys have independent cache entries. The key is the design decision, and it determines what is shared.
Why useQuery exists
The useEffect + useState problem. A component that fetches data on mount runs the fetch every time it mounts. The state tracks three variables: the data, the loading flag, and the error. The pattern is verbose, and it produces stale data, redundant requests, and loading spinners on every mount.
The caching problem. The result of a fetch is not stored anywhere. The next component that needs the same data must fetch it again. TanStack Query stores the result in the cache, keyed by the query key, and serves it to any component that asks.
The deduplication problem. Two components that request the same data at the same time fire two network requests. TanStack Query deduplicates them: the second request waits for the first, and both components receive the same result.
The stale-while-revalidate problem. Data is not always stale. The hook serves the cached data immediately, and if the data is stale, it refetches in the background. The user sees the cached data without a loading spinner, and the fresh data replaces it when the refetch completes.
The state problem. The hook tracks the data, the pending state, the fetching state, the error, and the status. The component destructures the flags it needs, and it does not manage the state itself.
The retry problem. A failed fetch is retried according to the retry option. The default is three attempts with exponential backoff. The retry logic is in the hook, not in the component.
The refetch problem. The data can be refetched on window focus, on reconnect, on mount, or on a manual trigger. The options control the behavior, and the refetch method triggers it manually.
a. The query key
The queryKey is an array that uniquely identifies the query. The array form allows hierarchical keys and invalidation by prefix.
The simple key
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
The key is an array with one element. The query is identified as “the todos list.”
The parameterized key
The key includes the parameters that affect the result.
useQuery({
queryKey: ['todo', todoId],
queryFn: () => fetchTodo(todoId),
});
The key ['todo', 42] is different from ['todo', 43]. The two are separate cache entries.
The hierarchical key
The key can be nested to represent a hierarchy.
['users', userId, 'posts'];
['users', userId, 'posts', postId];
The ['users', 42, 'posts'] key is the list of posts for the user. The ['users', 42, 'posts', 7] key is the specific post.
The serialization
The key is serialized to a string with a deterministic hash. The objects in the key are hashed by value, not by reference. Two keys with the same values are the same key, even if the array objects are different.
// The same key
['todos', { page: 1, filter: 'all' }]
['todos', { filter: 'all', page: 1 }]
The order of the object properties does not matter. The keys are the same.
The key factory
A common pattern is a key factory that centralizes the key construction.
const todoKeys = {
all: ['todos'],
lists: () => [...todoKeys.all, 'list'],
list: (filters) => [...todoKeys.lists(), filters],
details: () => [...todoKeys.all, 'detail'],
detail: (id) => [...todoKeys.details(), id],
};
useQuery({
queryKey: todoKeys.detail(todoId),
queryFn: () => fetchTodo(todoId),
});
The factory makes the keys consistent and refactorable. The todoKeys.all prefix is the base for the invalidation.
The invalidation by prefix
The invalidateQueries method invalidates every query whose key starts with the given prefix.
queryClient.invalidateQueries({ queryKey: ['todos'] });
The call invalidates ['todos'], ['todos', 1], and ['todos', 1, 'comments']. The prefix matching is the reason the keys are hierarchical.
The query key rules
| Rule | Description |
|---|---|
| The key is an array | The array form is required |
| The key is deterministic | The same values produce the same key |
| The key is serializable | The values must be JSON-serializable |
| The key is hierarchical | The prefix is the invalidation scope |
| The key includes the dependencies | The parameters that affect the result |
The key anti-patterns
| Anti-pattern | Problem |
|---|---|
| A plain string | Use the array form |
| A random value | The cache is never reused |
| The entire object | The key is too specific |
| The function | The key is not serializable |
| The stale closure | The key does not include the dependency |
b. The query function
The queryFn is the function that fetches the data. It returns a promise, and the resolved value is the data.
The basic fetch
useQuery({
queryKey: ['todos'],
queryFn: async () => {
const response = await fetch('/api/todos');
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
},
});
The function is async, and the response.json() is the resolved value.
The error throw
The queryFn must throw on error. The fetch API does not throw on HTTP errors, so the response.ok check is required.
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
Without the throw, a 404 produces a successful query with the error body as the data.
The parameterized fetch
The query function reads the parameters from the closure.
function TodoDetail({ todoId }) {
const { data } = useQuery({
queryKey: ['todo', todoId],
queryFn: () => fetchTodo(todoId),
});
return <div>{data?.title}</div>;
}
The todoId is in the closure and in the key. When the todoId changes, the key changes, and the query refetches.
The abort signal
The query function receives an AbortSignal that is aborted when the query is cancelled.
useQuery({
queryKey: ['todos'],
queryFn: async ({ signal }) => {
const response = await fetch('/api/todos', { signal });
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
},
});
The signal is used to cancel the fetch when the component unmounts or the query is superseded.
The context
The query function receives a QueryFunctionContext with the queryKey, the signal, and the meta.
useQuery({
queryKey: ['todos', { page: 1 }],
queryFn: async ({ queryKey, signal }) => {
const [_key, { page }] = queryKey;
const response = await fetch(`/api/todos?page=${page}`, { signal });
return response.json();
},
});
The queryKey in the context is the same array. The parameters are destructured from it.
The dependent query
The enabled option defers the query until a condition is met.
useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
enabled: !!userId,
});
The query does not run until userId is truthy. The pattern is used for the dependent queries and the conditional fetches.
The keepPreviousData replacement
In v5, the keepPreviousData option is replaced by placeholderData.
import { keepPreviousData } from '@tanstack/react-query';
useQuery({
queryKey: ['todos', page],
queryFn: () => fetchTodos(page),
placeholderData: keepPreviousData,
});
The placeholderData shows the previous page’s data while the new page is loading. The pattern is used for the pagination.
The select option
The select option transforms the data before it is returned.
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data) => data.filter((todo) => !todo.completed),
});
The select runs on every render, so it should be a stable reference. The useCallback is the fix.
const selectActive = useCallback((data) => data.filter((t) => !t.completed), []);
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: selectActive,
});
c. The state flags and the methods
The useQuery hook returns an object with the data, the state flags, and the methods.
The data
The data is the resolved value, or undefined if the query has not resolved yet.
const { data } = useQuery({ ... });
The status flags
The status is 'pending', 'error', or 'success'. The isPending, isError, and isSuccess are the boolean equivalents.
const { status, isPending, isError, isSuccess } = useQuery({ ... });
The fetch status flags
The fetchStatus is 'fetching', 'paused', or 'idle'. The isFetching is the boolean for 'fetching'.
const { fetchStatus, isFetching } = useQuery({ ... });
The isFetching is true when a fetch is in progress, regardless of whether the data is cached.
The isLoading flag
In v5, the isLoading is isPending && isFetching. It is true only during the first fetch, when there is no data yet.
const { isLoading } = useQuery({ ... });
The isPending is the correct flag for the initial load state.
The error
The error is the error object if the query failed.
const { error } = useQuery({ ... });
The error is null if the query succeeded.
The refetch
The refetch method triggers a manual refetch.
const { refetch } = useQuery({ ... });
<button onClick={() => refetch()}>Refresh</button>
The method returns a promise that resolves to the query result.
The dataUpdatedAt
The dataUpdatedAt is the timestamp of the last successful fetch.
const { dataUpdatedAt } = useQuery({ ... });
The value is used for the “last updated” display.
The isStale
The isStale is true when the data is older than the staleTime.
const { isStale } = useQuery({ ... });
The isPlaceholderData
The isPlaceholderData is true when the data is the placeholder, not the real data.
const { isPlaceholderData } = useQuery({ ... });
The flag is used to dim the previous page’s data during the pagination.
The state table
| Flag | Meaning |
|---|---|
isPending | No data yet |
isLoading | isPending && isFetching |
isFetching | A fetch is in progress |
isError | The query failed |
isSuccess | The query succeeded |
isStale | The data is older than the staleTime |
isPlaceholderData | The data is the placeholder |
Complete Example Session
// ============================================
// PART 1: THE BASIC QUERY
// ============================================
import { useQuery } from '@tanstack/react-query';
function Todos() {
const { data, isPending, error } = useQuery({
queryKey: ['todos'],
queryFn: async () => {
const response = await fetch('/api/todos');
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
},
});
if (isPending) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <ul>{data.map((todo) => <li key={todo.id}>{todo.title}</li>)}</ul>;
}
// ============================================
// PART 2: THE PARAMETERIZED KEY
// ============================================
function TodoDetail({ todoId }) {
const { data, isPending } = useQuery({
queryKey: ['todo', todoId],
queryFn: () => fetchTodo(todoId),
});
if (isPending) return <div>Loading...</div>;
return <h1>{data.title}</h1>;
}
// ============================================
// PART 3: THE KEY FACTORY
// ============================================
const todoKeys = {
all: ['todos'],
lists: () => [...todoKeys.all, 'list'],
list: (filters) => [...todoKeys.lists(), filters],
details: () => [...todoKeys.all, 'detail'],
detail: (id) => [...todoKeys.details(), id],
};
function TodoList({ filters }) {
const { data } = useQuery({
queryKey: todoKeys.list(filters),
queryFn: () => fetchTodos(filters),
});
return <ul>{data?.map((t) => <li key={t.id}>{t.title}</li>)}</ul>;
}
// ============================================
// PART 4: THE ABORT SIGNAL
// ============================================
function SearchResults({ query }) {
const { data, isPending } = useQuery({
queryKey: ['search', query],
queryFn: async ({ signal }) => {
const response = await fetch(`/api/search?q=${query}`, { signal });
if (!response.ok) throw new Error('Search failed');
return response.json();
},
});
if (isPending) return <div>Searching...</div>;
return <ul>{data.map((r) => <li key={r.id}>{r.title}</li>)}</ul>;
}
// ============================================
// PART 5: THE DEPENDENT QUERY
// ============================================
function UserPosts({ userId }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
enabled: !!userId,
});
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchPosts(user.id),
enabled: !!user?.id,
});
return <ul>{posts?.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}
// ============================================
// PART 6: THE PAGINATION WITH PLACEHOLDER DATA
// ============================================
import { keepPreviousData } from '@tanstack/react-query';
function PaginatedList() {
const [page, setPage] = useState(1);
const { data, isPlaceholderData } = useQuery({
queryKey: ['todos', page],
queryFn: () => fetchTodos(page),
placeholderData: keepPreviousData,
});
return (
<div style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
<ul>{data?.map((t) => <li key={t.id}>{t.title}</li>)}</ul>
<button onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</button>
<button onClick={() => setPage((p) => p + 1)}>Next</button>
</div>
);
}
// ============================================
// PART 7: THE SELECT OPTION
// ============================================
function ActiveTodos() {
const selectActive = useCallback(
(data) => data.filter((t) => !t.completed),
[]
);
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: selectActive,
});
return <ul>{data?.map((t) => <li key={t.id}>{t.title}</li>)}</ul>;
}
// ============================================
// PART 8: THE MANUAL REFETCH
// ============================================
function RefreshableTodos() {
const { data, isPending, refetch, isFetching } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
if (isPending) return <div>Loading...</div>;
return (
<div>
<button onClick={() => refetch()} disabled={isFetching}>
{isFetching ? 'Refreshing...' : 'Refresh'}
</button>
<ul>{data.map((t) => <li key={t.id}>{t.title}</li>)}</ul>
</div>
);
}
// ============================================
// PART 9: THE QUERY CLIENT METHODS
// ============================================
import { useQueryClient } from '@tanstack/react-query';
function InvalidateButton() {
const queryClient = useQueryClient();
function handleInvalidate() {
queryClient.invalidateQueries({ queryKey: ['todos'] });
}
return <button onClick={handleInvalidate}>Invalidate todos</button>;
}
function PrefetchButton() {
const queryClient = useQueryClient();
function handlePrefetch() {
queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
}
return <button onMouseEnter={handlePrefetch}>Hover to prefetch</button>;
}
// ============================================
// PART 10: THE COMPLETE PATTERN
// ============================================
// 1. Use the queryKey to identify the cache entry.
// 2. Use the queryFn to fetch the data.
// 3. Throw on the HTTP errors.
// 4. Use the abort signal to cancel.
// 5. Use enabled for the dependent queries.
// 6. Use placeholderData for the pagination.
// 7. Use select for the derived data.
// 8. Use isPending for the initial load.
// 9. Use isFetching for the background fetch.
// 10. Use invalidateQueries to refetch after a mutation.
The ten parts showed the basic query, the parameterized key, the key factory, the abort signal, the dependent query, the pagination, the select option, the manual refetch, the client methods, and the complete pattern.
Quick Reference
The useQuery Options
| Option | Purpose |
|---|---|
queryKey | The cache identifier |
queryFn | The fetch function |
enabled | The query is conditional |
staleTime | The freshness window |
gcTime | The cache lifetime |
retry | The retry count |
select | The data transformation |
placeholderData | The placeholder while loading |
The State Flags
| Flag | Meaning |
|---|---|
isPending | No data yet |
isLoading | isPending && isFetching |
isFetching | A fetch is in progress |
isError | The query failed |
isSuccess | The query succeeded |
isStale | The data is older than the staleTime |
The Returned Methods
| Method | Purpose |
|---|---|
refetch | The manual refetch |
dataUpdatedAt | The last fetch timestamp |
errorUpdateCount | The error count |
The Query Key Rules
| Rule | Description |
|---|---|
| The array form | Required |
| The deterministic values | The same values produce the same key |
| The hierarchical structure | The prefix is the invalidation scope |
| The dependency inclusion | The parameters that affect the result |
The Client Methods
| Method | Purpose |
|---|---|
invalidateQueries | Mark the cache as stale |
prefetchQuery | Fetch in advance |
setQueryData | Write to the cache |
getQueryData | Read from the cache |
Best Practices
✅ Do This:
// Use the array query key.
useQuery({ queryKey: ['todos'], queryFn: fetchTodos }); // ✅
// Throw on the HTTP errors.
if (!response.ok) throw new Error('Failed'); // ✅
// Use the abort signal.
queryFn: async ({ signal }) => fetch(url, { signal }); // ✅
// Use the key factory for the consistency.
const todoKeys = { detail: (id) => ['todos', 'detail', id] }; // ✅
// Use enabled for the dependent queries.
enabled: !!userId // ✅
// Use placeholderData for the pagination.
placeholderData: keepPreviousData // ✅
// Use select with useCallback.
const select = useCallback((d) => d.filter(...), []); // ✅
❌ Don’t Do This:
// Don't use the v4 array syntax.
useQuery(['todos'], fetchTodos); // ❌
// Don't forget the throw.
const response = await fetch(url); // ❌ No error on 404
return response.json();
// Don't use a random value in the key.
queryKey: ['todos', Math.random()]; // ❌ Never cached
// Don't inline the select.
select: (data) => data.filter(...); // ❌ New function every render
// Don't use isLoading for the initial load.
if (isLoading) return <Loading />; // ⚠️ Changed meaning in v5
// Don't forget the enabled flag.
useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId) }); // ❌ Fetches with undefined
// Don't forget the signal.
queryFn: () => fetch(url); // ❌ No cancellation
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Query never runs | enabled is false | Check the condition |
| Refetch on every render | The key is not stable | Use the stable values |
| Cache never reused | The key is unique | Use the deterministic key |
| Error not caught | The fetch does not throw | Check response.ok |
| Stale data shown | The staleTime is high | Lower the staleTime |
| Loading forever | The query is paused | Check the network |
| Select re-runs | The inline function | Use useCallback |
Real-World Examples
1. Basic Query
const { data, isPending } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
2. Parameterized Query
const { data } = useQuery({ queryKey: ['todo', id], queryFn: () => fetchTodo(id) });
3. Key Factory
const todoKeys = { detail: (id) => ['todos', id] };
4. Abort Signal
queryFn: async ({ signal }) => fetch(url, { signal });
5. Dependent Query
useQuery({ queryKey: ['user', id], queryFn: fetchUser, enabled: !!id });
6. Pagination
placeholderData: keepPreviousData
7. Select
select: useCallback((data) => data.filter(...), [])
8. Refetch
const { refetch } = useQuery({ ... });
<button onClick={() => refetch()}>Refresh</button>
9. Invalidate
queryClient.invalidateQueries({ queryKey: ['todos'] });
10. Prefetch
queryClient.prefetchQuery({ queryKey: ['todos'], queryFn: fetchTodos });
Visual
The Query Lifecycle
┌────────────────────────────────────────────────────────────┐
│ THE QUERY LIFECYCLE │
│ │
│ MOUNT │
│ ┌──────────────────────────────────────────────────┐ │
│ │ useQuery reads the cache by the queryKey. │ │
│ │ → fresh data: returned immediately. │ │
│ │ → stale data: returned, refetch in background. │ │
│ │ → no data: isPending, the queryFn runs. │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ v │
│ FETCH │
│ ┌──────────────────────────────────────────────────┐ │
│ │ The queryFn runs with the abort signal. │ │
│ │ The data is cached on success. │ │
│ │ The error is cached on failure. │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ v │
│ RE-RENDER │
│ ┌──────────────────────────────────────────────────┐ │
│ │ The component re-renders with the new data. │ │
│ │ The isFetching flag is false. │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
The State Flags
┌────────────────────────────────────────────────────────────┐
│ THE STATE FLAGS │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ isPending │ isFetching │ Meaning │ │
│ ├──────────────┼──────────────┼─────────────────────┤ │
│ │ true │ true │ Initial load │ │
│ │ true │ false │ No data, idle │ │
│ │ false │ true │ Background refetch │ │
│ │ false │ false │ Fresh or stale │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ isLoading = isPending && isFetching │
│ The initial load state is isPending. │
└────────────────────────────────────────────────────────────┘
The Query Key
┌────────────────────────────────────────────────────────────┐
│ THE QUERY KEY │
│ │
│ ['todos'] │
│ ['todos', 1] │
│ ['todos', 1, 'comments'] │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ invalidateQueries({ queryKey: ['todos'] }) │ │
│ │ → invalidates ALL of the above. │ │
│ │ │ │
│ │ invalidateQueries({ queryKey: ['todos', 1] }) │ │
│ │ → invalidates ['todos', 1] and │ │
│ │ ['todos', 1, 'comments']. │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ The prefix matching is the reason for the hierarchy. │
└────────────────────────────────────────────────────────────┘
The Pagination
┌────────────────────────────────────────────────────────────┐
│ THE PAGINATION │
│ │
│ Page 1 → data: [1, 2, 3] │
│ Click "Next" │
│ Page 2 → placeholderData: [1, 2, 3] (dimmed) │
│ fetch runs │
│ data: [4, 5, 6] │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ placeholderData: keepPreviousData │ │
│ │ → the previous page's data is shown │ │
│ │ → isPlaceholderData is true │ │
│ │ → the data is dimmed │ │
│ │ → the new data replaces it when ready │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
useQuery | The data-fetching hook |
queryKey | The cache identifier |
queryFn | The fetch function |
enabled | The conditional query |
select | The data transformation |
placeholderData | The placeholder while loading |
isPending | No data yet |
isFetching | A fetch is in progress |
refetch | The manual refetch |
invalidateQueries | The cache invalidation |
Key takeaways:
- The
queryKeyis the cache identifier. Two components with the same key share the cache entry. The key is an array, and the values are serialized deterministically. The hierarchical structure is the invalidation scope. - The
queryFnfetches the data and throws on error. ThefetchAPI does not throw on HTTP errors, so theresponse.okcheck is required. The function receives theAbortSignalfor cancellation. - The
isPendingflag is the initial load state. TheisLoadingflag changed in v5 to meanisPending && isFetching. TheisFetchingistruefor the background refetches. - The
enabledoption defers the query. The dependent queries use the flag to wait for a prerequisite. The query does not run until the condition is truthy. - The
placeholderDatashows the previous data. ThekeepPreviousDatahelper is the pagination pattern. TheisPlaceholderDataflag istruewhile the placeholder is shown. - The
selectoption transforms the data. The function runs on every render, so it must be stable. TheuseCallbackis the fix. - The
refetchmethod triggers the manual refetch. The method returns a promise, and theisFetchingflag istruewhile the refetch is in progress. - The
invalidateQueriesmethod marks the cache as stale. The prefix matching invalidates every query whose key starts with the given prefix. The invalidation triggers the background refetch for the active queries.
Remember: The useQuery hook is the entry point to the data fetching in a TanStack Query application. The queryKey identifies the cache entry, the queryFn fetches the data, and the state flags tell the component what to render. The enabled flag defers the query, the placeholderData shows the previous data, and the select transforms the result. The refetch method triggers the manual refetch, and the invalidateQueries method marks the cache as stale. The result is an application where the server state is cached, deduplicated, and kept in sync, and where the components render the data without the manual loading and error management.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!