Back to articles
React

Mastering TanStack Query: From Basics to Advanced Patterns

Complete guide to TanStack Query (React Query) covering caching strategies, optimistic updates, infinite queries, and real-time data synchronization in React applications.

15 mins read
TanStack QueryReactState ManagementAPI

TanStack Query (formerly React Query) revolutionizes data fetching in React applications by providing powerful caching, background updates, and synchronization features. This comprehensive guide covers everything from basic usage to advanced patterns for building robust data-driven applications.

Getting Started with TanStack Query

Terminal
# Install TanStack Querynpm install @tanstack/react-query# Install devtools (optional but recommended)npm install @tanstack/react-query-devtools

Set up the QueryClient and provider in your app:

ReactApp.tsx
import React from 'react';import { QueryClient, QueryClientProvider } from '@tanstack/react-query';import { ReactQueryDevtools } from '@tanstack/react-query-devtools';const queryClient = new QueryClient({  defaultOptions: {    queries: {      staleTime: 1000 * 60 * 5, // 5 minutes      cacheTime: 1000 * 60 * 10, // 10 minutes      retry: 3,      refetchOnWindowFocus: false,    },  },});export function App() {  return (    <QueryClientProvider client={queryClient}>      <div className="App">        {/* Your app components */}      </div>      <ReactQueryDevtools initialIsOpen={false} />    </QueryClientProvider>  );}

Basic Data Fetching with useQuery

ReactUserProfile.tsx
import React from 'react';import { useQuery } from '@tanstack/react-query';interface User {  id: number;  name: string;  email: string;  avatar: string;}async function fetchUser(userId: number): Promise<User> {  const response = await fetch(`/api/users/${userId}`);  if (!response.ok) {    throw new Error('Failed to fetch user');  }  return response.json();}export function UserProfile({ userId }: { userId: number }) {  const {    data: user,    isLoading,    error,    isError  } = useQuery({    queryKey: ['user', userId],    queryFn: () => fetchUser(userId),    enabled: !!userId, // Only run if userId exists  });  if (isLoading) return <div>Loading user...</div>;  if (isError) return <div>Error: {error.message}</div>;  if (!user) return <div>User not found</div>;  return (    <div className="user-profile">      <img src={user.avatar} alt={user.name} />      <h2>{user.name}</h2>      <p>{user.email}</p>    </div>  );}

Mutations for Data Updates

ReactUserForm.tsx
import React, { useState } from 'react';import { useMutation, useQueryClient } from '@tanstack/react-query';interface UpdateUserData {  name: string;  email: string;}async function updateUser(userId: number, data: UpdateUserData): Promise<User> {  const response = await fetch(`/api/users/${userId}`, {    method: 'PUT',    headers: { 'Content-Type': 'application/json' },    body: JSON.stringify(data),  });    if (!response.ok) {    throw new Error('Failed to update user');  }    return response.json();}export function UserForm({ userId, initialData }: {   userId: number;   initialData: UpdateUserData;}) {  const [formData, setFormData] = useState(initialData);  const queryClient = useQueryClient();  const updateUserMutation = useMutation({    mutationFn: (data: UpdateUserData) => updateUser(userId, data),    onSuccess: (updatedUser) => {      // Update the cache with new data      queryClient.setQueryData(['user', userId], updatedUser);            // Invalidate related queries      queryClient.invalidateQueries({ queryKey: ['users'] });            console.log('User updated successfully!');    },    onError: (error) => {      console.error('Failed to update user:', error);    },  });  const handleSubmit = (e: React.FormEvent) => {    e.preventDefault();    updateUserMutation.mutate(formData);  };  return (    <form onSubmit={handleSubmit}>      <input        type="text"        value={formData.name}        onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}        placeholder="Name"      />      <input        type="email"        value={formData.email}        onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}        placeholder="Email"      />      <button         type="submit"         disabled={updateUserMutation.isPending}      >        {updateUserMutation.isPending ? 'Updating...' : 'Update User'}      </button>            {updateUserMutation.isError && (        <div className="error">          Error: {updateUserMutation.error.message}        </div>      )}    </form>  );}

Optimistic Updates

ReactOptimisticTodos.tsx
import React from 'react';import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';interface Todo {  id: number;  text: string;  completed: boolean;}export function TodoList() {  const queryClient = useQueryClient();  const { data: todos = [] } = useQuery({    queryKey: ['todos'],    queryFn: fetchTodos,  });  const toggleTodoMutation = useMutation({    mutationFn: toggleTodo,    onMutate: async (todoId: number) => {      // Cancel any outgoing refetches      await queryClient.cancelQueries({ queryKey: ['todos'] });      // Snapshot the previous value      const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);      // Optimistically update to the new value      queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>        old.map(todo =>          todo.id === todoId             ? { ...todo, completed: !todo.completed }            : todo        )      );      // Return a context object with the snapshotted value      return { previousTodos };    },    onError: (err, todoId, context) => {      // If the mutation fails, rollback to the previous value      if (context?.previousTodos) {        queryClient.setQueryData(['todos'], context.previousTodos);      }    },    onSettled: () => {      // Always refetch after error or success      queryClient.invalidateQueries({ queryKey: ['todos'] });    },  });  return (    <ul>      {todos.map(todo => (        <li key={todo.id}>          <label>            <input              type="checkbox"              checked={todo.completed}              onChange={() => toggleTodoMutation.mutate(todo.id)}            />            {todo.text}          </label>        </li>      ))}    </ul>  );}

Infinite Queries for Pagination

ReactInfiniteUserList.tsx
import React from 'react';import { useInfiniteQuery } from '@tanstack/react-query';interface UsersResponse {  users: User[];  nextCursor?: number;  hasMore: boolean;}async function fetchUsers({ pageParam = 0 }): Promise<UsersResponse> {  const response = await fetch(`/api/users?cursor=${pageParam}&limit=10`);  if (!response.ok) throw new Error('Failed to fetch users');  return response.json();}export function InfiniteUserList() {  const {    data,    fetchNextPage,    hasNextPage,    isFetchingNextPage,    isLoading,    error,  } = useInfiniteQuery({    queryKey: ['users', 'infinite'],    queryFn: fetchUsers,    getNextPageParam: (lastPage) => lastPage.nextCursor,    initialPageParam: 0,  });  if (isLoading) return <div>Loading...</div>;  if (error) return <div>Error: {error.message}</div>;  return (    <div>      {data?.pages.map((page, i) => (        <div key={i}>          {page.users.map(user => (            <div key={user.id} className="user-item">              <h3>{user.name}</h3>              <p>{user.email}</p>            </div>          ))}        </div>      ))}            <button        onClick={() => fetchNextPage()}        disabled={!hasNextPage || isFetchingNextPage}      >        {isFetchingNextPage          ? 'Loading more...'          : hasNextPage          ? 'Load More'          : 'No more users'}      </button>    </div>  );}

Custom Hooks for Reusability

TypeScriptcustomHooks.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';// Custom hook for user dataexport function useUser(userId: number) {  return useQuery({    queryKey: ['user', userId],    queryFn: () => fetchUser(userId),    enabled: !!userId,    staleTime: 1000 * 60 * 5, // 5 minutes  });}// Custom hook for user mutationsexport function useUserMutations(userId: number) {  const queryClient = useQueryClient();  const updateUser = useMutation({    mutationFn: (data: UpdateUserData) => updateUserApi(userId, data),    onSuccess: (updatedUser) => {      queryClient.setQueryData(['user', userId], updatedUser);      queryClient.invalidateQueries({ queryKey: ['users'] });    },  });  const deleteUser = useMutation({    mutationFn: () => deleteUserApi(userId),    onSuccess: () => {      queryClient.removeQueries({ queryKey: ['user', userId] });      queryClient.invalidateQueries({ queryKey: ['users'] });    },  });  return {    updateUser,    deleteUser,  };}// Custom hook with dependent queriesexport function useUserWithPosts(userId: number) {  const userQuery = useUser(userId);    const postsQuery = useQuery({    queryKey: ['posts', userId],    queryFn: () => fetchUserPosts(userId),    enabled: !!userQuery.data, // Only fetch posts if user exists  });  return {    user: userQuery.data,    posts: postsQuery.data,    isLoading: userQuery.isLoading || postsQuery.isLoading,    error: userQuery.error || postsQuery.error,  };}
Smart Caching

Automatic background updates, cache invalidation, and intelligent data synchronization.

Optimistic Updates

Update UI immediately for better UX, with automatic rollback on errors.

Infinite Queries

Built-in pagination support for infinite scrolling and load-more patterns.

DevTools Integration

Powerful debugging tools for inspecting queries, cache, and mutations.