Skip to main content

Essential Concepts

Understand the fundamental concepts that make TanStack Query powerful and easy to use.

Query Keys

Query keys are the foundation of TanStack Query’s caching system. They uniquely identify each query in your application.

Basic Keys

The simplest query key is an array with a single string:
useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
})

Keys with Variables

Include variables in your query key to create unique cache entries:
useQuery({
  queryKey: ['todo', todoId],
  queryFn: () => fetchTodo(todoId),
})
Query keys are hashed deterministically, so the order and structure matter. ['todos', 1] and ['todos', '1'] are different keys.

Complex Keys

Query keys can include objects for more complex scenarios:
useQuery({
  queryKey: ['todos', { status: 'done', page: 1 }],
  queryFn: () => fetchTodos({ status: 'done', page: 1 }),
})
Object keys are sorted automatically, so {a: 1, b: 2} and {b: 2, a: 1} produce the same cache key.

Query Functions

The query function is where you fetch your data. It must return a Promise that resolves to data or throws an error.

Basic Query Function

const { data } = useQuery({
  queryKey: ['repos'],
  queryFn: async () => {
    const response = await fetch('https://api.github.com/repos/TanStack/query')
    if (!response.ok) {
      throw new Error('Network response was not ok')
    }
    return response.json()
  },
})

Using Query Key in Function

The query function receives a context object with the query key:
const { data } = useQuery({
  queryKey: ['todo', todoId],
  queryFn: ({ queryKey }) => {
    const [_key, id] = queryKey
    return fetchTodo(id)
  },
})

Signal for Cancellation

Use the AbortSignal for request cancellation:
const { data } = useQuery({
  queryKey: ['todos'],
  queryFn: ({ signal }) => {
    return fetch('/api/todos', { signal })
  },
})
Always throw errors in your query function instead of returning them. TanStack Query uses thrown errors to determine failure states.

Stale Time vs Cache Time

Understanding these two concepts is crucial for effective caching:

Stale Time

staleTime determines how long data is considered fresh:
useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  staleTime: 1000 * 60 * 5, // 5 minutes
})
  • Fresh data: Won’t refetch automatically
  • Stale data: Will refetch in the background when conditions trigger it
  • Default: 0 (immediately stale)

Cache Time (gcTime)

gcTime (garbage collection time) determines how long unused data stays in cache:
useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  gcTime: 1000 * 60 * 60, // 1 hour
})
  • Data remains in cache after all observers unmount
  • After gcTime expires, data is garbage collected
  • Default: 5 minutes
staleTime affects when queries refetch. gcTime affects when cache entries are removed from memory.

Query Status

Queries can be in one of several states:
const { status, fetchStatus, data, error } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
})

Status

  • pending - No cached data and query is currently fetching
  • error - Query encountered an error
  • success - Query succeeded and data is available

Fetch Status

  • fetching - Query function is executing
  • paused - Query wants to fetch but is paused (offline)
  • idle - Query is not fetching
Use isPending for initial loading states and isFetching to show background update indicators.

Mutations

While queries fetch data, mutations modify data on the server:
import { useMutation, useQueryClient } from '@tanstack/react-query'

function TodoForm() {
  const queryClient = useQueryClient()

  const mutation = useMutation({
    mutationFn: (newTodo) => {
      return fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify(newTodo),
      })
    },
    onSuccess: () => {
      // Invalidate and refetch
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    },
  })

  return (
    <button
      onClick={() => {
        mutation.mutate({ title: 'New Todo' })
      }}
    >
      Add Todo
    </button>
  )
}

Mutation Status

const { mutate, isPending, isError, isSuccess, error } = useMutation({
  mutationFn: createTodo,
})

if (isPending) return <Spinner />
if (isError) return <ErrorMessage error={error} />
if (isSuccess) return <SuccessMessage />

Optimistic Updates

Update the UI immediately before the server responds:
const mutation = useMutation({
  mutationFn: updateTodo,
  onMutate: async (newTodo) => {
    // Cancel outgoing refetches
    await queryClient.cancelQueries({ queryKey: ['todos'] })

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

    // Optimistically update
    queryClient.setQueryData(['todos'], (old) => [...old, newTodo])

    // Return context with snapshot
    return { previousTodos }
  },
  onError: (err, newTodo, context) => {
    // Rollback on error
    queryClient.setQueryData(['todos'], context.previousTodos)
  },
  onSettled: () => {
    // Refetch after error or success
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})
Always implement error handling with optimistic updates to ensure data consistency if the mutation fails.

Dependent Queries

Some queries depend on data from other queries:
function User({ userId }) {
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  })

  const { data: projects } = useQuery({
    queryKey: ['projects', user?.id],
    queryFn: () => fetchProjects(user.id),
    enabled: !!user?.id, // Only run when user.id exists
  })
}
Use the enabled option to control when queries execute based on other data availability.

Query Invalidation

Invalidate queries to mark them as stale and trigger refetches:
const queryClient = useQueryClient()

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

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

// Invalidate all queries
queryClient.invalidateQueries()

Prefetching

Fetch data before it’s needed for better user experience:
const queryClient = useQueryClient()

function TodosList() {
  const { data: todos } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  })

  return (
    <ul>
      {todos.map((todo) => (
        <li
          key={todo.id}
          onMouseEnter={() => {
            // Prefetch todo details on hover
            queryClient.prefetchQuery({
              queryKey: ['todo', todo.id],
              queryFn: () => fetchTodo(todo.id),
            })
          }}
        >
          {todo.title}
        </li>
      ))}
    </ul>
  )
}
Prefetching is great for predictable user navigation patterns, like hovering over links or buttons.

Next Steps

TypeScript

Learn how to use TanStack Query with TypeScript

DevTools

Set up DevTools to visualize your queries

Build docs developers (and LLMs) love