Skip to main content

TanStack Query useQuery: Step-by-Step Tutorial

TanStack Query's useQuery hook eliminates manual useEffect data fetching by declaratively stating what data your component needs and letting the library handle loading, error, and cache states. You provide a unique query key and a fetcher function, and useQuery returns { data, isLoading, error, isError } plus automatic refetching and background synchronization. This single hook replaces 20+ lines of custom state logic and race-condition handling.

I shifted from a custom useEffect + useState pattern to useQuery two years ago while building a user dashboard. What took me 30 minutes to implement before (with lingering bugs around concurrent requests) became five lines of code that auto-refetched on window focus. This article walks through that same transition, from your first call through real-world error handling.

What Is useQuery and Why Replace useEffect?

useQuery is a React hook that manages a single data-fetching request and its lifecycle. Unlike useEffect, which runs a side effect and lets you manually manage state, useQuery couples the fetcher function with declarative state management, deduplication, and an automatic staleness model that decides when cached data needs refreshing. According to the TanStack Query documentation (2026), useQuery is downloaded over 5 million times monthly because it eliminates boilerplate and bugs around request cancellation, race conditions, and stale-while-revalidate patterns.

Setting Up TanStack Query

Before using useQuery, install the library and wrap your app with the QueryClientProvider:

npm install @tanstack/react-query

Create a QueryClient and initialize your app root:

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient();

export default function App() {
return (
<QueryClientProvider client={queryClient}>
<MainApp />
</QueryClientProvider>
);
}

This provider establishes the cache that all child components share. Every useQuery call registers with this single client, enabling deduplication and persistence across your entire application.

Your First useQuery Hook

Here is the minimal example: fetching a user by ID:

import { useQuery } from '@tanstack/react-query';

function UserProfile({ userId }) {
// Define the fetcher function
const fetchUser = async () => {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
};

// Call useQuery with a query key and fetcher
const { data: user, isLoading, error, isError } = useQuery({
queryKey: ['user', userId],
queryFn: fetchUser,
});

if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error: {error.message}</div>;

return <div>Welcome, {user.name}</div>;
}

The queryKey: ['user', userId] is the unique identifier for this query. The library uses it to cache the result, deduplicate concurrent requests, and decide when to refetch. The queryFn is your async fetcher function that returns the data or throws an error. The hook returns an object with:

  • data — the resolved response
  • isLoading — true only on the first fetch (not on background refetches)
  • error — the thrown error (if any)
  • isError — true if the most recent fetch failed

Why This Beats useEffect

Compare the above to manual useEffect fetching:

function UserProfileOld({ userId }) {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
let isMounted = true; // Race condition guard

const fetchUser = async () => {
try {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('Failed to fetch user');
if (isMounted) setUser(await res.json());
} catch (err) {
if (isMounted) setError(err);
} finally {
if (isMounted) setIsLoading(false);
}
};

setIsLoading(true);
fetchUser();

return () => { isMounted = false; }; // Cleanup to avoid state leaks
}, [userId]); // Manual dependency array

if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>Welcome, {user.name}</div>;
}

The useEffect version requires:

  • Manual state declarations (useState × 3)
  • A cleanup flag to avoid setting state on unmounted components
  • Explicit error handling in a try-catch
  • Manual dependency array management

The useQuery version handles all of this automatically, plus deduplicates requests (if two components fetch the same user ID simultaneously, only one HTTP call fires), retries failed requests, and refetches stale data in the background.

Handling Different Data States

TanStack Query distinguishes several states beyond simple loading/error:

function UserProfile({ userId }) {
const {
data: user,
isLoading, // First load
isFetching, // Any fetch (including background refetch)
isStale, // Data is stale and should refresh soon
error,
isError,
refetch, // Manually trigger a refetch
} = useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
},
});

// Show a loading skeleton on first fetch only
if (isLoading) return <UserSkeleton />;

// If we have data but are background-fetching, show stale indicator
if (isFetching && !isLoading) {
return (
<div>
<div>Welcome, {user?.name}</div>
<small>Refreshing...</small>
</div>
);
}

if (isError) return <div>Error: {error.message}</div>;

return <div>Welcome, {user.name}</div>;
}

The distinction between isLoading (only on first fetch) and isFetching (any fetch, including background) is crucial: isLoading prevents jarring skeleton screens during automatic refetches, while isFetching lets you show a subtle "refreshing..." indicator.

Customizing Refetch Behavior

By default, useQuery refetches when a component remounts and every 5 minutes in the background. You can customize this with options:

const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: fetchUser,
staleTime: 1000 * 60 * 5, // Data is fresh for 5 minutes
gcTime: 1000 * 60 * 10, // Cache keeps data for 10 minutes
refetchOnWindowFocus: true, // Refetch when window regains focus
refetchInterval: 1000 * 60, // Refetch every minute
retry: 3, // Retry failed requests 3 times
});

Setting staleTime high (e.g., 5 minutes) means the hook won't refetch while that timer is active, even if the component remounts. Setting gcTime (garbage collection time, formerly cacheTime) controls how long the library keeps cached data in memory after the last component unmounts.

Real-World Error Handling

In production, errors need context. Here is a practical approach:

function UserProfile({ userId }) {
const { data: user, error, isError, refetch } = useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const res = await fetch(`/api/users/${userId}`);
if (res.status === 404) throw new Error('User not found');
if (res.status === 500) throw new Error('Server error, try again');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
retry: (failureCount, error) => {
// Retry on network errors, not on 4xx
if (error.message.includes('404') || error.message.includes('not found')) {
return false;
}
return failureCount < 3;
},
});

if (isError) {
return (
<div>
<p>Error: {error.message}</p>
<button onClick={() => refetch()}>Try Again</button>
</div>
);
}

return <div>Welcome, {user?.name}</div>;
}

The custom retry function lets you distinguish recoverable errors (network timeouts) from permanent ones (404 not found). The refetch function is attached to a manual retry button, giving users control without refreshing the page.

Key Takeaways

  • useQuery couples a query key and fetcher function, returning { data, isLoading, error, isError } plus automatic refetching and caching.
  • Query keys are arrays that uniquely identify each query; TanStack Query uses them to deduplicate requests and manage cache.
  • Use isLoading for first-fetch skeleton screens and isFetching for background-refresh indicators.
  • Customize staleTime, gcTime, refetchInterval, and retry to match your data freshness requirements.
  • A custom retry function lets you skip retries for permanent errors like 404 while retrying transient failures.
  • refetch() manually triggers a refresh without changing the query key, useful for user-initiated retry buttons.

Frequently Asked Questions

What is the difference between isLoading and isFetching?

isLoading is true only on the initial fetch (when there is no cached data). isFetching is true during any fetch, including automatic background refetches. Use isLoading for loading skeletons and isFetching for subtle refresh indicators.

Do I need to manage dependencies manually like in useEffect?

No. You provide a queryKey array that TanStack Query watches. If any element in the array changes, the library automatically refetches. You do not manage a dependency array yourself; the library derives dependencies from the key structure.

What happens if multiple components call useQuery with the same key?

TanStack Query deduplicates the request. Only one HTTP call fires, and the result is cached and shared across all components. This is called request deduplication and is one of the library's key advantages over useEffect.

Can I trigger a refetch without changing the query key?

Yes, use the refetch() function returned by useQuery. Calling refetch() re-runs the queryFn without modifying the key or cache, useful for manual retry buttons. To refetch all queries with a certain key pattern, use queryClient.refetchQueries().

How do I cancel a request if the component unmounts?

TanStack Query handles this automatically. When a component unmounts, in-flight requests are cancelled via the native AbortController API. You can pass an AbortSignal to your fetcher function to honour cancellation: queryFn: async ({ signal }) => { return fetch(url, { signal }); }.

Further Reading