Skip to main content
Technology & EngineeringState Management382 lines

TanStack Query (React Query)

TanStack Query for server state — useQuery, useMutation, query invalidation, optimistic updates, infinite queries, prefetching, and SSR hydration

Quick Summary25 lines
TanStack Query separates server state from client state. Server state is asynchronous, shared, and can become stale — it needs fundamentally different tools than local UI state. TanStack Query provides a declarative cache with automatic background refetching, deduplication, garbage collection, and mutation support. You describe what data you need; the library handles when and how to fetch it.

## Key Points

- **Server state is not client state** — remote data has different lifecycle concerns (staleness, caching, synchronization)
- **Declarative data fetching** — components declare their data dependencies; the cache orchestrates the rest
- **Automatic staleness management** — data is refetched in the background based on configurable stale times
- **Request deduplication** — multiple components requesting the same query trigger only one network request
- **Optimistic updates** — UI can update immediately while mutations are in flight
- **Framework-agnostic core** — `@tanstack/react-query` is the React adapter
1. **Colocate query hooks** — wrap `useQuery` in custom hooks (`useTodos`, `useUser`) to centralize query keys and fetch logic.
2. **Structure query keys hierarchically** — `['todos', 'list', { status }]` and `['todos', 'detail', id]` allow precise invalidation.
3. **Set appropriate `staleTime`** — default is `0` (always stale); set higher for data that changes infrequently to reduce refetches.
4. **Use `enabled` for conditional fetching** — never call hooks conditionally; use the `enabled` option instead.
5. **Extract query key factories** — define `todoKeys.all`, `todoKeys.detail(id)` as functions to avoid key typos.
6. **Prefetch on user intent** — hover, focus, or route-level prefetching makes navigation feel instant.

## Quick Example

```bash
npm install @tanstack/react-query
npm install @tanstack/react-query-devtools  # optional
```
skilldb get state-management-skills/TanStack Query (React Query)Full skill: 382 lines
Paste into your CLAUDE.md or agent config

TanStack Query (React Query)

Core Philosophy

TanStack Query separates server state from client state. Server state is asynchronous, shared, and can become stale — it needs fundamentally different tools than local UI state. TanStack Query provides a declarative cache with automatic background refetching, deduplication, garbage collection, and mutation support. You describe what data you need; the library handles when and how to fetch it.

  • Server state is not client state — remote data has different lifecycle concerns (staleness, caching, synchronization)
  • Declarative data fetching — components declare their data dependencies; the cache orchestrates the rest
  • Automatic staleness management — data is refetched in the background based on configurable stale times
  • Request deduplication — multiple components requesting the same query trigger only one network request
  • Optimistic updates — UI can update immediately while mutations are in flight
  • Framework-agnostic core@tanstack/react-query is the React adapter

Setup

npm install @tanstack/react-query
npm install @tanstack/react-query-devtools  # optional
// app/providers.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ReactNode, useState } from 'react';

export function QueryProvider({ children }: { children: ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000,       // 1 minute
            gcTime: 5 * 60 * 1000,      // 5 minutes (formerly cacheTime)
            retry: 2,
            refetchOnWindowFocus: true,
          },
        },
      })
  );

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

Key Techniques

useQuery — Fetching Data

// hooks/useTodos.ts
import { useQuery } from '@tanstack/react-query';

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

async function fetchTodos(): Promise<Todo[]> {
  const res = await fetch('/api/todos');
  if (!res.ok) throw new Error('Failed to fetch todos');
  return res.json();
}

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

// Parameterized query
export function useTodo(id: string) {
  return useQuery({
    queryKey: ['todos', id],
    queryFn: async (): Promise<Todo> => {
      const res = await fetch(`/api/todos/${id}`);
      if (!res.ok) throw new Error('Todo not found');
      return res.json();
    },
    enabled: !!id,  // only fetch when id is truthy
  });
}
// components/TodoList.tsx
import { useTodos } from '../hooks/useTodos';

export function TodoList() {
  const { data: todos, isLoading, isError, error } = useTodos();

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

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

useMutation — Modifying Data

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

interface CreateTodoInput {
  title: string;
}

export function useCreateTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (input: CreateTodoInput): Promise<Todo> => {
      const res = await fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify(input),
        headers: { 'Content-Type': 'application/json' },
      });
      if (!res.ok) throw new Error('Failed to create todo');
      return res.json();
    },
    onSuccess: () => {
      // Invalidate and refetch the todos list
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });
}
function AddTodoForm() {
  const createTodo = useCreateTodo();

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    createTodo.mutate(
      { title: formData.get('title') as string },
      {
        onSuccess: () => e.currentTarget.reset(),
        onError: (err) => alert(err.message),
      }
    );
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" required />
      <button type="submit" disabled={createTodo.isPending}>
        {createTodo.isPending ? 'Adding...' : 'Add'}
      </button>
    </form>
  );
}

Query Invalidation Strategies

const queryClient = useQueryClient();

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

// Invalidate all queries starting with 'todos'
queryClient.invalidateQueries({ queryKey: ['todos'], exact: false });

// Invalidate a specific todo
queryClient.invalidateQueries({ queryKey: ['todos', todoId] });

// Remove query from cache entirely
queryClient.removeQueries({ queryKey: ['todos', todoId] });

// Manually set query data (e.g., after a mutation returns the updated item)
queryClient.setQueryData(['todos', newTodo.id], newTodo);

Optimistic Updates

export function useUpdateTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (updated: Todo): Promise<Todo> => {
      const res = await fetch(`/api/todos/${updated.id}`, {
        method: 'PUT',
        body: JSON.stringify(updated),
        headers: { 'Content-Type': 'application/json' },
      });
      return res.json();
    },
    onMutate: async (updatedTodo) => {
      // Cancel outgoing refetches so they don't overwrite our optimistic update
      await queryClient.cancelQueries({ queryKey: ['todos'] });

      // Snapshot previous value
      const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);

      // Optimistically update the cache
      queryClient.setQueryData<Todo[]>(['todos'], (old) =>
        old?.map((t) => (t.id === updatedTodo.id ? updatedTodo : t))
      );

      return { previousTodos };
    },
    onError: (_err, _todo, context) => {
      // Roll back on error
      if (context?.previousTodos) {
        queryClient.setQueryData(['todos'], context.previousTodos);
      }
    },
    onSettled: () => {
      // Refetch to ensure server state is in sync
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });
}

Infinite Queries (Pagination / Infinite Scroll)

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

interface PaginatedResponse<T> {
  data: T[];
  nextCursor: string | null;
}

export function useInfinitePosts() {
  return useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: async ({ pageParam }): Promise<PaginatedResponse<Post>> => {
      const res = await fetch(`/api/posts?cursor=${pageParam}`);
      return res.json();
    },
    initialPageParam: '',
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  });
}
function PostFeed() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfinitePosts();

  const allPosts = data?.pages.flatMap((page) => page.data) ?? [];

  return (
    <div>
      {allPosts.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? 'Loading more...' : 'Load more'}
        </button>
      )}
    </div>
  );
}

Prefetching

// Prefetch on hover for instant navigation
function TodoListItem({ id, title }: { id: string; title: string }) {
  const queryClient = useQueryClient();

  const prefetch = () => {
    queryClient.prefetchQuery({
      queryKey: ['todos', id],
      queryFn: () => fetchTodoById(id),
      staleTime: 30 * 1000,
    });
  };

  return (
    <Link to={`/todos/${id}`} onMouseEnter={prefetch}>
      {title}
    </Link>
  );
}

// Prefetch in a route loader (React Router)
export const todoLoader = (queryClient: QueryClient) =>
  async ({ params }: { params: { id: string } }) => {
    await queryClient.ensureQueryData({
      queryKey: ['todos', params.id],
      queryFn: () => fetchTodoById(params.id),
    });
    return null;
  };

SSR Hydration (Next.js App Router)

// app/todos/page.tsx (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';

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

  await queryClient.prefetchQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  });

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

Dependent Queries

function useUserProjects(userId: string | undefined) {
  // First query: fetch the user
  const userQuery = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId!),
    enabled: !!userId,
  });

  // Second query: depends on the first
  const projectsQuery = useQuery({
    queryKey: ['projects', userQuery.data?.teamId],
    queryFn: () => fetchProjects(userQuery.data!.teamId),
    enabled: !!userQuery.data?.teamId,
  });

  return { user: userQuery, projects: projectsQuery };
}

Best Practices

  1. Colocate query hooks — wrap useQuery in custom hooks (useTodos, useUser) to centralize query keys and fetch logic.
  2. Structure query keys hierarchically['todos', 'list', { status }] and ['todos', 'detail', id] allow precise invalidation.
  3. Set appropriate staleTime — default is 0 (always stale); set higher for data that changes infrequently to reduce refetches.
  4. Use enabled for conditional fetching — never call hooks conditionally; use the enabled option instead.
  5. Extract query key factories — define todoKeys.all, todoKeys.detail(id) as functions to avoid key typos.
  6. Prefetch on user intent — hover, focus, or route-level prefetching makes navigation feel instant.
  7. Separate server state from client state — use TanStack Query for API data and Zustand/Jotai for local-only UI state.

Anti-Patterns

  1. Duplicating server state locally — copying fetched data into useState leads to stale UI and sync bugs.
  2. Using queryKey strings instead of arrays — string keys don't support hierarchical invalidation.
  3. Forgetting onSettled invalidation after optimistic updates — the cache can drift from the server if you skip the final refetch.
  4. Setting staleTime: Infinity everywhere — data never refetches automatically; appropriate only for truly static data.
  5. Calling queryClient methods during render — side effects like invalidateQueries must happen in event handlers or mutation callbacks, not in the component body.
  6. Ignoring error boundaries — unhandled query errors crash the app; use throwOnError with React error boundaries or handle inline.

Install this skill directly: skilldb add state-management-skills

Get CLI access →