Главная/Статьи/TanStack Query в Next.js — кэширование и мутации

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 в серверном компоненте честнее — вы не платите за возможности, которыми не пользуетесь.

🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.