TanStack Query в Next.js — кэширование и мутации

TanStack Query (React Query) решает проблемы управления серверным состоянием: кэширование, ревалидация, оптимистичные обновления. Интеграция с App Router.

Дмитрий Мещеряков
Дмитрий Мещеряков
📅 3 августа 2026 г.📖 8 мин чтения

С появлением серверных компонентов вопрос «нужен ли TanStack Query в Next.js» стал звучать всерьёз: значительную часть того, ради чего его брали — загрузка данных, кэширование, ревалидация, — фреймворк теперь умеет сам.

Ответ зависит от того, какие у вас данные. Разберём библиотеку и границу, за которой она действительно нужна, а не дублирует возможности фреймворка.

Зачем TanStack Query

Next.js App Router отлично работает с серверными компонентами, но для клиентских взаимодействий нужно управление состоянием. TanStack Query решает:

  • Кэширование — не дублируем запросы
  • Ревалидация — автообновление при фокусе, интервале
  • Мутации — оптимистичные обновления
  • Дедупликация — один запрос на много компонентов
💡 Совет

TanStack Query v5 — последняя версия с улучшенной типизацией и поддержкой Server Components через prefetch.

Установка и настройка

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

Провайдер

typescript
// src/providers/query-provider.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState, type ReactNode } from 'react';
export function QueryProvider({ children }: { children: ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            // Данные считаются свежими 1 минуту
            staleTime: 60 * 1000,
            // Кэш хранится 5 минут после unmount
            gcTime: 5 * 60 * 1000,
            // Повторные попытки при ошибке
            retry: 1,
            // Ревалидация при фокусе
            refetchOnWindowFocus: true,
          },
        },
      })
  );
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

Подключение в layout

typescript
// src/app/layout.tsx
import { QueryProvider } from '@/providers/query-provider';
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ru">
      <body>
        <QueryProvider>{children}</QueryProvider>
      </body>
    </html>
  );
}

Базовые запросы

useQuery — получение данных

typescript
// src/hooks/use-posts.ts
'use client';
import { useQuery } from '@tanstack/react-query';
type Post = {
  id: number;
  title: string;
  body: string;
  userId: number;
};
async function fetchPosts(): Promise<Post[]> {
  const response = await fetch('/api/posts');
  if (!response.ok) {
    throw new Error('Failed to fetch posts');
  }
  return response.json();
}
export function usePosts() {
  return useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  });
}
// С параметрами
export function usePost(id: number) {
  return useQuery({
    queryKey: ['posts', id],
    queryFn: async () => {
      const response = await fetch(`/api/posts/${id}`);
      if (!response.ok) throw new Error('Post not found');
      return response.json() as Promise<Post>;
    },
    // Не запрашивать, если нет id
    enabled: !!id,
  });
}

Использование в компоненте

typescript
// src/components/PostList.tsx
'use client';
import { usePosts } from '@/hooks/use-posts';
export function PostList() {
  const { data: posts, isLoading, error, refetch } = usePosts();
  if (isLoading) {
    return <div className="skeleton">Загрузка...</div>;
  }
  if (error) {
    return (
      <div className="error">
        <p>Ошибка: {error.message}</p>
        <button onClick={() => refetch()}>Повторить</button>
      </div>
    );
  }
  return (
    <ul className="post-list">
      {posts?.map((post) => (
        <li key={post.id}>
          <h3>{post.title}</h3>
          <p>{post.body}</p>
        </li>
      ))}
    </ul>
  );
}

Мутации

useMutation — изменение данных

typescript
// src/hooks/use-create-post.ts
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
type CreatePostData = {
  title: string;
  body: string;
};
type Post = {
  id: number;
  title: string;
  body: string;
};
async function createPost(data: CreatePostData): Promise<Post> {
  const response = await fetch('/api/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });
  if (!response.ok) {
    throw new Error('Failed to create post');
  }
  return response.json();
}
export function useCreatePost() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: createPost,
    // После успеха — инвалидируем кэш постов
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
    // Или добавляем в кэш напрямую
    // onSuccess: (newPost) => {
    //   queryClient.setQueryData(['posts'], (old: Post[]) => [...old, newPost]);
    // },
  });
}

Форма с мутацией

typescript
// src/components/CreatePostForm.tsx
'use client';
import { useState } from 'react';
import { useCreatePost } from '@/hooks/use-create-post';
export function CreatePostForm() {
  const [title, setTitle] = useState('');
  const [body, setBody] = useState('');
  const { mutate, isPending, error } = useCreatePost();
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    mutate(
      { title, body },
      {
        onSuccess: () => {
          setTitle('');
          setBody('');
        },
      }
    );
  };
  return (
    <form onSubmit={handleSubmit}>
      <input
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Заголовок"
        disabled={isPending}
      />
      <textarea
        value={body}
        onChange={(e) => setBody(e.target.value)}
        placeholder="Текст"
        disabled={isPending}
      />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Создание...' : 'Создать'}
      </button>
      {error && <p className="error">{error.message}</p>}
    </form>
  );
}

Оптимистичные обновления

typescript
// src/hooks/use-toggle-like.ts
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
type Post = {
  id: number;
  title: string;
  likes: number;
  liked: boolean;
};
export function useToggleLike(postId: number) {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: async () => {
      const response = await fetch(`/api/posts/${postId}/like`, {
        method: 'POST',
      });
      return response.json();
    },
    // Оптимистичное обновление
    onMutate: async () => {
      // Отменяем текущие запросы
      await queryClient.cancelQueries({ queryKey: ['posts', postId] });
      // Сохраняем текущее состояние
      const previousPost = queryClient.getQueryData<Post>(['posts', postId]);
      // Оптимистично обновляем
      queryClient.setQueryData<Post>(['posts', postId], (old) => {
        if (!old) return old;
        return {
          ...old,
          liked: !old.liked,
          likes: old.liked ? old.likes - 1 : old.likes + 1,
        };
      });
      // Возвращаем для отката
      return { previousPost };
    },
    // При ошибке — откатываем
    onError: (err, variables, context) => {
      if (context?.previousPost) {
        queryClient.setQueryData(['posts', postId], context.previousPost);
      }
    },
    // После завершения (успех или ошибка) — синхронизируем
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['posts', postId] });
    },
  });
}

Prefetch для SSR

Server Component + Client Component

typescript
// src/app/posts/page.tsx (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { PostList } from '@/components/PostList';
async function getPosts() {
  const response = await fetch('https://api.example.com/posts', {
    next: { revalidate: 60 },
  });
  return response.json();
}
export default async function PostsPage() {
  const queryClient = new QueryClient();
  // Prefetch на сервере
  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  });
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <h1>Посты</h1>
      <PostList />
    </HydrationBoundary>
  );
}

Пагинация

Infinite Query

typescript
// src/hooks/use-infinite-posts.ts
'use client';
import { useInfiniteQuery } from '@tanstack/react-query';
type PostsResponse = {
  posts: Post[];
  nextCursor: number | null;
};
export function useInfinitePosts() {
  return useInfiniteQuery({
    queryKey: ['posts', 'infinite'],
    queryFn: async ({ pageParam }): Promise<PostsResponse> => {
      const response = await fetch(`/api/posts?cursor=${pageParam}&limit=10`);
      return response.json();
    },
    initialPageParam: 0,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  });
}
// Компонент
function InfinitePostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfinitePosts();
  return (
    <>
      {data?.pages.map((page) =>
        page.posts.map((post) => <PostCard key={post.id} post={post} />)
      )}
      {hasNextPage && (
        <button
          onClick={() => fetchNextPage()}
          disabled={isFetchingNextPage}
        >
          {isFetchingNextPage ? 'Загрузка...' : 'Загрузить ещё'}
        </button>
      )}
    </>
  );
}

Полезные паттерны

Фабрика query keys

typescript
// src/lib/query-keys.ts
export const queryKeys = {
  posts: {
    all: ['posts'] as const,
    list: (filters: { page?: number; search?: string }) =>
      ['posts', 'list', filters] as const,
    detail: (id: number) => ['posts', 'detail', id] as const,
  },
  users: {
    all: ['users'] as const,
    detail: (id: number) => ['users', id] as const,
    posts: (userId: number) => ['users', userId, 'posts'] as const,
  },
};
// Использование
useQuery({
  queryKey: queryKeys.posts.detail(postId),
  queryFn: () => fetchPost(postId),
});
// Инвалидация всех постов
queryClient.invalidateQueries({ queryKey: queryKeys.posts.all });

Custom hook с типами

typescript
// src/hooks/use-api.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
type ApiHookOptions<T> = {
  queryKey: unknown[];
  queryFn: () => Promise<T>;
  staleTime?: number;
};
export function useApiQuery<T>({ queryKey, queryFn, staleTime }: ApiHookOptions<T>) {
  return useQuery({
    queryKey,
    queryFn,
    staleTime: staleTime ?? 60000,
  });
}
⚠️ Важно

Не дублируйте логику! Server Components могут получать данные напрямую. Используйте TanStack Query только для клиентских взаимодействий: формы, real-time обновления, оптимистичные UI.

Итоги

ЗадачаРешение
Получение данныхuseQuery
Изменение данныхuseMutation
Бесконечный скроллuseInfiniteQuery
SSR prefetchprefetchQuery + HydrationBoundary
Оптимистичные обновленияonMutate + onError rollback

Где проходит граница с серверными компонентами

Серверные компоненты лучше для данных, которые загружаются один раз при открытии страницы и не меняются во время работы с ней: карточка товара, статья, страница раздела. Никакого JavaScript у клиента, никакой библиотеки в бандле, данные приезжают вместе с разметкой.

TanStack Query лучше там, где данные живут своей жизнью после загрузки страницы: списки с фильтрами, которые пользователь крутит; формы с оптимистичными обновлениями; данные, которые нужно перезапрашивать по событию или по возврату на вкладку; бесконечная прокрутка.

Признак, по которому проще всего решить: если между открытием страницы и уходом с неё данные должны обновиться хотя бы раз — это TanStack Query. Если нет — серверный компонент.

Смешивать их нормально и обычно правильно: страница рендерится на сервере, а интерактивный список внутри неё работает через библиотеку с предзагруженными данными.

Правила работы с библиотекой:

Фабрика ключей — обязательна. Ключи, разбросанные строками по компонентам, расходятся в первый же месяц: в одном месте ['orders', id], в другом ['order', id] — и инвалидация после мутации не срабатывает. Ошибка при этом молчаливая: данные просто не обновляются.

staleTime задавайте осознанно. Значение по умолчанию означает, что данные считаются устаревшими сразу, и запросы уходят чаще, чем нужно. Для справочников и редко меняющихся списков это минуты, а не нули.

Инвалидация после мутации важнее оптимистичного обновления. Оптимистичное обновление — это красиво, но именно инвалидация гарантирует, что на экране окажется то, что реально в базе. Начинайте с неё, оптимистичность добавляйте там, где задержка действительно мешает.

Не тащите библиотеку ради одного запроса. Если на всём приложении две загрузки данных, обычный fetch в серверном компоненте честнее — вы не платите за возможности, которыми не пользуетесь.

Частые вопросы

Нужен ли TanStack Query, если есть серверные компоненты?

Зависит от данных. Серверные компоненты лучше для того, что загружается один раз при открытии страницы и не меняется во время работы с ней: карточка товара, статья, страница раздела — никакого JavaScript у клиента и никакой библиотеки в бандле. TanStack Query нужен там, где данные живут своей жизнью после загрузки: списки с фильтрами, формы с оптимистичными обновлениями, перезапрос по событию или по возврату на вкладку, бесконечная прокрутка.

Как решить, что использовать в конкретном случае?

По одному признаку: если между открытием страницы и уходом с неё данные должны обновиться хотя бы раз — это TanStack Query. Если нет — серверный компонент. Смешивать их нормально и обычно правильно: страница рендерится на сервере, а интерактивный список внутри неё работает через библиотеку с предзагруженными данными. И не тащите библиотеку ради одного запроса: если на всём приложении две загрузки данных, обычный fetch в серверном компоненте честнее.

Зачем нужна фабрика query keys?

Чтобы ключи не расходились. Строки, разбросанные по компонентам, расходятся в первый же месяц: в одном месте ключ с orders, в другом с order — и инвалидация после мутации не срабатывает. Ошибка при этом молчаливая: данные просто не обновляются, ничего не падает. Фабрика собирает все ключи в одном модуле и делает их типизированными.

Что такое staleTime и какое значение выбрать?

Это время, в течение которого данные считаются свежими и повторный запрос не уходит. Значение по умолчанию означает, что данные устаревают сразу, поэтому запросы идут чаще, чем нужно. Для справочников и редко меняющихся списков ставьте минуты, а не нули. Отдельно есть gcTime — сколько кэш живёт после того, как компонент размонтирован.

Как сделать предзагрузку данных на сервере?

Через prefetchQuery в серверном компоненте и HydrationBoundary с результатом dehydrate. Сервер наполняет клиентский QueryClient до рендера, клиентский компонент получает данные сразу и не показывает состояние загрузки. Ключ в prefetchQuery должен совпадать с ключом в useQuery, иначе гидратация не сработает и запрос уйдёт заново.

Оптимистичные обновления или инвалидация после мутации?

Начинайте с инвалидации: именно она гарантирует, что на экране окажется то, что реально в базе. Оптимистичное обновление красиво, но требует ручного отката в onError через сохранённое в onMutate предыдущее состояние и всё равно завершается инвалидацией в onSettled. Добавляйте оптимистичность там, где задержка действительно мешает, а не по умолчанию.