Серверное состояние — это кеш, и управлять им вручную не нужно. TanStack Query (бывший React Query) решает ровно эту задачу: загрузка, кеширование, инвалидация, состояния загрузки и ошибки. Понять её — значит перестать писать руками то, что давно автоматизировано, и убрать из приложения целый класс багов.

Почему не useEffect

Классический антипаттерн — грузить данные в useEffect и складывать в useState:

// антипаттерн
const [data, setData] = useState();
useEffect(() => {
  fetch("/api/products").then((r) => r.json()).then(setData);
}, []);

Что тут не так: нет кеша (каждый монтаж — новый запрос), нет обработки гонок (быстрая смена параметров даёт устаревший ответ), нет состояний ошибки и фоновой перезагрузки, дублирующиеся запросы при нескольких компонентах. Всё это придётся написать руками — и это именно то, что даёт Query из коробки.

useQuery

Запрос объявляется через useQuery с ключом (queryKey) и функцией загрузки (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("failed to load products");
      return res.json();
    },
  });
}
function ProductList() {
  const { data, isPending, isError } = useProducts();
  if (isPending) return <Spinner />;
  if (isError) return <ErrorBox />;
  return <ul>{data.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;
}

isPending, isError, data — состояния запроса; Query сам кеширует по queryKey, переиспользует данные между компонентами, перезагружает в фоне по необходимости. Параметры запроса входят в ключ (["products", { category }]) — смена параметра автоматически даёт новый запрос и снимает проблему гонок.

Мутации и инвалидация

Изменение данных — это useMutation. После успешной мутации связанные запросы инвалидируют, чтобы кеш обновился.

import { useMutation, useQueryClient } from "@tanstack/react-query";

function useCreateProduct() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (input: CreateProductInput) =>
      fetch("/api/products", { method: "POST", body: JSON.stringify(input) }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["products"] });
    },
  });
}

invalidateQueries помечает кеш устаревшим — Query перезапросит данные, и список обновится сам. Это и есть управление серверным состоянием: ты описываешь, что от чего зависит, а синхронизацию делает библиотека.

Согласованность с backend-контрактом

Типы ответа — не выдумка frontend, а часть контракта API. queryFn должна возвращать тип, совпадающий с тем, что отдаёт backend; в идеале эти типы генерируются из контракта (OpenAPI), и тогда расхождение между frontend и backend ловится компилятором, а не в проде. Это та же типобезопасная граница, что между слоями backend, только между сервисами.

Где это в UCP

TanStack Query снимает с frontend целый пласт ручной работы и багов, переводя серверное состояние в декларативный кеш. Это позволяет продукт-инженеру не воевать с синхронизацией данных, а заниматься продуктом. Данные, пришедшие через Query, дальше попадают в формы и отображение — и везде типы держатся от backend-контракта, замыкая сквозную типобезопасность от базы до экрана.