Skip to main content

Overview

TanStack Query (formerly React Query) is a powerful library for fetching, caching, and updating server state in React applications. It eliminates the need for manual data fetching logic and provides automatic caching, background refetching, and request deduplication.
React Query handles the complexities of data synchronization between your server and client, providing a better developer experience than traditional data fetching approaches.

Installation and Setup

Install TanStack Query:
npm install @tanstack/react-query

QueryClient Configuration

Set up the QueryClientProvider in your app root:
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { RouterProvider, createBrowserRouter } from 'react-router-dom';

const queryClient = new QueryClient();

const router = createBrowserRouter([
  {
    path: '/',
    element: <Navigate to="/events" />,
  },
  {
    path: '/events',
    element: <Events />,
    children: [
      {
        path: '/events/new',
        element: <NewEvent />,
      },
    ],
  },
]);

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

export default App;
The QueryClient instance manages the query cache and global configuration for all queries in your application.

Basic Query Usage

Fetch data using the useQuery hook:
import { useQuery } from '@tanstack/react-query';
import LoadingIndicator from '../UI/LoadingIndicator.jsx';
import ErrorBlock from '../UI/ErrorBlock.jsx';
import EventItem from './EventItem.jsx';
import { fetchEvents } from '../../util/http.js';

export default function NewEventsSection() {
  const { data, isPending, isError, error } = useQuery({
    queryKey: ['events'],
    queryFn: fetchEvents,
  });
  
  let content;
  
  if (isPending) {
    content = <LoadingIndicator />;
  }
  
  if (isError) {
    content = (
      <ErrorBlock
        title="An error occurred"
        message={error.info?.message || 'Failed to fetch events.'}
      />
    )
  }
  
  if (data) {
    content = (
      <ul className="events-list">
        {data.map((event) => (
          <li key={event.id}>
            <EventItem event={event} />
          </li>
        ))}
      </ul>
    );
  }
  
  return (
    <section className="content-section" id="new-events-section">
      <header>
        <h2>Recently added events</h2>
      </header>
      {content}
    </section>
  );
}
The queryKey uniquely identifies the query and is used for caching. Use an array to include dynamic parameters: ['events', { type: 'recent' }].

Query States

React Query provides several state flags:

isPending

Query is currently fetching for the first time

isError

Query encountered an error

isSuccess

Query successfully fetched data

isFetching

Query is fetching (including background refetches)

Mutations for Data Changes

Use useMutation to create, update, or delete data:
import { Link, useNavigate } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import Modal from '../UI/Modal.jsx';
import EventForm from './EventForm.jsx';
import { createNewEvent } from '../../util/http.js';
import ErrorBlock from '../UI/ErrorBlock.jsx';

export default function NewEvent() {
  const navigate = useNavigate();
  
  const { mutate, isPending, isError, error } = useMutation({
    mutationFn: createNewEvent,
  });
  
  function handleSubmit(formData) {
    mutate({ event: formData });
  }
  
  return (
    <Modal onClose={() => navigate('../')}>
      <EventForm onSubmit={handleSubmit}>
        {isPending && 'Submitting...'}
        {!isPending && (
          <>
            <Link to="../" className="button-text">
              Cancel
            </Link>
            <button type="submit" className="button">
              Create
            </button>
          </>
        )}
      </EventForm>
      {isError && (
        <ErrorBlock
          title="Failed to create event"
          message={
            error.info?.message ||
            'Failed to create event. Please check your inputs and try again later.'
          }
        />
      )}
    </Modal>
  );
}
Mutations don’t automatically refetch queries. You need to invalidate or update the cache manually.

Invalidating Queries

Refresh cached data after mutations:
import { useMutation } from '@tanstack/react-query';
import { queryClient } from '../../util/http.js';

export default function NewEvent() {
  const navigate = useNavigate();
  
  const { mutate, isPending, isError, error } = useMutation({
    mutationFn: createNewEvent,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['events'] });
      navigate('/events');
    },
  });
  
  function handleSubmit(formData) {
    mutate({ event: formData });
  }
  
  // ... rest of component
}
invalidateQueries marks queries as stale and triggers a refetch if they’re currently being used.

Query Configuration Options

Customize query behavior:
const { data } = useQuery({
  queryKey: ['events'],
  queryFn: fetchEvents,
  staleTime: 5000, // Data stays fresh for 5 seconds
});

Dynamic Queries

Fetch data based on user input or state:
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { fetchEvents } from '../../util/http.js';

export default function FindEventSection() {
  const [searchTerm, setSearchTerm] = useState('');
  
  const { data, isLoading, isError, error } = useQuery({
    queryKey: ['events', { search: searchTerm }],
    queryFn: () => fetchEvents({ search: searchTerm }),
    enabled: searchTerm.length > 0,
  });
  
  return (
    <section>
      <input
        type="search"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search events..."
      />
      
      {isLoading && <p>Loading...</p>}
      {isError && <p>Error: {error.message}</p>}
      
      {data && (
        <ul>
          {data.map((event) => (
            <li key={event.id}>{event.title}</li>
          ))}
        </ul>
      )}
    </section>
  );
}
Include dynamic parameters in the queryKey array so React Query caches results separately for different search terms.

Optimistic Updates

Update the UI immediately before the server responds:
const { mutate } = useMutation({
  mutationFn: updateEvent,
  onMutate: async (newEvent) => {
    // Cancel outgoing refetches
    await queryClient.cancelQueries({ queryKey: ['events', newEvent.id] });
    
    // Snapshot the previous value
    const previousEvent = queryClient.getQueryData(['events', newEvent.id]);
    
    // Optimistically update to the new value
    queryClient.setQueryData(['events', newEvent.id], newEvent);
    
    // Return context with the previous value
    return { previousEvent };
  },
  onError: (err, newEvent, context) => {
    // Rollback to previous value on error
    queryClient.setQueryData(
      ['events', newEvent.id],
      context.previousEvent
    );
  },
  onSettled: (newEvent) => {
    // Refetch after error or success
    queryClient.invalidateQueries({ queryKey: ['events', newEvent.id] });
  },
});
Always implement error handling to rollback optimistic updates when mutations fail.

Global Query Configuration

Set default options for all queries:
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5 minutes
      cacheTime: 1000 * 60 * 10, // 10 minutes
      retry: 3,
      refetchOnWindowFocus: false,
    },
    mutations: {
      retry: 1,
    },
  },
});

Pagination

Implement paginated queries:
const [page, setPage] = useState(1);

const { data, isPreviousData } = useQuery({
  queryKey: ['events', page],
  queryFn: () => fetchEvents({ page }),
  keepPreviousData: true, // Keep old data while fetching new page
});

return (
  <div>
    <EventsList events={data?.events} />
    
    <button
      onClick={() => setPage((old) => Math.max(old - 1, 1))}
      disabled={page === 1}
    >
      Previous
    </button>
    
    <button
      onClick={() => setPage((old) => old + 1)}
      disabled={isPreviousData || !data?.hasMore}
    >
      Next
    </button>
  </div>
);

Infinite Queries

Implement infinite scroll:
import { useInfiniteQuery } from '@tanstack/react-query';

function Events() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['events'],
    queryFn: ({ pageParam = 1 }) => fetchEvents({ page: pageParam }),
    getNextPageParam: (lastPage, pages) => {
      return lastPage.hasMore ? pages.length + 1 : undefined;
    },
  });
  
  return (
    <div>
      {data?.pages.map((page, i) => (
        <div key={i}>
          {page.events.map((event) => (
            <EventItem key={event.id} event={event} />
          ))}
        </div>
      ))}
      
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage
          ? 'Loading more...'
          : hasNextPage
          ? 'Load More'
          : 'Nothing more to load'}
      </button>
    </div>
  );
}

DevTools

Add React Query DevTools for debugging:
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}
DevTools only appear in development and help you visualize query states, cache contents, and refetch behavior.

Comparison with React Router Loaders

  • Automatic caching and background refetching
  • Built-in loading and error states
  • Request deduplication
  • Optimistic updates
  • Better for frequently changing data
  • Loads data before rendering (no loading spinner)
  • Tightly integrated with navigation
  • Simpler for static or infrequently changing data
  • Better for initial page load performance
Use React Query when:
  • Data changes frequently
  • Multiple components need the same data
  • You need background refetching
  • Optimistic updates are important
Use React Router Loaders when:
  • Data is relatively static
  • Initial page load speed is critical
  • Data is route-specific
  • Simple read-only scenarios

Best Practices

Query Keys

Use descriptive, hierarchical keys:
['events']
['events', { type: 'recent' }]
['events', eventId]
['events', eventId, 'attendees']

Error Handling

Throw custom errors in query functions:
if (!response.ok) {
  const error = new Error('Failed');
  error.info = await response.json();
  throw error;
}

Avoid Over-fetching

Use enabled to prevent unnecessary requests:
useQuery({
  queryKey: ['user', userId],
  queryFn: fetchUser,
  enabled: !!userId,
});

Separate Concerns

Keep API functions in separate files:
// util/http.js
export async function fetchEvents() {
  // API logic
}

Routing

Compare React Query with React Router data loading

Deployment

Deploy applications using React Query

Build docs developers (and LLMs) love