TanStack Query 02 ⚛️ Basic Queries — Fetching data with useQuery
useQuery is the hook that turns a component into a consumer of server state. It accepts a query key and a query function, returns the cached data if it exists, triggers a fetch if the data is missing or stale, and re-renders the component when the request resolves. The hook does not replace fetch or axios; it orchestrates them. The query function performs the actual network call, and TanStack Query manages the lifecycle around it: caching, deduplication, background refetching, and error retry.
This chapter covers the anatomy of a useQuery call, the three primary states a query can occupy, the distinction between status and fetchStatus, and the query function contract that determines whether a request succeeds or fails. It builds directly on the provider setup from the previous chapter; every example assumes that QueryClientProvider wraps the component tree.
Key point: A query function must return a promise that either resolves the data or throws an error. fetch does not throw on HTTP error status codes, so the query function must check response.ok and throw explicitly. A query function that resolves undefined is treated as a failure, not a success.
Why useQuery works the way it does
The state machine problem.
A network request is not a binary event. It begins in a pending state with no data, transitions to success with data or error with a failure, and can later re-enter a fetching state for a background refetch while still displaying the previous data. TanStack Query models this as two separate dimensions: status describes the data (“do we have it?”), and fetchStatus describes the query function (“is it running?”). This separation prevents a common UI bug where a background refetch causes the loading spinner to replace already-rendered data . A query in status: 'success' with fetchStatus: 'fetching' should keep showing the cached data, not a spinner.
The error detection problem.
TanStack Query determines that a query has errored only if the query function throws or returns a rejected promise . The native fetch API does not throw on 404 or 500 responses; it resolves with a Response object whose ok property is false. A query function that returns response.json() without checking ok will treat a 500 error as a success, caching the error payload as if it were valid data. The query function must explicitly throw when response.ok is false. This is not a TanStack Query quirk; it is the contract that makes error states meaningful.
The stale-while-revalidate problem.
By default, TanStack Query treats cached data as stale immediately (staleTime: 0). A stale query refetches in the background when the component mounts, when the window regains focus, or when the network reconnects . The cached data remains visible during the refetch, and the component re-renders with the new data when it arrives. This default is aggressive, and it surprises developers who expect data to persist without refetching. The staleTime option controls how long data is considered fresh; while fresh, no background refetch occurs.
a. The anatomy of a useQuery call
Every useQuery call requires two properties: queryKey and queryFn. The key is a serializable array that uniquely identifies the data. The function is any function that returns a promise.
import { useQuery } from '@tanstack/react-query'
function TodoList() {
// The query key is an array; it must include every variable the queryFn depends on
const { data, isPending, isError, error } = useQuery({
queryKey: ['todos'], // Unique identifier for this data
queryFn: async () => { // Must return a promise
const response = await fetch('/api/todos')
if (!response.ok) { // fetch does not throw on HTTP errors
throw new Error('Failed to fetch todos')
}
return response.json() // Resolves the data
},
})
// State checks: pending first, then error, then success
if (isPending) return <p>Loading...</p>
if (isError) return <p>Error: {error.message}</p>
return (
<ul>
{data.map((todo: { id: number; title: string }) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}
The query function receives a QueryFunctionContext object containing the queryKey, the client, an optional AbortSignal, and meta . The key is available inside the function, which allows extracting the query function to a separate module and reading the variables from the key rather than closing over them.
// Extracted query function that reads variables from the query key
async function fetchTodoById({ queryKey }: { queryKey: [string, number] }) {
const [_key, todoId] = queryKey
const response = await fetch(`/api/todos/${todoId}`)
if (!response.ok) throw new Error('Failed to fetch todo')
return response.json()
}
// The component passes the key; the function reads the id from it
const { data } = useQuery({
queryKey: ['todo', 42],
queryFn: fetchTodoById,
})
This pattern is not required, but it makes query functions testable in isolation and avoids stale closures over component variables.
b. The three primary states and fetchStatus
The result object returned by useQuery exposes boolean flags and string statuses. The primary flags are isPending, isError, and isSuccess. Each corresponds to a string value on the status property .
const { status, data, error, isFetching } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
// status: 'pending' | 'error' | 'success'
// isPending: status === 'pending'
// isError: status === 'error'
// isSuccess: status === 'success'
// isFetching: true during ANY fetch, including background refetch
The fetchStatus property is separate from status. It describes the query function: 'fetching', 'paused', or 'idle'. A query can be status: 'success' and fetchStatus: 'fetching' simultaneously—this is the background refetch scenario. The component should render the cached data, not a loading state, when this occurs . A common v5 migration error is using isLoading as “no data yet.” In v5, isLoading is defined as isPending && isFetching, which means a query that is pending but paused (due to network mode) will have isLoading: false even though there is no data . The correct flag for “no data yet” is isPending.
// Correct: isPending means no data has been loaded yet
if (isPending) return <Spinner />
// Incorrect for "no data yet" in v5: isLoading also requires isFetching
if (isLoading) return <Spinner /> // ❌ can be false when pending + paused
c. Query keys and dependencies
The query key is the cache address. Two components that call useQuery with the same key share the same cache entry. If the key includes a variable, changing that variable produces a new key and a new fetch. The rule is absolute: every variable the query function reads must appear in the key .
// The userId is part of the key, so changing it triggers a new fetch
function UserProfile({ userId }: { userId: number }) {
const { data } = useQuery({
queryKey: ['user', userId], // userId is in the key
queryFn: () => fetchUser(userId), // userId is used here
})
// ...
}
Omitting userId from the key produces a subtle bug: the query function closes over the current userId, but the cache key is ['user'] for every user. When userId changes, the key does not, so TanStack Query returns the cached data from the previous user and does not refetch . The component displays the wrong user’s profile. The query key must include every variable that affects the fetched data.
Query keys must be arrays at the top level and must be serializable with JSON.stringify. Object property order does not affect hashing, but array order does: ['todos', { status, page }] and ['todos', { page, status }] are equivalent, while ['todos', status, page] and ['todos', page, status] are different keys .
Complete Example Session
// ============================================
// PART 1: THE QUERY FUNCTION
// ============================================
// The query function is a plain async function
// It must throw on HTTP errors because fetch does not
async function fetchTodos(): Promise<Todo[]> {
const response = await fetch('/api/todos')
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
return response.json()
}
// ============================================
// PART 2: THE TYPE DEFINITION
// ============================================
interface Todo {
id: number
title: string
completed: boolean
}
// ============================================
// PART 3: THE COMPONENT WITH useQuery
// ============================================
import { useQuery } from '@tanstack/react-query'
function TodoList() {
const {
data, // The resolved data, or undefined
status, // 'pending' | 'error' | 'success'
isPending, // status === 'pending'
isError, // status === 'error'
error, // The thrown error, if isError
isFetching, // true during any fetch, including background
} = useQuery({
queryKey: ['todos'], // Cache key: no variables here
queryFn: fetchTodos, // The function that fetches data
staleTime: 30_000, // Data is fresh for 30 seconds
})
// ============================================
// PART 4: THE PENDING STATE
// ============================================
if (isPending) {
// No data has been loaded yet
// This is the initial load, not a background refetch
return <p>Loading todos...</p>
}
// ============================================
// PART 5: THE ERROR STATE
// ============================================
if (isError) {
// The query function threw or returned a rejected promise
// The error object is available for display
return <p>Error: {error.message}</p>
}
// ============================================
// PART 6: THE SUCCESS STATE
// ============================================
// At this point, status === 'success' and data is defined
// TypeScript narrows the type of data after the pending/error checks
return (
<div>
{/* Show a subtle indicator during background refetch */}
{isFetching && <span>Refreshing...</span>}
<ul>
{data.map((todo) => (
<li key={todo.id}>
{todo.title} {todo.completed ? '✓' : ''}
</li>
))}
</ul>
</div>
)
}
The six parts demonstrate the complete flow: define the query function with error handling, call useQuery with a key and function, check isPending, check isError, and render the data. The isFetching flag is used inside the success branch to show a background refetch indicator without replacing the data.
Quick Reference
useQuery Required Options
| Option | Type | Purpose |
|---|---|---|
queryKey | Array | Unique cache identifier; must include all variables |
queryFn | () => Promise | Fetches the data; must throw on error |
Result Flags
| Flag | Meaning | When to Use |
|---|---|---|
isPending | No data yet | Initial loading state |
isError | Query function threw | Error display |
isSuccess | Data available | Normal render |
isFetching | Fetching in progress (any) | Background refetch indicator |
data | Resolved data | Render when isSuccess |
error | Thrown error | Display when isError |
Important Defaults
| Default | Value | Effect |
|---|---|---|
staleTime | 0 | Data is stale immediately; refetches on mount |
gcTime | 5 minutes | Inactive cache entries are garbage collected |
retry | 3 | Failed queries retry three times with backoff |
refetchOnWindowFocus | true | Refetches when the tab regains focus |
Best Practices
✅ Do This:
// Check response.ok in the query function
if (!response.ok) throw new Error('Failed') // ✅
// Include every variable in the query key
queryKey: ['user', userId], queryFn: () => fetchUser(userId) // ✅
// Use isPending for the initial loading state
if (isPending) return <Spinner /> // ✅
// Use isFetching for background refetch indicators
{isFetching && <span>Refreshing...</span>} // ✅
// Set staleTime based on data volatility
staleTime: 30_000 // ✅
// Extract query functions for testability
queryFn: fetchTodos // ✅
❌ Don’t Do This:
// Return response.json() without checking ok
return response.json() // Treats 500 errors as success // ❌
// Omit variables from the query key
queryKey: ['user'], queryFn: () => fetchUser(userId) // ❌ Stale data
// Use isLoading as "no data yet" in v5
if (isLoading) return <Spinner /> // Can be false when pending // ❌
// Resolve undefined from the query function
return undefined // TanStack Query treats this as a failure // ❌
// Create a new function on every render without memoization
queryFn: () => fetchTodos() // Acceptable if key is stable // ❌ (if key unstable)
// Ignore the error state
const { data } = useQuery({...})
return <div>{data.map(...)}</div> // Crashes when data is undefined // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Error state never triggers | fetch does not throw on 4xx/5xx | Check response.ok and throw |
| Stale data displayed | Variable omitted from query key | Include all dependencies in key |
| Spinner replaces data on refetch | isPending checked instead of isFetching | Use isPending for initial only |
Query function resolves undefined | Missing return or explicit undefined | Return null for empty success |
| Infinite refetch loop | Query function not stable | Extract function or use queryOptions |
TypeScript error on data | Not narrowing with isPending/isError | Check states before accessing |
Real-World Examples
1. Basic todos query
const { data, isPending } = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then(r => r.json()),
})
2. Query with parameter in key
queryKey: ['todo', todoId],
queryFn: () => fetch(`/api/todos/${todoId}`).then(r => r.json()),
3. Proper error handling with fetch
queryFn: async () => {
const res = await fetch('/api/todos')
if (!res.ok) throw new Error('Fetch failed')
return res.json()
}
4. Background refetch indicator
{isFetching && <span>Updating...</span>}
5. Conditional query with enabled
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
enabled: !!userId, // Do not fetch until userId exists
6. Polling with refetchInterval
queryKey: ['status'],
queryFn: fetchStatus,
refetchInterval: 5000, // Refetch every 5 seconds
7. Data transformation with select
select: (todos) => todos.filter(t => !t.completed), // Only incomplete
8. Reading query key in the function
async function fetchTodo({ queryKey }: { queryKey: [string, number] }) {
const [, id] = queryKey
return fetch(`/api/todos/${id}`).then(r => r.json())
}
9. Manual refetch
const { refetch } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
<button onClick={() => refetch()}>Reload</button>
10. Success-only render with type narrowing
if (isPending) return <Spinner />
if (isError) return <Error message={error.message} />
return <List data={data} /> // data is typed and defined
Visual
The Query State Machine
┌──────────────────────────────────────────────────────────────┐
│ useQuery STATES: status vs fetchStatus │
│ │
│ status (about DATA): │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ pending │ → │ error │ │ success │ │
│ │ no data │ │ threw │ │ has data │ │
│ └──────────┘ └──────────┘ └────┬─────┘ │
│ │ │
│ fetchStatus (about QUERYFN): │ │
│ ┌──────────┐ ┌──────────┐ ┌────▼─────┐ │
│ │ fetching │ │ paused │ │ idle │ │
│ │ running │ │ offline │ │ waiting │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ Key insight: success + fetching = background refetch │
│ Render the cached data, show isFetching indicator separately │
│ │
│ pending + fetching = initial load (isPending true) │
│ pending + paused = offline, no data (isLoading false in v5)│
└──────────────────────────────────────────────────────────────┘
Query Key as Cache Address
┌──────────────────────────────────────────────────────────────┐
│ THE QUERY KEY DETERMINES WHICH CACHE ENTRY IS USED │
│ │
│ Component A: │
│ useQuery({ queryKey: ['todos'], queryFn: fetchAll }) │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────┐ │
│ │ Cache │ │
│ │ ['todos'] → [Todo, ...]│ ← Shared │
│ │ ['todo', 1] → Todo #1 │ │
│ │ ['todo', 2] → Todo #2 │ │
│ └─────────────────────────────────┘ │
│ ▲ │
│ │ │
│ Component B: │
│ useQuery({ queryKey: ['todos'], queryFn: fetchAll }) │
│ │
│ Same key → same cache entry → no duplicate fetch │
│ │
│ But: queryKey ['todo', 1] and ['todo', 2] are different │
│ Changing the key produces a new fetch. │
└──────────────────────────────────────────────────────────────┘
The Query Function Contract
┌──────────────────────────────────────────────────────────────┐
│ WHAT THE QUERY FUNCTION MUST DO │
│ │
│ ✅ Return a Promise │
│ ✅ Resolve with data (not undefined) │
│ ✅ Throw or reject on failure │
│ │
│ ❌ Do not resolve undefined │
│ ❌ Do not ignore HTTP error statuses with fetch │
│ │
│ fetch behavior: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ const res = await fetch('/api/data') │ │
│ │ // 200 → res.ok = true → return res.json() → SUCCESS│ │
│ │ // 500 → res.ok = false → return res.json() → SUCCESS│ │
│ │ // (WRONG! 500 is cached) │ │
│ │ │ │
│ │ // Correct: │ │
│ │ if (!res.ok) throw new Error('Failed') → ERROR │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Required options | queryKey (array), queryFn (promise-returning function) |
| Primary states | isPending, isError, isSuccess |
| Loading flag for initial | isPending (not isLoading in v5) |
| Background refetch flag | isFetching |
| Query function contract | Must throw on error; must not resolve undefined |
fetch error handling | Check response.ok and throw manually |
Default staleTime | 0 (data is immediately stale) |
Default gcTime | 5 minutes |
| Query key rule | Include every variable the query function uses |
| Key serialization | Arrays, JSON-serializable; object order irrelevant |
Key takeaways:
- The query function must throw on HTTP errors.
fetchresolves even for 500 responses. Without an explicitif (!response.ok) throw, error payloads are cached as success data . isPendingmeans “no data yet.” In v5,isLoadingisisPending && isFetching, which can be false when a query is pending but paused. UseisPendingfor the initial loading state .isFetchingis separate fromstatus. A query can be successful and still fetching in the background. UseisFetchingfor a subtle refresh indicator, not for replacing the data view.- The query key must include every dependency. Omitting a variable produces stale data that never refetches when that variable changes. The key is the cache address, and the address must change when the inputs change .
- Resolving
undefinedis a failure. If a query function returnsundefined, TanStack Query treats it as an error. Returnnullfor an empty success . - Default
staleTimeis zero. Data is considered stale immediately and refetches on mount and window focus. SetstaleTimeexplicitly for data that does not change every second. - The query function receives the key. Extracted functions can read variables from
queryKeyrather than closing over component scope, which makes them testable and avoids stale closures.
Remember: useQuery is a state machine that maps a promise to a component render. The query key identifies the cache entry, the query function produces the promise, and the result flags tell the component which branch to render. The two most common bugs are failing to throw on HTTP errors and omitting dependencies from the query key. Get those right, check isPending before isError before isSuccess, and the hook behaves predictably.
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!