← Back to DevBytes

Building Modern Web Apps with TanStack Query: Complete Guide

What is TanStack Query?

TanStack Query (formerly known as React Query) is a powerful data-fetching and state management library for JavaScript applications. It simplifies the process of fetching, caching, synchronizing, and updating server state in your web apps. While it was originally built for React, TanStack Query now supports multiple frameworks including Vue, Solid, Svelte, and even plain JavaScript.

At its core, TanStack Query treats server state as a first-class citizen. It provides hooks and utilities to manage asynchronous data with minimal boilerplate, automatic caching, background refetching, pagination, optimistic updates, and much more.

Why TanStack Query Matters

πŸš€ Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Traditional approaches to data fetching in modern web apps often lead to scattered logic, manual caching, race conditions, and repetitive code. TanStack Query addresses these pain points by offering:

Getting Started: Installation and Setup

To begin using TanStack Query in a React project, install the core library and the React adapter:

npm install @tanstack/react-query

Next, wrap your application with the QueryClientProvider and create a QueryClient instance:

// App.jsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
    </QueryClientProvider>
  );
}

Basic Query Example

Fetching data with TanStack Query is done via the useQuery hook. Here's a simple example that fetches a list of posts from an API:

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

function fetchPosts() {
  return axios.get('/api/posts').then(res => res.data);
}

function PostsList() {
  const { data, isLoading, isError, error } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  });

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

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

Key points:

Query Configuration and Options

TanStack Query offers extensive configuration options via the third argument to useQuery or through the defaultOptions on the QueryClient. Common options include:

useQuery({
  queryKey: ['posts', { page }],
  queryFn: () => fetchPosts(page),
  staleTime: 1000 * 60 * 5, // 5 minutes
  cacheTime: 1000 * 60 * 30, // 30 minutes
  refetchInterval: false,
  enabled: !!page,
});

Mutations: Creating, Updating, and Deleting Data

For modifying server data, use the useMutation hook. It handles the mutation lifecycle (loading, error, success) and integrates with the cache for invalidation.

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

function addPost(newPost) {
  return axios.post('/api/posts', newPost).then(res => res.data);
}

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

  const mutation = useMutation({
    mutationFn: addPost,
    onSuccess: () => {
      // Invalidate and refetch the 'posts' query
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
  });

  const handleSubmit = (event) => {
    event.preventDefault();
    const formData = new FormData(event.target);
    mutation.mutate({
      title: formData.get('title'),
      body: formData.get('body'),
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" placeholder="Title" required />
      <textarea name="body" placeholder="Body" required />
      <button type="submit" disabled={mutation.isLoading}>
        {mutation.isLoading ? 'Adding...' : 'Add Post'}
      </button>
      {mutation.isError && <p>Error: {mutation.error.message}</p>}
      {mutation.isSuccess && <p>Post added!</p>}
    </form>
  );
}

Optimistic Updates

Optimistic updates allow you to update the UI before the server responds, making the app feel instant. Use onMutate to modify the cache, and onSettled to either rollback on error or invalidate on success.

const mutation = useMutation({
  mutationFn: updateTodo,
  onMutate: async (newTodo) => {
    // Cancel any outgoing refetches to avoid overwriting optimistic update
    await queryClient.cancelQueries({ queryKey: ['todos'] });

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

    // Optimistically update the cache
    queryClient.setQueryData(['todos'], (old) =>
      old.map(todo => (todo.id === newTodo.id ? { ...todo, ...newTodo } : todo))
    );

    return { previousTodos };
  },
  onError: (err, newTodo, context) => {
    // Rollback to the previous state
    queryClient.setQueryData(['todos'], context.previousTodos);
  },
  onSettled: () => {
    // Always refetch after error or success to ensure server state
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

Pagination and Infinite Queries

TanStack Query provides useInfiniteQuery for handling paginated data with "load more" or infinite scroll patterns.

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

function fetchProjects({ pageParam = 0 }) {
  return axios.get('/api/projects', { params: { offset: pageParam, limit: 10 } });
}

function ProjectsList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    status,
  } = useInfiniteQuery({
    queryKey: ['projects'],
    queryFn: fetchProjects,
    getNextPageParam: (lastPage, allPages) => {
      // Return the offset for the next page, or undefined if no more pages
      return lastPage.nextOffset ?? undefined;
    },
  });

  if (status === 'loading') return <div>Loading...</div>;
  if (status === 'error') return <div>Error</div>;

  return (
    <div>
      {data.pages.map((group, i) => (
        <div key={i}>
          {group.projects.map(project => (
            <p key={project.id}>{project.name}</p>
          ))}
        </div>
      ))}
      <div>
        <button
          onClick={() => fetchNextPage()}
          disabled={!hasNextPage || isFetchingNextPage}
        >
          {isFetchingNextPage
            ? 'Loading more...'
            : hasNextPage
            ? 'Load More'
            : 'Nothing more to load'}
        </button>
      </div>
    </div>
  );
}

Query Invalidation and Refetching

To force a refetch of specific queries after a mutation or user action, use queryClient.invalidateQueries or refetchQueries.

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

// Refetch a single query
queryClient.refetchQueries({ queryKey: ['user', userId] });

Devtools

TanStack Query ships with a powerful devtools panel. Install the devtools package:

npm install @tanstack/react-query-devtools

Then add it to your app:

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

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

Best Practices

Conclusion

TanStack Query revolutionizes the way developers handle server state in modern web applications. By abstracting away caching, synchronization, and loading states, it lets you focus on building features rather than boilerplate. Its robust API supports everything from simple data fetching to complex pagination and optimistic updates, making it an essential tool for any serious web developer. Start small, adopt its patterns gradually, and you'll soon wonder how you ever managed data without it.

πŸš€ Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started β€” $23.99/mo
← Back to all articles