TanStack Query для React: кэширование, перезагрузка и мутации
Замените шаблонный код useEffect fetch на TanStack Query: ключи запросов, параметры staleTime и gcTime, операции изменения данных, а также ситуации, когда эта библиотека не требуется.
Как кэшируется, обновляется и перезагружается асинхронное состояние сервера — и когда стоит добавлять соответствующую библиотеку.
Многие приложения на React начинаются с одинаковой структуры загрузки данных: useEffect, функция fetch и несколько флагов useState для отображения информации о задачах и ошибках. Такой подход подходит для первоначальной реализации. Однако именно здесь часто возникают дублирующиеся запросы, устаревшие экраны и скопированный шаблон кода.
В следующих разделах рассматриваются эти проблемы и показано, как TanStack Query их решает. Единственными предпосылками являются хуки React и базовые знания TypeScript. В примерах используется DummyJSON — публичный API, не требующий ключа доступа.
1. Базовая структура
Список продуктов, написанный с использованием стандартных хуков, выглядит так.
import { useEffect, useState } from "react";
type Product = {
id: number;
title: string;
price: number;
};
function ProductList() {
const [products, setProducts] = useState<Product[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
fetch("https://dummyjson.com/products?limit=10")
.then((res) => {
if (!res.ok) throw new Error("Request failed");
return res.json();
})
.then((data: { products: Product[] }) => {
if (!cancelled) setProducts(data.products);
})
.catch((err: Error) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, []);
if (isLoading) return <p>Loading…</p>;
if (error) return <p>{error}</p>;
return (
<ul>
{products.map((product) => (
<li key={product.id}>
{product.title} - ${product.price}
</li>
))}
</ul>
);
}
Этот код уже достаточно осторожен: он использует флаг cancelled, чтобы медленный ответ не мог изменить состояние после отключения. Множество реальных проектов пренебрегают такой защитой.
2. Что не учитывается в этом коде
Сам запрос работает нормально. Проблемы кроются в окружающем коде.
- Отсутствует кэширование. При покидании страницы и возвращении снова выполняется сетевой запрос, даже если данные не изменились.
- Отсутствует уникализация запросов. Три компонента, нуждающиеся в одном и том же списке товаров, отправляют три одинаковых запроса.
- Отсутствует возможность повторной попытки. Одно прерванное соединение приводит к ошибке на интерфейсе, даже если вторая попытка была бы успешной.
- Отсутствует повторная верификация данных. Вкладка, оставленная открытой час, продолжает показывать устаревшие данные до тех пор, пока что-то ещё не запустит загрузку.
cancelled помогает при отключении функции, но не полностью решает проблему перекрытия обрабатываемых запросов.Для каждой из этих проблем существует известное решение. Реализация этих решений вручную означает необходимость создания собственного слоя кэширования.
3. Идея библиотеки
Полезно разделять состояние на два вида.
Клиентское состояние находится в ведении интерфейса: открыта ли модальная окна, какое поле формы активно, какая тема выбрана. Оно меняется только тогда, когда это делает ваш код. Для этой задачи подходит useState.
Состояние сервера является заимствованным. Оно хранится в магазине, который находится не под вашим контролем. Другие пользователи могут его изменять, а копия в браузере представляет собой лишь моментальную снимок. Хранение этого снимка исключительно в useState создаёт иллюзию того, что временный вид является официальным.
Заимствованные данные требуют специального хранилища: места для копии, механизма отслеживания срока годности и правила, определяющего момент повторного получения данных.
Холодильник — подходящая аналогия. Молоко хранится дома, чтобы не приходить в магазин за каждой чашкой кофе, но оно портится, поэтому нужно проверять дату и пополнять запасы до того, как оно испортится. TanStack Query выполняет эту функцию для ответов API.
4. Что такое TanStack Query
TanStack Query управляет асинхронным удалённым состоянием в приложениях браузера. Проект распространяется под лицензией MIT, его можно использовать бесплатно, и он широко применяется в проектах на React.
Руководства, написанные много лет назад, по-прежнему упоминают React Query. Это название использовалось до версии 3. После выпуска адаптеров для Vue, Svelte, Solid и Angular версия 4 переименовала проект в TanStack Query. В React вы по-прежнему устанавливаете @tanstack/react-query; текущей является основная версия 5.
Он не является заменой для fetch или axios. Функция запроса остается у вас. Библиотека отвечает за определение времени выполнения, хранения данных, их свежести и обработки сбоев вокруг этой функции.
5. Настройка
Для начала достаточно двух шагов.
npm install @tanstack/react-query
Затем оберните всю структуру один раз у корня:
// main.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
const queryClient = new QueryClient();
export default function Root() {
return (
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
}
QueryClient — это экземпляр кэша. QueryClientProvider делает его доступным для всех компонентов-потомков.
6. Тот же компонент, переписанный
import { useQuery } from "@tanstack/react-query";
type Product = {
id: number;
title: string;
price: number;
};
async function fetchProducts(): Promise<Product[]> {
const res = await fetch("https://dummyjson.com/products?limit=10");
if (!res.ok) throw new Error("Request failed");
const data: { products: Product[] } = await res.json();
return data.products;
}
function ProductList() {
const { data, isPending, isError, error } = useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
});
if (isPending) return <p>Loading…</p>;
if (isError) return <p>{error.message}</p>;
return (
<ul>
{data.map((product) => (
<li key={product.id}>
{product.title} - ${product.price}
</li>
))}
</ul>
);
}
Примерно сорок строк сокращаются до пятнадцати. Переменная data задаётся в виде Product[] без дополнительных аннотаций, поскольку её тип определяется функцией fetchProducts. После обработки веток isPending и isError TypeScript считает переменную data определённой, поэтому не требуется использование операции опционального цепления для метода map и нет необходимости в утверждении отсутствия нуля.
7. Что даёт более краткая версия
По сравнению с недостатками, описанными в разделе 2, значения по умолчанию уже покрывают наиболее частые случаи:
- Функция Remount сразу отображает кэшированные данные и повторно проверяет их в фоновом режиме.
- Идентичные запросы, находящиеся в процессе выполнения, объединяются в один запрос.
- Неудачные запросы автоматически повторяются (по умолчанию три попытки с задержками).
- Устаревшие записи перезагружаются при фокусировке окна, восстановлении сетевого соединения и повторном отображении контента.
Ни один из этих параметров поведения не настраивался в переписанном компоненте — это стандартная настройка библиотеки.
8. Три момента, которые стоит понимать
Большинство первоначальных недопониманий связаны с следующими тремя аспектами.
Ключ запроса
queryKey — это адрес кэша. Два компонента, использующих ["products"], делят одну запись и один сетевой запрос.
Основное правило: в ключе должно присутствовать каждое значение, от которого зависит функция запроса.
function ProductList({ category }: { category: string }) {
const { data } = useQuery({
queryKey: ["products", category],
queryFn: () => fetchProductsByCategory(category),
});
// …
}
Если из ключа исключить category, изменение категории все равно может привести к отображению списка предыдущей категории из кэша. Эта ошибка чрезвычайно распространена среди новичков.
staleTime и gcTime
Эти названия звучат похоже, но означают разные вещи.
staleTime устанавливает окно срока действия данных. В течение этого окна библиотека пропускает сетевые операции. При значении по умолчанию 0 результат сразу считается устаревшим: интерфейс всё ещё может отображать значение из кэша, но любое событие запускает фоновую обновку. Увеличьте это значение, если данные редко меняются:
useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
staleTime: 5 * 60 * 1000, // fresh for five minutes
});
gcTime — это время, в течение которого неиспользуемые данные остаются в памяти после того, как последний подписчик отключился. По умолчанию это пять минут. Когда истекает это время, запись удаляется, и следующий запрос начинается с чистого листа.
Короче говоря: staleTime контролирует повторную загрузку; gcTime — удаление данных.
Когда происходит повторная загрузка
По умолчанию устаревшее запросы обновляются при загрузке компонента, когда окно снова получает фокус и при восстановлении соединения с сетью. Каждое из этих действий можно отключить как на уровне клиента, так и для конкретного запроса:
useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
refetchOnWindowFocus: false,
});
Функция обновления при получении фокуса удивляет пользователей в первый раз, когда они с ней сталкиваются. Обычно именно её активное использование позволяет долго открытой вкладке оставаться актуальной.
9. Изменение данных с помощью useMutation
useQuery используется для чтения данных. useMutation — для их записи.
import { useMutation, useQueryClient } from "@tanstack/react-query";
type NewProduct = {
title: string;
price: number;
};
function AddProductButton() {
const queryClient = useQueryClient();
const { mutate, isPending } = useMutation({
mutationFn: async (product: NewProduct) => {
const res = await fetch("https://dummyjson.com/products/add", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(product),
});
if (!res.ok) throw new Error("Could not add product");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] });
},
});
return (
<button
onClick={() => mutate({ title: "New product", price: 25 })}
disabled={isPending}
>
{isPending ? "Saving…" : "Add product"}
</button>
);
}
Важной строкой является invalidateQueries. Этот вызов помечает все записи в кэше с префиксом ["products"] как устаревшие, поэтому наблюдатели, запущенные при загрузке компонента, сразу запрашивают свежие данные. Локальные массивы не редактируются вручную, и странице не требуется полная перезагрузка.
10. Инструменты разработчика
npm install @tanstack/react-query-devtools
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
В процессе разработки панель отображает каждый ключ запроса, его статус, данные и время последнего получения. Наблюдение за тем, как записи меняют статус с «свежих» на «устаревшие» при наведении курсора, помогает быстрее понять принципы работы кэша, чем простое чтение информации. В версиях продукта этот пакет автоматически удаляется.
Распространённые ошибки
- Игнорирование переменных в ключе запроса. Если функция запроса считывает какое-либо значение, оно обязательно должно входить в состав ключа.
- Размещение
useQueryвнутриuseEffect. Этот хук уже выполняется во время отрисовки; нет необходимости что-либо дополнительно «запускать». - Использование библиотеки для управления только состоянием на стороне клиента. Поля форм и флаги модалок должны храниться с помощью
useState. - Установка
staleTime: Infinityвезде. Это отключает повторную верификацию данных, что сводит на нет большинство преимуществ.
data в локальное состояние. Таким образом создаются две копии, причем отрендеренная версия перестает отслеживать кэш.12. Когда он может не понадобиться
Если приложение обращается к одному конечной точке на одном экране, использование провайдеров и хуков может оказаться излишним по сравнению с реальной необходимостью.
Если фреймворк уже предоставляет слой данных — серверные компоненты Next.js или маршрутизатор с загрузчиками — часть работы уже выполнена. TanStack Query всё ещё может помочь с интерактивными запросами на стороне клиента, но это не обязательно.
Для состояния, которое никогда не покидает браузер, выберите другой инструмент.
13. Куда двигаться дальше
Повседневная работа охватывается вышеописанными концепциями. Более глубокие темы рассматриваются в других источниках:
- Официальная документация — справочные материалы и интерактивные демо-версии
- Страницированные и бесконечные списки — используйте
useInfiniteQuery, чтобы при прокрутке загружались дополнительные страницы - Запросы, ожидающие результатов других операций — блокируйте последующий запрос с помощью параметра
enabled, пока не завершится предварительная операция - Оптимистичный интерфейс — отображайте ожидаемый результат до возврата ответа с изменениями
Запомните один важный принцип: удаленные данные должны храниться в кэше, а не в состоянии отдельных компонентов. Исходя из этой концепции, остальная часть API становится более понятной.