Have you grown tired of handling data fetching natively in your React applications? There's just way too much setup required for something that has already been solved. Managing loading states, caching, and error handling can quickly become overwhelming.

Let me introduce you to TanStack Query (formerly known as React Query), a powerful library that simplifies data fetching, caching, and synchronization. It eliminates boilerplate code and helps you manage server state efficiently in React applications.

Note: React Query was renamed to TanStack Query starting with v5, as the library now supports frameworks beyond React (Vue, Svelte, Angular, Solid). The React-specific package is @tanstack/react-query. Throughout this tutorial, we'll use the terms interchangeably.

In this tutorial, we'll integrate TanStack Query in a Next.js project, learn how to create queries, use mutations, create custom hooks, and even explore folder structure and trade-offs compared to other methods.


Folder Structure

Before we dive into the setup, here's a typical folder structure for a Next.js project using TanStack Query. This helps you understand where to place files and maintain organization:

/project-root
├── /src
│   ├── /app
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   ├── /api
│   │   │   └── /todos
│   │   │       └── route.ts
│   │   └── /components
│   │       ├── Todos.tsx
│   │       └── AddTodo.tsx
│   ├── /hooks
│   │   └── useTodos.tsx
│   └── /components
│       └── query-provider.tsx
├── package.json
└── README.md

Key Folders:

  • src/app/: Contains Next.js pages, layout components, and API routes.
  • src/hooks/: Contains custom hooks like useTodos.tsx.
  • src/app/api/: API route handlers (Next.js App Router convention).
  • src/components/: Contains reusable React components, such as query-provider.tsx.

Note: We use a QueryProvider component because client-side code like QueryClientProvider cannot be used directly in server-rendered layout.tsx. The provider must be a client component.


Installation

Start by creating a Next.js project:

npx create-next-app@latest

Install TanStack Query and its optional ESLint plugin:

npm i @tanstack/react-query
npm i -D @tanstack/eslint-plugin-query

TanStack Query is compatible with React v18+ and works seamlessly with both React DOM and React Native.

Setting Up QueryClientProvider

To enable TanStack Query, wrap your application with the QueryProvider component. This setup ensures global access to the query client throughout your app.

It is important that we create it as a separate client component, because React does not allow client-side hooks in server components like layout.tsx.

query-provider.tsx:

// src/components/query-provider.tsx
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { useState } from "react";

export default function QueryProvider({ children }: { children: ReactNode }) {
  const [queryClient] = useState(() => new QueryClient({}));

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

Configure layout.tsx:

// src/app/layout.tsx
import QueryProvider from '@/components/query-provider';
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'My Next.js App',
  description: 'Generated by create next app',
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <QueryProvider>
          {children}
        </QueryProvider>
      </body>
    </html>
  );
}

Creating an API for Todos

To implement the GET and POST operations for our todos, we'll use the following API route in Next.js.

// src/app/api/todos/route.ts

import { NextResponse } from 'next/server';

let todos = [
  { id: 1, title: 'Learn React' },
  { id: 2, title: 'Build a Next.js App' },
  { id: 3, title: 'Understand TanStack Query' },
];

export async function GET() {
  return NextResponse.json(todos);
}

export async function POST(request: Request) {
  const newTodo = await request.json();

  if (!newTodo?.title) {
    return NextResponse.json({ error: "Title is required" }, { status: 400 });
  }

  // Generate a new id for the todo
  const newTodoWithId = { id: todos.length + 1, title: newTodo.title };

  todos.push(newTodoWithId);

  return NextResponse.json(newTodoWithId, { status: 201 });
}

Explanation:

  1. GET Handler: Returns the list of todos in JSON format.
  2. POST Handler: Validates the request body, creates a new todo with a generated ID, and responds with the newly created todo.

This API route serves as the backend for fetching and adding todos. It integrates seamlessly with the TanStack Query setup we'll build next.

Creating Queries

Queries in TanStack Query simplify data fetching by handling caching, background updates, and error states automatically.

Example: Fetching Todos

// src/components/Todos.tsx
'use client';

import useTodos from '@/hooks/useTodos';

export default function Todos() {
  const { data, error, isLoading } = useTodos();

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

  return (
    <ul>
      {data.map((todo: { id: number; title: string }) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

Using Mutations

Mutations are used to create, update, or delete server data.

Example: Adding a Todo

// src/components/AddTodo.tsx
'use client';

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

export default function AddTodo() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: async (newTodo: { title: string }) => {
      const res = await fetch('/api/todos', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newTodo),
      });
      if (!res.ok) throw new Error('Failed to add todo');
      return res.json();
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  return (
    <div>
      {mutation.isPending ? (
        'Adding todo...'
      ) : (
        <>
          {mutation.isError && <div>An error occurred: {mutation.error.message}</div>}
          {mutation.isSuccess && <div>Todo added!</div>}
          <button onClick={() => mutation.mutate({ title: 'Do Laundry' })}>
            Add Todo
          </button>
        </>
      )}
    </div>
  );
}

Key things to note:

  • mutationFn: The async function that performs the actual API call.
  • onSuccess: After a successful mutation, we call invalidateQueries to refetch the todos list, keeping the UI in sync with the server.
  • isPending: Replaces the old isLoading property from v4. In TanStack Query v5, isPending is the correct way to check if a mutation is in progress.

Optimistic Updates

For a snappier user experience, you can update the UI before the server responds, then roll back if something goes wrong. This is called an optimistic update.

const mutation = useMutation({
  mutationFn: async (newTodo: { title: string }) => {
    const res = await fetch('/api/todos', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(newTodo),
    });
    if (!res.ok) throw new Error('Failed to add todo');
    return res.json();
  },
  onMutate: async (newTodo) => {
    // Cancel outgoing refetches so they don't overwrite our optimistic update
    await queryClient.cancelQueries({ queryKey: ['todos'] });

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

    // Optimistically update the cache
    queryClient.setQueryData(['todos'], (old: any[]) => [
      ...old,
      { id: Date.now(), title: newTodo.title },
    ]);

    // Return the snapshot so we can roll back on error
    return { previousTodos };
  },
  onError: (_err, _newTodo, context) => {
    // Roll back to the previous value on error
    queryClient.setQueryData(['todos'], context?.previousTodos);
  },
  onSettled: () => {
    // Refetch after error or success to make sure we're in sync
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

Optimistic updates make your app feel instant, which is especially important for actions like adding items, toggling states, or deleting records.

Creating Custom Hooks

Custom hooks make your TanStack Query code cleaner and easier to reuse. Use them whenever possible to keep your components concise and your logic organized.

Hook for Fetching Todos

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

export default function useTodos() {
  return useQuery({
    queryKey: ['todos'],
    queryFn: async () => {
      const res = await fetch('/api/todos');
      if (!res.ok) throw new Error('Failed to fetch');
      return res.json();
    },
  });
}

By extracting the query into a custom hook, any component that needs the todos list can simply call useTodos() without duplicating the fetch logic.

Trade-offs and Comparisons

Why TanStack Query?

  • Advantages:
    • Automatic caching and background refetching.
    • Handles loading, error, and success states out of the box.
    • Built-in support for optimistic updates, pagination, and infinite queries.
    • DevTools for debugging (install @tanstack/react-query-devtools).
    • Easy to scale for larger applications.

Alternatives: SWR, Axios, or Native Fetch

  • SWR (by Vercel):

    • Pros: Lightweight, great for simple use cases, built by the Next.js team.
    • Cons: Less feature-rich than TanStack Query for complex scenarios (mutations, optimistic updates).
  • Native Fetch:

    • Pros: Lightweight and built-in, no extra dependencies.
    • Cons: Requires manual state management (loading, error handling, caching, etc.).
  • Axios:

    • Pros: Simplifies HTTP requests and offers interceptors.
    • Cons: Still requires manual state management.

TanStack Query abstracts away these complexities, saving you time and effort. For most Next.js applications that need client-side data fetching, it's the best choice.


Conclusion

TanStack Query is a game-changer for managing server data in React applications. It simplifies data fetching, caching, and state management, making your code cleaner and more efficient.

In this tutorial, you learned:

  1. How to set up TanStack Query in a Next.js project.
  2. How to fetch data with queries.
  3. How to modify server data with mutations.
  4. How to use optimistic updates for a snappy UI.
  5. How to create reusable custom hooks.
  6. Trade-offs between TanStack Query and other approaches.

Start integrating TanStack Query today, and enjoy effortless data management in your Next.js applications!


GitHub Repository

The full source code for this tutorial is available on GitHub. You can access it here: Github Repo