Практически любое React-приложение работает с данными с сервера: список товаров, профиль пользователя, история заказов. Казалось бы, задача простая — сделал запрос, положил данные в переменную, отобразил. Но на практике всё сложнее: нужно показать индикатор загрузки, обработать ошибку, не делать лишних запросов, обновить список после изменения. Всё это можно написать самому — и это будет много кода с багами. Или воспользоваться TanStack Query.
Почему fetch в useEffect не работает хорошо
Самый очевидный способ загрузить данные в React — сделать запрос внутри useEffect и сохранить результат в useState:
function ProductList() {
const [data, setData] = useState(null);
useEffect(() => {
fetch("/api/products")
.then((r) => r.json())
.then(setData);
}, []);
return <ul>{data?.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;
}
Такой код работает для самого простого случая, но быстро показывает проблемы:
- Нет индикатора загрузки. Пока данные не пришли, компонент отображает пустоту.
- Нет обработки ошибки. Если запрос упал, пользователь ничего не увидит.
- Нет кеша. Каждый раз, когда компонент монтируется заново, идёт новый запрос — даже если данные уже есть.
- Гонка запросов. Если пользователь быстро переключает фильтры, несколько запросов летят параллельно и приходят в случайном порядке — в итоге на экране оказываются устаревшие данные.
- Дублирующиеся запросы. Если один и тот же список нужен двум компонентам, каждый делает свой запрос независимо.
TanStack Query (раньше назывался React Query) решает все эти проблемы из коробки.
Что такое TanStack Query
TanStack Query — библиотека для управления серверным состоянием в React. Главная идея: данные с сервера — это не часть состояния приложения, а кеш. Библиотека управляет этим кешем: загружает данные, хранит их, обновляет в фоне, инвалидирует когда нужно.
Установка:
npm install @tanstack/react-query
Приложение оборачивается в провайдер один раз:
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router />
</QueryClientProvider>
);
}
Как сделать запрос через useQuery
Любой запрос данных объявляется через useQuery. У него два обязательных параметра:
queryKey— уникальный ключ, по которому Query хранит данные в кеше. Если два компонента используют один ключ, они получат одни и те же данные без повторного запроса.queryFn— асинхронная функция, которая загружает данные. Должна вернуть данные или выбросить ошибку.
import { useQuery } from "@tanstack/react-query";
function useProducts() {
return useQuery({
queryKey: ["products"],
queryFn: async (): Promise<Product[]> => {
const res = await fetch("/api/products");
if (!res.ok) throw new Error("Не удалось загрузить товары");
return res.json();
},
});
}
Хорошая практика — выносить useQuery в отдельный хук (useProducts, useUser и т.д.), а не писать прямо в компоненте. Тогда логику загрузки легко переиспользовать.
В компоненте используем то, что вернул хук:
function ProductList() {
const { data, isPending, isError } = useProducts();
if (isPending) return <Spinner />;
if (isError) return <p>Что-то пошло не так. Попробуйте обновить страницу.</p>;
return (
<ul>
{data.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
isPending — запрос ещё идёт, данных нет. isError — запрос завершился ошибкой. data — загруженные данные (доступны только когда isPending и isError оба false).
Параметры в ключе запроса
Если данные зависят от параметра (категория, поисковый запрос, страница) — параметр включается в queryKey. Query автоматически делает новый запрос при изменении ключа:
function useProducts(category: string) {
return useQuery({
queryKey: ["products", category],
queryFn: async (): Promise<Product[]> => {
const res = await fetch(`/api/products?category=${category}`);
if (!res.ok) throw new Error("Ошибка загрузки");
return res.json();
},
});
}
При смене категории Query запрашивает новые данные, а старые остаются в кеше — если пользователь вернётся к предыдущей категории, данные покажутся мгновенно, пока идёт фоновое обновление.
Мутации: изменение данных
useQuery — для чтения. Для создания, обновления и удаления используют useMutation.
import { useMutation, useQueryClient } from "@tanstack/react-query";
function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateProductInput) =>
fetch("/api/products", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
}).then((r) => {
if (!r.ok) throw new Error("Не удалось создать товар");
return r.json();
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] });
},
});
}
После успешного создания вызывается invalidateQueries — Query помечает кеш списка товаров устаревшим и автоматически перезапрашивает его. Список на экране обновится сам, без ручного обновления состояния.
В компоненте мутация вызывается через mutate:
function CreateProductButton() {
const { mutate, isPending } = useCreateProduct();
return (
<button
onClick={() => mutate({ name: "Новый товар", price: 100 })}
disabled={isPending}
>
{isPending ? "Сохранение..." : "Создать товар"}
</button>
);
}
Фоновое обновление
По умолчанию TanStack Query обновляет данные в нескольких ситуациях:
- когда пользователь возвращается на вкладку браузера;
- когда компонент монтируется повторно;
- когда восстанавливается интернет-соединение.
При этом пользователь видит старые данные сразу, а обновлённые подтягиваются в фоне. Это называется stale-while-revalidate — показать то, что есть, и тихо обновить.
Время, через которое данные считаются устаревшими, настраивается через staleTime:
useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
staleTime: 5 * 60 * 1000, // данные свежие 5 минут, фонового обновления не будет
});
Коротко
useEffect + useStateдля загрузки данных — источник целого класса багов: нет кеша, нет обработки ошибок, гонки запросов.- TanStack Query управляет серверными данными как кешем: загружает, хранит, обновляет в фоне.
useQueryобъявляет запрос;queryKey— ключ кеша,queryFn— функция загрузки.- Параметры в
queryKey— автоматическое переключение запроса при изменении фильтров. isPending/isError/data— состояния запроса прямо из хука.useMutation— для изменения данных;invalidateQueriesпосле успеха обновляет связанный кеш.- Фоновое обновление при возврате на вкладку или восстановлении сети — из коробки.
Что почитать дальше
- Состояние в React — разница между локальным и серверным состоянием.
- Формы в React — как данные из запросов попадают в формы редактирования.
- Обработка ошибок и Suspense — как показывать ошибки и состояния загрузки системно.