Серверное состояние — это кеш, и управлять им вручную не нужно. 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-контракта, замыкая сквозную типобезопасность от базы до экрана.