| |

React 64 ⚛️ Fetching and Caching Data with useQuery

⏱️ Estimated read: 16 min | |

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

RuleDescription
The key is an arrayThe array form is required
The key is deterministicThe same values produce the same key
The key is serializableThe values must be JSON-serializable
The key is hierarchicalThe prefix is the invalidation scope
The key includes the dependenciesThe parameters that affect the result

The key anti-patterns

Anti-patternProblem
A plain stringUse the array form
A random valueThe cache is never reused
The entire objectThe key is too specific
The functionThe key is not serializable
The stale closureThe 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

FlagMeaning
isPendingNo data yet
isLoadingisPending && isFetching
isFetchingA fetch is in progress
isErrorThe query failed
isSuccessThe query succeeded
isStaleThe data is older than the staleTime
isPlaceholderDataThe 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

OptionPurpose
queryKeyThe cache identifier
queryFnThe fetch function
enabledThe query is conditional
staleTimeThe freshness window
gcTimeThe cache lifetime
retryThe retry count
selectThe data transformation
placeholderDataThe placeholder while loading

The State Flags

FlagMeaning
isPendingNo data yet
isLoadingisPending && isFetching
isFetchingA fetch is in progress
isErrorThe query failed
isSuccessThe query succeeded
isStaleThe data is older than the staleTime

The Returned Methods

MethodPurpose
refetchThe manual refetch
dataUpdatedAtThe last fetch timestamp
errorUpdateCountThe error count

The Query Key Rules

RuleDescription
The array formRequired
The deterministic valuesThe same values produce the same key
The hierarchical structureThe prefix is the invalidation scope
The dependency inclusionThe parameters that affect the result

The Client Methods

MethodPurpose
invalidateQueriesMark the cache as stale
prefetchQueryFetch in advance
setQueryDataWrite to the cache
getQueryDataRead 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

PitfallWhy It HappensFix
Query never runsenabled is falseCheck the condition
Refetch on every renderThe key is not stableUse the stable values
Cache never reusedThe key is uniqueUse the deterministic key
Error not caughtThe fetch does not throwCheck response.ok
Stale data shownThe staleTime is highLower the staleTime
Loading foreverThe query is pausedCheck the network
Select re-runsThe inline functionUse 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

ItemValue
useQueryThe data-fetching hook
queryKeyThe cache identifier
queryFnThe fetch function
enabledThe conditional query
selectThe data transformation
placeholderDataThe placeholder while loading
isPendingNo data yet
isFetchingA fetch is in progress
refetchThe manual refetch
invalidateQueriesThe cache invalidation

Key takeaways:

  • The queryKey is 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 queryFn fetches the data and throws on error. The fetch API does not throw on HTTP errors, so the response.ok check is required. The function receives the AbortSignal for cancellation.
  • The isPending flag is the initial load state. The isLoading flag changed in v5 to mean isPending && isFetching. The isFetching is true for the background refetches.
  • The enabled option 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 placeholderData shows the previous data. The keepPreviousData helper is the pagination pattern. The isPlaceholderData flag is true while the placeholder is shown.
  • The select option transforms the data. The function runs on every render, so it must be stable. The useCallback is the fix.
  • The refetch method triggers the manual refetch. The method returns a promise, and the isFetching flag is true while the refetch is in progress.
  • The invalidateQueries method 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!