TanStack Query (React Query) - Powerful Async State Management

Learn how to use TanStack Query for data fetching, caching, synchronization, and server state management in React and Next.js applications with automatic background updates and optimistic UI.

TanStack Query (React Query)

TanStack Query

TanStack Query (formerly React Query) is a powerful library for fetching, caching, synchronizing, and updating server state in React applications. It eliminates the need for manual state management, loading states, and cache invalidation logic.

What is TanStack Query?

TanStack Query is not just a data fetching library - it's a comprehensive async state management solution that makes fetching, caching, synchronizing and updating server state a breeze.

Key Features

  • Automatic caching: Intelligent caching with automatic garbage collection
  • Background refetching: Keep data fresh without blocking UI
  • Request deduplication: Prevent duplicate requests automatically
  • Optimistic updates: Update UI before server confirms
  • Pagination & infinite scroll: Built-in support for paginated data
  • Parallel & dependent queries: Advanced query orchestration
  • DevTools: Powerful debugging and inspection tools
  • TypeScript support: Full type safety
  • SSR compatible: Works with Next.js server components

Why TanStack Query?

Traditional state management libraries (Redux, MobX) are designed for client state, not server state. Server state has unique challenges:

  • Data is stored remotely (you don't own it)
  • Requires asynchronous APIs
  • Can be changed by others without your knowledge
  • Can become stale
  • Needs caching, deduplication, and background updates

TanStack Query handles all of these concerns automatically.

Installation

Install TanStack Query in your Next.js project:

yarn add @tanstack/react-query
yarn add -D @tanstack/react-query-devtools

Setup

App Router Setup (Next.js 13+)

// app/providers.tsx
'use client';

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

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000, // 1 minute
        refetchOnWindowFocus: false,
      },
    },
  }));

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}
// app/layout.tsx
import { Providers } from './providers';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

Basic Usage

useQuery - Fetching Data

The useQuery hook fetches and caches data:

'use client';

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

interface User {
  id: string;
  name: string;
  email: string;
}

function UsersPage() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: async () => {
      const response = await fetch('/api/users');
      if (!response.ok) throw new Error('Failed to fetch users');
      return response.json() as Promise<User[]>;
    }
  });

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

  return (
    <div>
      <h1>Users</h1>
      {data?.map(user => (
        <div key={user.id}>
          {user.name} - {user.email}
        </div>
      ))}
    </div>
  );
}

Query Keys

Query keys uniquely identify queries for caching:

// Simple key
useQuery({ queryKey: ['users'], queryFn: fetchUsers });

// Key with parameters
useQuery({ 
  queryKey: ['user', userId], 
  queryFn: () => fetchUser(userId) 
});

// Complex key with multiple parameters
useQuery({
  queryKey: ['users', { page, limit, sort }],
  queryFn: () => fetchUsers({ page, limit, sort })
});

Query with TypeScript

interface Product {
  id: string;
  name: string;
  price: number;
}

function ProductList() {
  const { data, isLoading, error } = useQuery<Product[], Error>({
    queryKey: ['products'],
    queryFn: async () => {
      const res = await fetch('/api/products');
      return res.json();
    }
  });

  // data is typed as Product[] | undefined
  // error is typed as Error | null
}

Query States

TanStack Query provides detailed state information:

function DataComponent() {
  const {
    data,
    error,
    isLoading,      // Initial loading state
    isFetching,     // Loading or refetching
    isSuccess,      // Query succeeded
    isError,        // Query failed
    isPending,      // Query is pending
    isRefetching,   // Background refetch
    status,         // 'pending' | 'error' | 'success'
    fetchStatus,    // 'fetching' | 'paused' | 'idle'
  } = useQuery({
    queryKey: ['data'],
    queryFn: fetchData
  });

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

  return (
    <div>
      {isFetching && <div>Updating...</div>}
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
}

useMutation - Modifying Data

The useMutation hook handles POST, PUT, PATCH, DELETE operations:

'use client';

import { useMutation, useQueryClient } from '@tanstack/react-query';

interface CreateUserData {
  name: string;
  email: string;
}

function CreateUserForm() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: async (newUser: CreateUserData) => {
      const response = await fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newUser)
      });
      return response.json();
    },
    onSuccess: () => {
      // Invalidate and refetch users query
      queryClient.invalidateQueries({ queryKey: ['users'] });
    }
  });

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    mutation.mutate({
      name: formData.get('name') as string,
      email: formData.get('email') as string
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" placeholder="Name" required />
      <input name="email" type="email" placeholder="Email" required />
      <button disabled={mutation.isPending}>
        {mutation.isPending ? 'Creating...' : 'Create User'}
      </button>
      {mutation.isError && <div>Error: {mutation.error.message}</div>}
      {mutation.isSuccess && <div>User created successfully!</div>}
    </form>
  );
}

Cache Management

Invalidating Queries

Tell TanStack Query that data is stale and needs refetching:

const queryClient = useQueryClient();

// Invalidate specific query
queryClient.invalidateQueries({ queryKey: ['users'] });

// Invalidate multiple queries
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['posts'] });

// Invalidate all queries starting with 'user'
queryClient.invalidateQueries({ queryKey: ['user'] });

Updating Cache Manually

const queryClient = useQueryClient();

// Set data in cache
queryClient.setQueryData(['user', userId], newUserData);

// Update data in cache
queryClient.setQueryData(['user', userId], (oldData) => ({
  ...oldData,
  name: 'Updated Name'
}));

// Get data from cache
const cachedUser = queryClient.getQueryData(['user', userId]);

Optimistic Updates

Update UI immediately, then sync with server:

const mutation = useMutation({
  mutationFn: updateUser,
  onMutate: async (newUser) => {
    // Cancel outgoing refetches
    await queryClient.cancelQueries({ queryKey: ['user', newUser.id] });

    // Snapshot previous value
    const previousUser = queryClient.getQueryData(['user', newUser.id]);

    // Optimistically update
    queryClient.setQueryData(['user', newUser.id], newUser);

    // Return context for rollback
    return { previousUser };
  },
  onError: (err, newUser, context) => {
    // Rollback on error
    if (context?.previousUser) {
      queryClient.setQueryData(
        ['user', newUser.id],
        context.previousUser
      );
    }
  },
  onSettled: (newUser) => {
    // Always refetch after error or success
    queryClient.invalidateQueries({ queryKey: ['user', newUser?.id] });
  }
});

Advanced Patterns

Dependent Queries

Fetch data that depends on previous queries:

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

  // Second query - depends on first query
  const { data: posts } = useQuery({
    queryKey: ['posts', user?.id],
    queryFn: () => fetchUserPosts(user!.id),
    enabled: !!user // Only run when user is available
  });

  return <div>{/* Render user and posts */}</div>;
}

Parallel Queries

Fetch multiple resources simultaneously:

function Dashboard() {
  const usersQuery = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers
  });

  const postsQuery = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts
  });

  const commentsQuery = useQuery({
    queryKey: ['comments'],
    queryFn: fetchComments
  });

  const isLoading = usersQuery.isLoading || 
                    postsQuery.isLoading || 
                    commentsQuery.isLoading;

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      <UsersList data={usersQuery.data} />
      <PostsList data={postsQuery.data} />
      <CommentsList data={commentsQuery.data} />
    </div>
  );
}

Using useQueries for Dynamic Parallel Queries

function UserProfiles({ userIds }: { userIds: string[] }) {
  const userQueries = useQueries({
    queries: userIds.map(id => ({
      queryKey: ['user', id],
      queryFn: () => fetchUser(id)
    }))
  });

  const isLoading = userQueries.some(query => query.isLoading);
  const allData = userQueries.map(query => query.data);

  return <div>{/* Render all users */}</div>;
}

Pagination

Traditional Pagination

'use client';

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

function ProductsList() {
  const [page, setPage] = useState(1);

  const { data, isLoading } = useQuery({
    queryKey: ['products', page],
    queryFn: () => fetchProducts(page),
    placeholderData: (previousData) => previousData, // Keep old data while fetching
  });

  return (
    <div>
      {isLoading ? (
        <div>Loading...</div>
      ) : (
        <>
          {data?.products.map(product => (
            <div key={product.id}>{product.name}</div>
          ))}
          <button 
            onClick={() => setPage(p => p - 1)} 
            disabled={page === 1}
          >
            Previous
          </button>
          <span>Page {page}</span>
          <button 
            onClick={() => setPage(p => p + 1)}
            disabled={!data?.hasMore}
          >
            Next
          </button>
        </>
      )}
    </div>
  );
}

Infinite Queries (Infinite Scroll)

'use client';

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

interface PostsResponse {
  posts: Post[];
  nextCursor?: number;
}

function InfinitePostsList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: async ({ pageParam = 0 }) => {
      const response = await fetch(`/api/posts?cursor=${pageParam}`);
      return response.json() as Promise<PostsResponse>;
    },
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    initialPageParam: 0,
  });

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      {data?.pages.map((page, i) => (
        <div key={i}>
          {page.posts.map(post => (
            <div key={post.id}>
              <h3>{post.title}</h3>
              <p>{post.content}</p>
            </div>
          ))}
        </div>
      ))}

      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage
          ? 'Loading more...'
          : hasNextPage
          ? 'Load More'
          : 'Nothing more to load'}
      </button>
    </div>
  );
}

Prefetching

Load data before it's needed:

'use client';

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

function ProductCard({ productId }: { productId: string }) {
  const queryClient = useQueryClient();

  // Prefetch on hover
  const handleMouseEnter = () => {
    queryClient.prefetchQuery({
      queryKey: ['product', productId],
      queryFn: () => fetchProduct(productId),
      staleTime: 60 * 1000, // Cache for 1 minute
    });
  };

  return (
    <div onMouseEnter={handleMouseEnter}>
      <Link href={`/products/${productId}`}>
        View Product
      </Link>
    </div>
  );
}

Server-Side Prefetching (Next.js)

// app/products/page.tsx
import { 
  dehydrate, 
  HydrationBoundary,
  QueryClient 
} from '@tanstack/react-query';
import { ProductsList } from './products-list';

export default async function ProductsPage() {
  const queryClient = new QueryClient();

  // Prefetch on server
  await queryClient.prefetchQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <ProductsList />
    </HydrationBoundary>
  );
}

Background Refetching

TanStack Query automatically refetches data in the background:

const { data } = useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  staleTime: 60 * 1000, // Consider fresh for 1 minute
  refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes
  refetchOnWindowFocus: true, // Refetch when window regains focus
  refetchOnReconnect: true, // Refetch when internet reconnects
  refetchOnMount: true, // Refetch when component mounts
});

Error Handling

Query-Level Error Handling

function UserProfile({ userId }: { userId: string }) {
  const { data, error, isError } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    retry: 3, // Retry failed requests 3 times
    retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
  });

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

  return <div>{/* Render user */}</div>;
}

Global Error Handling

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 2,
      onError: (error) => {
        console.error('Query error:', error);
        toast.error('Failed to load data');
      },
    },
    mutations: {
      onError: (error) => {
        console.error('Mutation error:', error);
        toast.error('Failed to save changes');
      },
    },
  },
});

Real-World Examples

Complete CRUD Example

'use client';

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

interface Todo {
  id: string;
  title: string;
  completed: boolean;
}

function TodoApp() {
  const queryClient = useQueryClient();

  // Fetch todos
  const { data: todos, isLoading } = useQuery({
    queryKey: ['todos'],
    queryFn: async () => {
      const res = await fetch('/api/todos');
      return res.json() as Promise<Todo[]>;
    },
  });

  // Create todo
  const createMutation = useMutation({
    mutationFn: async (title: string) => {
      const res = await fetch('/api/todos', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title }),
      });
      return res.json();
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  // Update todo
  const updateMutation = useMutation({
    mutationFn: async (todo: Todo) => {
      const res = await fetch(`/api/todos/${todo.id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(todo),
      });
      return res.json();
    },
    onMutate: async (updatedTodo) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      const previousTodos = queryClient.getQueryData(['todos']);

      queryClient.setQueryData<Todo[]>(['todos'], (old) =>
        old?.map(todo =>
          todo.id === updatedTodo.id ? updatedTodo : todo
        )
      );

      return { previousTodos };
    },
    onError: (_err, _todo, context) => {
      queryClient.setQueryData(['todos'], context?.previousTodos);
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  // Delete todo
  const deleteMutation = useMutation({
    mutationFn: async (id: string) => {
      await fetch(`/api/todos/${id}`, { method: 'DELETE' });
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      <h1>Todos</h1>
      
      <form onSubmit={(e) => {
        e.preventDefault();
        const formData = new FormData(e.currentTarget);
        createMutation.mutate(formData.get('title') as string);
        e.currentTarget.reset();
      }}>
        <input name="title" placeholder="New todo" required />
        <button disabled={createMutation.isPending}>Add</button>
      </form>

      <ul>
        {todos?.map(todo => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() =>
                updateMutation.mutate({
                  ...todo,
                  completed: !todo.completed
                })
              }
            />
            <span style={{
              textDecoration: todo.completed ? 'line-through' : 'none'
            }}>
              {todo.title}
            </span>
            <button onClick={() => deleteMutation.mutate(todo.id)}>
              Delete
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

DevTools

TanStack Query DevTools provide powerful debugging:

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

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools 
        initialIsOpen={false}
        position="bottom-right"
      />
    </QueryClientProvider>
  );
}

DevTools features:

  • View all queries and their states
  • Inspect query data
  • Manually trigger refetches
  • View query timeline
  • Debug cache behavior

Best Practices

1. Use Query Keys Consistently

// Good - consistent key structure
const todoKeys = {
  all: ['todos'] as const,
  lists: () => [...todoKeys.all, 'list'] as const,
  list: (filters: string) => [...todoKeys.lists(), { filters }] as const,
  details: () => [...todoKeys.all, 'detail'] as const,
  detail: (id: string) => [...todoKeys.details(), id] as const,
};

// Usage
useQuery({ queryKey: todoKeys.detail(id), queryFn: () => fetchTodo(id) });

2. Configure Sensible Defaults

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000, // 1 minute
      refetchOnWindowFocus: false,
      retry: 1,
    },
  },
});

3. Separate Concerns

// hooks/useTodos.ts
export function useTodos() {
  return useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  });
}

export function useCreateTodo() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: createTodo,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });
}

// Component
function TodoList() {
  const { data } = useTodos();
  const createTodo = useCreateTodo();
  
  return <div>{/* ... */}</div>;
}

4. Handle Loading and Error States

function DataComponent() {
  const { data, isLoading, isError, error, refetch } = useQuery({
    queryKey: ['data'],
    queryFn: fetchData,
  });

  if (isLoading) return <LoadingSkeleton />;
  
  if (isError) {
    return (
      <ErrorState 
        message={error.message}
        onRetry={refetch}
      />
    );
  }

  return <DataDisplay data={data} />;
}

Summary

TanStack Query is a game-changer for data fetching in React applications. It provides:

  • Automatic caching with intelligent invalidation
  • Background updates to keep data fresh
  • Optimistic updates for instant UI feedback
  • Request deduplication to prevent redundant calls
  • Pagination & infinite scroll out of the box
  • DevTools for debugging
  • TypeScript support for type safety

By handling all the complexities of server state management, TanStack Query lets you focus on building features instead of managing boilerplate code. It's especially powerful when combined with Axios for HTTP requests or used with Next.js API routes.

In the next lesson, we'll explore Refine, a complete framework that builds on these concepts to provide full CRUD application scaffolding.