TanStack Query (ранее React Query) - библиотека для управления серверным состоянием в React-приложениях. Она берет на себя кэширование, синхронизацию, фоновое обновление и дедупликацию запросов, позволяя полностью отказаться от ручного управления состоянием загрузки через useEffect + useState.
Ключевые преимущества:
- Автоматическое кэширование и инвалидация данных
- Дедупликация одинаковых запросов
- Фоновое обновление устаревших данных
- Встроенная поддержка пагинации и бесконечной подгрузки
- Оптимистичные обновления
- Offline-режим
- Встроенные DevTools для отладки
Установка
npm i @tanstack/react-query @tanstack/react-query-devtoolsSetup
Для работы библиотеки необходимо создать экземпляр QueryClient и обернуть приложение в QueryClientProvider.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import App from './App';
const queryClient = new QueryClient();
function Root() {
return (
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}Сервисный слой
Перед работой с хуками стоит организовать слой запросов. Типичный сервис на axios:
import axios from 'axios';
const api = axios.create({
baseURL: 'https://api.example.com',
});
export interface Post {
id: number;
title: string;
body: string;
userId: number;
}
export interface CreatePostDto {
title: string;
body: string;
userId: number;
}
export const postService = {
async getAll() {
const { data } = await api.get<Post[]>('/posts');
return data;
},
async getById(id: number) {
const { data } = await api.get<Post>(`/posts/${id}`);
return data;
},
async create(dto: CreatePostDto) {
const { data } = await api.post<Post>('/posts', dto);
return data;
},
async update(id: number, dto: Partial<CreatePostDto>) {
const { data } = await api.patch<Post>(`/posts/${id}`, dto);
return data;
},
async delete(id: number) {
await api.delete(`/posts/${id}`);
},
};useQuery
Основной хук для получения данных. В v5 принимает единственный объект с параметрами.
import { useQuery } from '@tanstack/react-query';
import { postService } from './services/post.service';
function PostList() {
const {
data: posts,
isPending,
isError,
error,
} = useQuery({
queryKey: ['posts'],
queryFn: postService.getAll,
});
if (isPending) return <div>Loading...</div>;
if (isError) return <div>Error: {error.message}</div>;
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}Параметры useQuery
Основные опции, которые принимает хук:
const query = useQuery({
// уникальный ключ запроса - массив, описывающий зависимости
queryKey: ['posts', { status: 'active', page: 1 }],
// функция получения данных, должна вернуть промис
queryFn: ({ queryKey }) => {
const [, params] = queryKey;
return postService.getAll(params);
},
// время в мс, в течение которого данные считаются свежими
staleTime: 5 * 60 * 1000,
// время хранения неактивных данных в кэше (в v5 переименован из cacheTime)
gcTime: 10 * 60 * 1000,
// автоматический рефетч через заданный интервал
refetchInterval: 30_000,
// условное выполнение запроса
enabled: !!userId,
// трансформация данных перед возвратом
select: (data) => data.filter((post) => post.userId === 1),
// данные-заглушка до завершения первого запроса (в v5 вместо keepPreviousData)
placeholderData: (previousData) => previousData,
});Статусы запроса
В v5 статусы разделены на две оси - status (состояние данных) и fetchStatus (состояние сетевого запроса).
Состояние данных:
isPending- данных ещё нет, запрос выполняется впервые. В v5 заменяет устаревшийisLoadingisError- запрос завершился ошибкойisSuccess- данные получены успешно
Состояние сетевого запроса:
isFetching- сетевой запрос выполняется прямо сейчас. Может бытьtrueодновременно сisSuccess, когда происходит фоновое обновлениеfetchStatus-'fetching'|'paused'|'idle'
Important
В v5
isLoadingудалён. ИспользуйтеisPendingдля проверки отсутствия данных иisFetchingдля проверки активного сетевого запроса.
useMutation
Хук для операций изменения данных - создание, обновление, удаление. В v5 строковый ключ мутации убран, используется только объект параметров.
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { postService, CreatePostDto } from './services/post.service';
function CreatePostForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (dto: CreatePostDto) => postService.create(dto),
onSuccess: (newPost) => {
// инвалидация кэша - запросы с этим ключом будут перезапрошены
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
onError: (error) => {
console.error('Failed to create post:', error);
},
onSettled: () => {
// выполнится и при успехе, и при ошибке
},
});
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
mutation.mutate({
title: formData.get('title') as string,
body: formData.get('body') as string,
userId: 1,
});
}
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Title" />
<textarea name="body" placeholder="Body" />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create Post'}
</button>
{mutation.isError && <p>Error: {mutation.error.message}</p>}
</form>
);
}Оптимистичные обновления
Паттерн, при котором UI обновляется мгновенно, не дожидаясь ответа сервера. Если запрос завершится ошибкой - данные откатятся к предыдущему состоянию.
const mutation = useMutation({
mutationFn: (dto: CreatePostDto) => postService.create(dto),
onMutate: async (newPostDto) => {
// отменяем исходящие запросы, чтобы они не перезаписали оптимистичное обновление
await queryClient.cancelQueries({ queryKey: ['posts'] });
// сохраняем предыдущее состояние для отката
const previousPosts = queryClient.getQueryData<Post[]>(['posts']);
// оптимистично обновляем кэш
queryClient.setQueryData<Post[]>(['posts'], (old) => [
...(old ?? []),
{ ...newPostDto, id: Date.now() },
]);
// возвращаем контекст для отката
return { previousPosts };
},
onError: (_error, _variables, context) => {
// откатываем к предыдущему состоянию при ошибке
if (context?.previousPosts) {
queryClient.setQueryData(['posts'], context.previousPosts);
}
},
onSettled: () => {
// всегда синхронизируем с сервером после мутации
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});useInfiniteQuery
Хук для реализации бесконечной подгрузки данных.
import { useInfiniteQuery } from '@tanstack/react-query';
interface PaginatedResponse<T> {
items: T[];
nextCursor: number | null;
}
function InfinitePostList() {
const {
data,
isPending,
isError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: async ({ pageParam }): Promise<PaginatedResponse<Post>> => {
const response = await api.get('/posts', {
params: { cursor: pageParam, limit: 20 },
});
return response.data;
},
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
if (isPending) return <div>Loading...</div>;
if (isError) return <div>Error loading posts</div>;
return (
<div>
{data.pages.map((page, i) => (
<div key={i}>
{page.items.map((post) => (
<div key={post.id}>{post.title}</div>
))}
</div>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? 'Loading more...'
: hasNextPage
? 'Load more'
: 'Nothing more to load'}
</button>
</div>
);
}Параллельные запросы
Несколько вызовов useQuery в одном компоненте выполняются параллельно автоматически.
function Dashboard() {
const postsQuery = useQuery({
queryKey: ['posts'],
queryFn: postService.getAll,
});
const usersQuery = useQuery({
queryKey: ['users'],
queryFn: userService.getAll,
});
if (postsQuery.isPending || usersQuery.isPending) {
return <div>Loading...</div>;
}
return (
<div>
<PostList posts={postsQuery.data} />
<UserList users={usersQuery.data} />
</div>
);
}Зависимые запросы
Запросы, которые должны выполняться последовательно, управляются через опцию enabled.
function UserPosts({ userId }: { userId: number }) {
// первый запрос - получаем пользователя
const userQuery = useQuery({
queryKey: ['user', userId],
queryFn: () => userService.getById(userId),
});
// второй запрос - выполнится только когда первый завершится
const postsQuery = useQuery({
queryKey: ['posts', { authorId: userQuery.data?.id }],
queryFn: () => postService.getByAuthor(userQuery.data!.id),
enabled: !!userQuery.data?.id,
});
if (userQuery.isPending) return <div>Loading user...</div>;
return (
<div>
<h2>{userQuery.data.name}</h2>
{postsQuery.isPending ? (
<div>Loading posts...</div>
) : (
postsQuery.data?.map((post) => <div key={post.id}>{post.title}</div>)
)}
</div>
);
}Инвалидация запросов
invalidateQueries помечает данные как устаревшие и перезапрашивает их, если соответствующий запрос активен.
const queryClient = useQueryClient();
// инвалидировать все запросы с ключом ['posts']
queryClient.invalidateQueries({ queryKey: ['posts'] });
// инвалидировать конкретный запрос
queryClient.invalidateQueries({ queryKey: ['posts', postId] });
// инвалидировать все запросы, начинающиеся с ['posts']
queryClient.invalidateQueries({ queryKey: ['posts'], exact: false });
// инвалидировать вообще все запросы
queryClient.invalidateQueries();Info
Ключи сравниваются по принципу prefix matching. Инвалидация
['posts']затронет и['posts', 1], и['posts', { page: 2 }]. Чтобы инвалидировать только точное совпадение, используйтеexact: true.
Prefetching
Предварительная загрузка данных позволяет заполнить кэш до того, как пользователь перейдёт на страницу.
const queryClient = useQueryClient();
// предзагрузка при наведении на ссылку
function PostLink({ postId }: { postId: number }) {
function handleMouseEnter() {
queryClient.prefetchQuery({
queryKey: ['posts', postId],
queryFn: () => postService.getById(postId),
staleTime: 60_000,
});
}
return (
<a href={`/posts/${postId}`} onMouseEnter={handleMouseEnter}>
View Post
</a>
);
}useQueryClient
Хук для доступа к экземпляру QueryClient из любого компонента внутри провайдера.
import { useQueryClient } from '@tanstack/react-query';
function CacheControls() {
const queryClient = useQueryClient();
return (
<div>
<button onClick={() => queryClient.invalidateQueries({ queryKey: ['posts'] })}>
Refresh Posts
</button>
<button onClick={() => queryClient.clear()}>
Clear Cache
</button>
</div>
);
}Кастомные хуки
Оборачивание запросов в кастомные хуки инкапсулирует ключи, параметры и трансформации. Это основной паттерн для production-кода.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { postService, Post, CreatePostDto } from './services/post.service';
// ключи запросов как фабричные функции
const postKeys = {
all: ['posts'] as const,
lists: () => [...postKeys.all, 'list'] as const,
list: (filters: Record<string, unknown>) => [...postKeys.lists(), filters] as const,
details: () => [...postKeys.all, 'detail'] as const,
detail: (id: number) => [...postKeys.details(), id] as const,
};
export function usePosts(filters?: Record<string, unknown>) {
return useQuery({
queryKey: filters ? postKeys.list(filters) : postKeys.lists(),
queryFn: postService.getAll,
staleTime: 5 * 60 * 1000,
});
}
export function usePost(id: number) {
return useQuery({
queryKey: postKeys.detail(id),
queryFn: () => postService.getById(id),
enabled: id > 0,
});
}
export function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: CreatePostDto) => postService.create(dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: postKeys.lists() });
},
});
}
export function useDeletePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => postService.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: postKeys.lists() });
},
});
}Использование в компоненте:
function PostPage({ postId }: { postId: number }) {
const { data: post, isPending } = usePost(postId);
const deletePost = useDeletePost();
if (isPending) return <div>Loading...</div>;
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
<button
onClick={() => deletePost.mutate(postId)}
disabled={deletePost.isPending}
>
Delete
</button>
</article>
);
}Глобальные настройки
Значения по умолчанию задаются при создании QueryClient.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
gcTime: 5 * 60 * 1000,
retry: 2,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
},
mutations: {
retry: 1,
},
},
});Info
В v5 параметр
cacheTimeпереименован вgcTime(garbage collection time). Это время, в течение которого неактивные данные хранятся в памяти перед удалением.
DevTools
DevTools отображают состояние всех запросов: свежие, устаревшие, активные и неактивные. Через панель можно вручную инвалидировать, сбросить или удалить любой запрос из кэша.
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router />
{/* DevTools автоматически скрываются в production-сборке */}
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-right" />
</QueryClientProvider>
);
}TypeScript
TanStack Query v5 полностью типизирован. Типы выводятся автоматически из queryFn.
// типы выводятся из возвращаемого значения queryFn
const { data } = useQuery({
queryKey: ['posts'],
queryFn: postService.getAll,
});
// data имеет тип Post[] | undefined
// явное указание типов при необходимости
const { data } = useQuery<Post[], Error>({
queryKey: ['posts'],
queryFn: postService.getAll,
});
// типизация select
const { data } = useQuery({
queryKey: ['posts'],
queryFn: postService.getAll,
select: (posts): string[] => posts.map((p) => p.title),
});
// data имеет тип string[] | undefined
// типизация ошибок
const { error } = useQuery<Post[], AxiosError<{ message: string }>>({
queryKey: ['posts'],
queryFn: postService.getAll,
});
// error имеет тип AxiosError<{ message: string }> | nullSummary
Основные изменения в v5 по сравнению с v4:
isLoadingзаменён наisPending,cacheTimeпереименован вgcTime, строковые ключи мутаций удалены,keepPreviousDataзаменён наplaceholderData, все хуки принимают единственный объект параметров,onSuccess/onErrorколбэки вuseQueryудалены - используйтеuseEffectили обрабатывайте в компоненте.