TanStack Query (React Query)

Powerful server state management with caching, background updates, and more

TanStack Query - Server State Management

TanStack Query (formerly React Query) is a powerful data-fetching and server state management library. It handles caching, background updates, stale data, pagination, and more out of the box. It's specifically designed for managing server state - data that lives on your backend.

Key Features

  • Automatic Caching — Cache responses and reduce API calls
  • Background Updates — Refetch stale data automatically
  • Request Deduplication — Dedupe identical concurrent requests
  • Optimistic Updates — Update UI before server confirms
  • Infinite Queries — Easy pagination and infinite scroll

Installation

npm install @tanstack/react-query

Setup

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

// Create a client
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60, // 1 minute
      retry: 3,
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <MyApp />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

Basic Query

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

// Fetch function
async function fetchTodos() {
  const response = await fetch('/api/todos');
  if (!response.ok) throw new Error('Failed to fetch');
  return response.json();
}

function TodoList() {
  const { data, isLoading, isError, error, refetch } = useQuery({
    queryKey: ['todos'],           // Unique key for caching
    queryFn: fetchTodos,           // Function that returns a promise
  });
  
  if (isLoading) return <div>Loading...</div>;
  if (isError) return <div>Error: {error.message}</div>;
  
  return (
    <div>
      <button onClick={() => refetch()}>Refresh</button>
      <ul>
        {data.map(todo => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </div>
  );
}

Query with Parameters

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

async function fetchUser(userId) {
  const response = await fetch(`/api/users/${userId}`);
  return response.json();
}

function UserProfile({ userId }) {
  const { data: user, isLoading } = useQuery({
    queryKey: ['user', userId],    // Key includes the parameter
    queryFn: () => fetchUser(userId),
    enabled: !!userId,             // Only run if userId exists
  });
  
  if (isLoading) return <div>Loading...</div>;
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

// Dependent queries
function UserPosts({ userId }) {
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });
  
  const { data: posts } = useQuery({
    queryKey: ['posts', user?.id],
    queryFn: () => fetchPosts(user.id),
    enabled: !!user?.id,           // Only fetch when user is loaded
  });
  
  return (/* ... */);
}

Mutations

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

async function createTodo(newTodo) {
  const response = await fetch('/api/todos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(newTodo),
  });
  return response.json();
}

function AddTodo() {
  const queryClient = useQueryClient();
  const [text, setText] = useState('');
  
  const mutation = useMutation({
    mutationFn: createTodo,
    onSuccess: () => {
      // Invalidate and refetch todos
      queryClient.invalidateQueries({ queryKey: ['todos'] });
      setText('');
    },
    onError: (error) => {
      console.error('Failed to create todo:', error);
    },
  });
  
  const handleSubmit = (e) => {
    e.preventDefault();
    mutation.mutate({ text, completed: false });
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input 
        value={text} 
        onChange={(e) => setText(e.target.value)}
        disabled={mutation.isPending}
      />
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Adding...' : 'Add Todo'}
      </button>
    </form>
  );
}

Optimistic Updates

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

function TodoItem({ todo }) {
  const queryClient = useQueryClient();
  
  const toggleMutation = useMutation({
    mutationFn: (todoId) => fetch(`/api/todos/${todoId}/toggle`, { method: 'PATCH' }),
    
    // Optimistic update
    onMutate: async (todoId) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      
      // Snapshot previous value
      const previousTodos = queryClient.getQueryData(['todos']);
      
      // Optimistically update
      queryClient.setQueryData(['todos'], (old) =>
        old.map(t => t.id === todoId ? { ...t, completed: !t.completed } : t)
      );
      
      // Return context with snapshot
      return { previousTodos };
    },
    
    // If mutation fails, rollback
    onError: (err, todoId, context) => {
      queryClient.setQueryData(['todos'], context.previousTodos);
    },
    
    // Always refetch after error or success
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });
  
  return (
    <li onClick={() => toggleMutation.mutate(todo.id)}>
      {todo.completed ? '✓' : '○'} {todo.text}
    </li>
  );
}

Infinite Queries

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

async function fetchPosts({ pageParam = 1 }) {
  const response = await fetch(`/api/posts?page=${pageParam}&limit=10`);
  return response.json();
}

function InfinitePostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    getNextPageParam: (lastPage, pages) => {
      // Return undefined if no more pages
      return lastPage.hasMore ? pages.length + 1 : undefined;
    },
  });
  
  if (isLoading) return <div>Loading...</div>;
  
  return (
    <div>
      {data.pages.map((page, i) => (
        <div key={i}>
          {page.posts.map(post => (
            <PostCard key={post.id} post={post} />
          ))}
        </div>
      ))}
      
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage
          ? 'Loading more...'
          : hasNextPage
            ? 'Load More'
            : 'No more posts'}
      </button>
    </div>
  );
}

Query States

State Description
isPending Query has no data yet (first load)
isLoading isPending && isFetching (initial load)
isFetching Any fetch is in progress (including background)
isStale Data is older than staleTime
isSuccess Query has data and no error

💡 Best Practices

  • • Use descriptive query keys: ['todos', { filter, sort }]
  • • Set appropriate staleTime for your data freshness needs
  • • Use enabled option to control when queries run
  • • Invalidate queries after mutations
  • • Use optimistic updates for better UX
  • • Install React Query DevTools for debugging