С появлением серверных компонентов вопрос «нужен ли TanStack Query в Next.js» стал звучать всерьёз: значительную часть того, ради чего его брали — загрузка данных, кэширование, ревалидация, — фреймворк теперь умеет сам.
Ответ зависит от того, какие у вас данные. Разберём библиотеку и границу, за которой она действительно нужна, а не дублирует возможности фреймворка.
Зачем TanStack Query
Next.js App Router отлично работает с серверными компонентами, но для клиентских взаимодействий нужно управление состоянием. TanStack Query решает:
- Кэширование — не дублируем запросы
- Ревалидация — автообновление при фокусе, интервале
- Мутации — оптимистичные обновления
- Дедупликация — один запрос на много компонентов
TanStack Query v5 — последняя версия с улучшенной типизацией и поддержкой Server Components через prefetch.
Установка и настройка
npm install @tanstack/react-query @tanstack/react-query-devtoolsПровайдер
// 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
// 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 — получение данных
// 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,
});
}Использование в компоненте
// 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 — изменение данных
// 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]);
// },
});
}Форма с мутацией
// 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>
);
}Оптимистичные обновления
// 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
// 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
// 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
// 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 с типами
// 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 prefetch | prefetchQuery + HydrationBoundary |
| Оптимистичные обновления | onMutate + onError rollback |
Где проходит граница с серверными компонентами
Серверные компоненты лучше для данных, которые загружаются один раз при открытии страницы и не меняются во время работы с ней: карточка товара, статья, страница раздела. Никакого JavaScript у клиента, никакой библиотеки в бандле, данные приезжают вместе с разметкой.
TanStack Query лучше там, где данные живут своей жизнью после загрузки страницы: списки с фильтрами, которые пользователь крутит; формы с оптимистичными обновлениями; данные, которые нужно перезапрашивать по событию или по возврату на вкладку; бесконечная прокрутка.
Признак, по которому проще всего решить: если между открытием страницы и уходом с неё данные должны обновиться хотя бы раз — это TanStack Query. Если нет — серверный компонент.
Смешивать их нормально и обычно правильно: страница рендерится на сервере, а интерактивный список внутри неё работает через библиотеку с предзагруженными данными.
Правила работы с библиотекой:
Фабрика ключей — обязательна. Ключи, разбросанные строками по компонентам, расходятся в первый же месяц: в одном месте ['orders', id], в другом ['order', id] — и инвалидация после мутации не срабатывает. Ошибка при этом молчаливая: данные просто не обновляются.
staleTime задавайте осознанно. Значение по умолчанию означает, что данные считаются устаревшими сразу, и запросы уходят чаще, чем нужно. Для справочников и редко меняющихся списков это минуты, а не нули.
Инвалидация после мутации важнее оптимистичного обновления. Оптимистичное обновление — это красиво, но именно инвалидация гарантирует, что на экране окажется то, что реально в базе. Начинайте с неё, оптимистичность добавляйте там, где задержка действительно мешает.
Не тащите библиотеку ради одного запроса. Если на всём приложении две загрузки данных, обычный fetch в серверном компоненте честнее — вы не платите за возможности, которыми не пользуетесь.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.