← назад к разделу

Практически любое 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 с ключом ключ не найден в кеше пусто: isPending, идёт запрос ответ пришёл ответ в кеше: data у всех с этим ключом данные устарели возврат на вкладку: старое сразу, свежее в фоне

Второй компонент с тем же ключом не делает запрос, а читает кеш; фоновое обновление запускается, когда данные старше staleTime.

Мутации: изменение данных

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 минут, фонового обновления не будет
});
Дополнительно: при первом чтении можно пропустить

Глубже: оптимистичное обновлениерасширенноеспросят на собеседовании

Кнопка «в избранное» не должна ждать сервер: пользователь нажал и сразу видит результат. Мутация умеет подменить кеш до ответа и откатить его, если сервер отказал:

useMutation({
  mutationFn: toggleFavorite,
  onMutate: async (id: string) => {
    await queryClient.cancelQueries({ queryKey: ["products"] });
    const previous = queryClient.getQueryData<Product[]>(["products"]);
    queryClient.setQueryData<Product[]>(["products"], (old) =>
      old?.map((p) => (p.id === id ? { ...p, favorite: !p.favorite } : p)),
    );
    return { previous };
  },
  onError: (_err, _id, ctx) => queryClient.setQueryData(["products"], ctx?.previous),
  onSettled: () => queryClient.invalidateQueries({ queryKey: ["products"] }),
});

Три шага повторяются в каждом таком коде: отменить фоновые запросы, чтобы они не перетёрли подмену; сохранить снимок и записать ожидаемый результат; при ошибке вернуть снимок, а в любом исходе сверить кеш с сервером. Приём уместен для действий, которые почти всегда удаются и обратимы: лайк, флажок, переименование. Для оплаты и удаления честнее показать ожидание.

Коротко

  • useEffect + useState для загрузки данных — источник целого класса багов: нет кеша, нет обработки ошибок, гонки запросов.
  • TanStack Query управляет серверными данными как кешем: загружает, хранит, обновляет в фоне.
  • useQuery объявляет запрос; queryKey — ключ кеша, queryFn — функция загрузки.
  • Параметры в queryKey — автоматическое переключение запроса при изменении фильтров.
  • isPending / isError / data — состояния запроса прямо из хука.
  • useMutation — для изменения данных; invalidateQueries после успеха обновляет связанный кеш.
  • Фоновое обновление при возврате на вкладку или восстановлении сети — из коробки.

Что почитать дальше