โ† Back to DevBytes

TanStack Query from Beginner to Expert: A Learning Path

Introduction to TanStack Query

TanStack Query (formerly React Query) is a powerful data-fetching and state management library for web applications. It abstracts away the complex machinery of fetching, caching, synchronizing, and updating asynchronous data, letting you focus on building features rather than reinventing the wheel with useEffect and useState.

Whether you are building a small dashboard or a large enterprise application, TanStack Query scales gracefully with your needs. In this tutorial, we will walk through a structured learning path โ€” from absolute beginner concepts to expert-level patterns โ€” so you can master the library end to end.

Why TanStack Query Matters

Before diving into code, it is important to understand the problems TanStack Query solves. Traditional data fetching in React often looks like this:

function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let isMounted = true;
    fetch('/api/users')
      .then(res => res.json())
      .then(data => {
        if (isMounted) {
          setUsers(data);
          setLoading(false);
        }
      })
      .catch(err => {
        if (isMounted) {
          setError(err);
          setLoading(false);
        }
      });
    return () => { isMounted = false; };
  }, []);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error!</p>;
  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

This pattern has several hidden costs:

TanStack Query solves all of these out of the box. It treats server state as a first-class citizen, distinct from client state, and provides a declarative API to manage it.

Getting Started: Installation and Setup

TanStack Query is framework-agnostic, but in this tutorial we will use React. Install the required packages:

npm install @tanstack/react-query @tanstack/react-query-devtools

Next, set up the QueryClient and wrap your application with the QueryClientProvider:

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

export default function App() {
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000,
        refetchOnWindowFocus: false,
      },
    },
  }));

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

Using useState to create the client ensures it is stable across re-renders. The devtools panel is optional but highly recommended during development.

Core Concepts: Queries and Mutations

Fetching Data with useQuery

The useQuery hook is the primary tool for reading data. It accepts a unique query key and a query function:

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

async function fetchUsers() {
  const res = await fetch('/api/users');
  if (!res.ok) throw new Error('Failed to fetch users');
  return res.json();
}

function UserList() {
  const { data, isLoading, error, isError } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

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

  return (
    <ul>
      {data.map(user => (
        <li key={user.id}>{user.name} โ€” {user.email}</li>
      ))}
    </ul>
  );
}

The queryKey is an array that uniquely identifies the query. It acts as both a cache key and a dependency array โ€” when the values inside change, TanStack Query refetches automatically.

Dynamic Query Keys

Query keys can include variables, which makes them perfect for parameterized queries:

function UserProfile({ userId }) {
  const { data } = useQuery({
    queryKey: ['users', userId],
    queryFn: () => fetchUserById(userId),
  });

  return <div>{data?.name}</div>;
}

When userId changes, the query automatically refetches with the new value. The cache stores each unique key separately, so navigating back to a previous user is instant.

Mutating Data with useMutation

While useQuery handles reads, useMutation handles writes โ€” creating, updating, or deleting data:

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

async function createUser(newUser) {
  const res = await fetch('/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(newUser),
  });
  if (!res.ok) throw new Error('Failed to create user');
  return res.json();
}

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

  const mutation = useMutation({
    mutationFn: createUser,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    const formData = new FormData(e.target);
    mutation.mutate({
      name: formData.get('name'),
      email: formData.get('email'),
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" required />
      <input name="email" type="email" required />
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Saving...' : 'Add User'}
      </button>
      {mutation.isError && <p>Error: {mutation.error.message}</p>}
    </form>
  );
}

The key pattern here is invalidateQueries. After a successful mutation, we tell TanStack Query that the ['users'] cache is stale, triggering a background refetch to keep the UI in sync.

Intermediate Patterns

Stale Time vs Cache Time

Understanding the difference between staleTime and gcTime (formerly cacheTime) is essential:

const { data } = useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
  staleTime: 5 * 60 * 1000, // fresh for 5 minutes
  gcTime: 30 * 60 * 1000,   // kept in cache for 30 minutes after unmount
});

Pagination

Pagination is straightforward with query keys that include the page number:

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

  const { data, isLoading, isFetching } = useQuery({
    queryKey: ['posts', page],
    queryFn: () => fetchPosts(page),
    placeholderData: keepPreviousData,
  });

  return (
    <div>
      {isLoading ? (
        <p>Loading...</p>
      ) : (
        <ul>
          {data.items.map(post => (
            <li key={post.id}>{post.title}</li>
          ))}
        </ul>
      )}
      <button
        onClick={() => setPage(old => Math.max(old - 1, 1))}
        disabled={page === 1}
      >
        Previous
      </button>
      <span>Page {page}</span>
      <button
        onClick={() => setPage(old => old + 1)}
        disabled={!data.hasNextPage}
      >
        Next
      </button>
      {isFetching && <span>Updating...</span>}
    </div>
  );
}

The keepPreviousData import from @tanstack/react-query ensures the UI keeps showing the previous page while the new one loads, preventing flicker.

Infinite Scrolling

For infinite scroll, use the useInfiniteQuery hook:

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

function InfiniteFeed() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['feed'],
    queryFn: ({ pageParam = 1 }) => fetchFeed(pageParam),
    getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
    initialPageParam: 1,
  });

  return (
    <div>
      {data.pages.map((page, i) => (
        <div key={i}>
          {page.items.map(item => (
            <div key={item.id}>{item.title}</div>
          ))}
        </div>
      ))}
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? 'Loading more...' : 'Load More'}
      </button>
    </div>
  );
}

The getNextPageParam function determines the next page parameter based on the last fetched page. Returning undefined signals that there are no more pages.

Prefetching

Prefetching loads data before it is needed, creating an instant experience for the user. Use the queryClient.prefetchQuery method:

function UserTable({ users, onSelect }) {
  const queryClient = useQueryClient();

  const handleHover = (userId) => {
    queryClient.prefetchQuery({
      queryKey: ['users', userId],
      queryFn: () => fetchUserById(userId),
      staleTime: 60 * 1000,
    });
  };

  return (
    <table>
      <tbody>
        {users.map(user => (
          <tr
            key={user.id}
            onClick={() => onSelect(user.id)}
            onMouseEnter={() => handleHover(user.id)}
          >
            <td>{user.name}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

When the user hovers over a row, the detail data is fetched in the background. By the time they click, the data is already cached and the detail view renders instantly.

Advanced Techniques

Optimistic Updates

Optimistic updates immediately reflect the expected result in the UI, then roll back if the mutation fails. This creates a snappy user experience:

function TodoList() {
  const queryClient = useQueryClient();
  const queryKey = ['todos'];

  const updateMutation = useMutation({
    mutationFn: updateTodo,
    onMutate: async (newTodo) => {
      await queryClient.cancelQueries({ queryKey });
      const previousTodos = queryClient.getQueryData(queryKey);

      queryClient.setQueryData(queryKey, (old) =>
        old.map(todo => todo.id === newTodo.id ? { ...todo, ...newTodo } : todo)
      );

      return { previousTodos };
    },
    onError: (err, newTodo, context) => {
      queryClient.setQueryData(queryKey, context.previousTodos);
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey });
    },
  });

  const handleToggle = (todo) => {
    updateMutation.mutate({ ...todo, completed: !todo.completed });
  };

  // render todos...
}

The onMutate callback runs before the network request and updates the cache optimistically. If the request fails, onError restores the previous state. onSettled always runs to ensure final consistency with the server.

Dependent Queries

Sometimes a query depends on the result of another. Use the enabled option to control when a query runs:

function ProjectDetails({ projectId }) {
  const { data: project } = useQuery({
    queryKey: ['projects', projectId],
    queryFn: () => fetchProject(projectId),
  });

  const { data: tasks } = useQuery({
    queryKey: ['projects', projectId, 'tasks'],
    queryFn: () => fetchTasks(project.id),
    enabled: !!project?.id,
  });

  return (
    <div>
      <h1>{project?.name}</h1>
      <ul>{tasks?.map(task => <li key={task.id}>{task.title}</li>)}</ul>
    </div>
  );
}

The tasks query only executes once the project data is available, preventing unnecessary or failed requests.

Parallel and Batch Queries

For independent queries, simply call useQuery multiple times โ€” they run in parallel automatically:

function Dashboard() {
  const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
  const postsQuery = useQuery({ queryKey: ['posts'], queryFn: fetchPosts });
  const statsQuery = useQuery({ queryKey: ['stats'], queryFn: fetchStats });

  if (usersQuery.isLoading || postsQuery.isLoading || statsQuery.isLoading) {
    return <p>Loading dashboard...</p>;
  }

  return (
    <div>
      <section>Users: {usersQuery.data.length}</section>
      <section>Posts: {postsQuery.data.length}</section>
      <section>Revenue: {statsQuery.data.revenue}</section>
    </div>
  );
}

For a dynamic number of parallel queries, use useQueries:

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

function MultiUserProfiles({ userIds }) {
  const queries = useQueries({
    queries: userIds.map(id => ({
      queryKey: ['users', id],
      queryFn: () => fetchUserById(id),
    })),
  });

  const isLoading = queries.some(q => q.isLoading);

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

  return (
    <ul>
      {queries.map((q, i) => (
        <li key={userIds[i]}>{q.data?.name}</li>
      ))}
    </ul>
  );
}

Custom Query Hooks

As your application grows, encapsulating query logic in custom hooks improves reusability and testability:

function useUser(userId, options = {}) {
  return useQuery({
    queryKey: ['users', userId],
    queryFn: () => fetchUserById(userId),
    enabled: !!userId,
    ...options,
  });
}

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

  return useMutation({
    mutationFn: updateUser,
    onSuccess: (data, variables) => {
      queryClient.setQueryData(['users', variables.id], data);
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
}

// Usage
function Profile({ userId }) {
  const { data } = useUser(userId);
  const updateUser = useUpdateUser();
  // ...
}

Query Cancellation and Race Conditions

TanStack Query supports AbortSignal for request cancellation, which prevents race conditions when query keys change rapidly:

async function fetchSearchResults({ queryKey, signal }) {
  const [, searchTerm] = queryKey;
  const res = await fetch(`/api/search?q=${searchTerm}`, { signal });
  if (!res.ok) throw new Error('Search failed');
  return res.json();
}

function SearchBox() {
  const [term, setTerm] = useState('');

  const { data } = useQuery({
    queryKey: ['search', term],
    queryFn: fetchSearchResults,
    enabled: term.length > 2,
  });

  return (
    <div>
      <input
        value={term}
        onChange={e => setTerm(e.target.value)}
        placeholder="Search..."
      />
      <ul>{data?.map(r => <li key={r.id}>{r.title}</li>)}</ul>
    </div>
  );
}

When the user types quickly, TanStack Query automatically cancels the previous in-flight request before starting the new one, ensuring the results always match the latest input.

Best Practices

Structure Query Keys Hierarchically

Treat query keys like a nested namespace. This makes invalidation powerful and predictable:

// Good: hierarchical keys
['users']
['users', 'list', { page: 1, filter: 'active' }]
['users', 'detail', 42]
['users', 'detail', 42, 'posts']

// Invalidation by prefix
queryClient.invalidateQueries({ queryKey: ['users'] });
// This invalidates ALL user-related queries

Separate Server State from Client State

Do not duplicate server data in Redux, Zustand, or useState. Let TanStack Query be the single source of truth for server state. Use local state only for UI-specific concerns like form inputs, modals, and toggles.

Handle Errors Globally and Locally

Configure a global error handler in the QueryClient for logging and toast notifications, while still allowing per-query overrides:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 2,
      refetchOnWindowFocus: true,
    },
    mutations: {
      onError: (error) => {
        toast.error(error.message);
      },
    },
  },
});

Use Selectors to Transform Data

The select option lets you derive view-specific data without duplicating cache entries:

const { data: activeUsers } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  select: (users) => users.filter(u => u.isActive),
});

Avoid Over-Fetching with Smart Invalidation

Instead of invalidating broad query keys after every mutation, consider using setQueryData to directly update the cache when you know the new state. This avoids unnecessary network requests:

const mutation = useMutation({
  mutationFn: updateUser,
  onSuccess: (updatedUser, variables) => {
    queryClient.setQueryData(['users', variables.id], updatedUser);
  },
});

Type Safety with TypeScript

Define types for your query functions and use them consistently:

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

async function fetchUserById(id: number): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error('Failed');
  return res.json();
}

const { data } = useQuery<User, Error>({
  queryKey: ['users', userId],
  queryFn: () => fetchUserById(userId),
});
// data is typed as User | undefined

Conclusion

TanStack Query transforms the way you handle server state in modern web applications. Starting from simple useQuery and useMutation calls, you can progressively adopt more advanced patterns like optimistic updates, prefetching, infinite scrolling, and custom hooks as your needs grow. By treating server state as a distinct concern with its own caching, synchronization, and invalidation strategies, you eliminate entire categories of bugs while delivering a faster, more responsive user experience. The key to mastery is understanding the lifecycle of a query โ€” from fresh to stale to inactive to garbage collected โ€” and leveraging query keys as the backbone of your cache architecture. With these foundations and best practices in place, TanStack Query becomes not just a data-fetching library, but a complete server-state management system that scales from a single component to the largest applications.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles