React Query Basics

Difficulty: Intermediate

React Query (TanStack Query) is a library for managing server state in React applications. Server state is fundamentally different from client state: it is owned by a remote source, can become stale, and is shared across the network. React Query gives you caching, automatic refetching, deduplication, and background updates out of the box.

The core hook is useQuery. You provide a queryKey (a unique array that identifies the data) and a queryFn (an async function that fetches the data). React Query caches the result under that key and returns an object with data, isLoading, isError, and error fields. If another component requests the same key, React Query returns the cached result instantly and optionally refetches in the background.

The queryKey is crucial. It must include every variable that the fetch depends on. For example, ['users', userId] ensures that switching userId triggers a new fetch. React Query serialises the key and uses it as a cache lookup. If you forget to include a dependency, you will get stale data.

staleTime controls how long cached data is considered fresh. While data is fresh, React Query will not refetch - it serves from cache. The default staleTime is 0 (immediately stale), which means every mount triggers a background refetch. Setting staleTime: 60_000 means data is fresh for one minute. gcTime (previously cacheTime) controls how long unused data stays in cache before garbage collection.

React Query eliminates a huge amount of boilerplate: no more managing loading/error booleans in state, no useEffect for fetching, no manual cache invalidation. It is the recommended way to handle data fetching in modern React applications.

Code examples

Basic useQuery usage

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

function UserProfile({ userId }) {
  const { data, isLoading, isError, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
    staleTime: 5 * 60 * 1000, // 5 minutes
  });

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

  return <h1>{data.name}</h1>;
}

queryKey includes userId so each user gets a separate cache entry. staleTime of 5 minutes means navigating back to this user within 5 minutes shows instant cached data with no refetch.

QueryClient provider setup

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

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60_000,       // 1 minute
      gcTime: 5 * 60_000,     // 5 minutes
      retry: 2,               // retry failed queries twice
      refetchOnWindowFocus: true,
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <RouterComponent />
    </QueryClientProvider>
  );
}

QueryClient holds the cache and default settings. Wrap your app with QueryClientProvider. All useQuery calls inside share this client.

Dependent queries

function UserPosts({ userId }) {
  // First query: get user
  const userQuery = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  // Second query: get posts, only runs when user is loaded
  const postsQuery = useQuery({
    queryKey: ['posts', userId],
    queryFn: () => fetchPostsByUser(userId),
    enabled: !!userQuery.data, // disabled until user loads
  });

  if (userQuery.isLoading) return <p>Loading user...</p>;
  if (postsQuery.isLoading) return <p>Loading posts...</p>;

  return (
    <div>
      <h1>{userQuery.data.name}</h1>
      {postsQuery.data.map(p => <p key={p.id}>{p.title}</p>)}
    </div>
  );
}

The enabled option lets you chain queries. postsQuery won't fire until userQuery.data is available, avoiding premature fetches.

Key points

Concepts covered

useQuery, queryKey, staleTime, caching, QueryClient, refetching