Библиотеки запросов и дедупликация
Библиотека запросов превращает серверное состояние в кэшируемый саморевалидирующийся ресурс — кэш, дедупликация, фоновая ревалидация, повторы и мутация-с-инвалидацией, — чтобы ты перестал копировать серверные данные в useState/Redux и синхронизировать их вручную.
Ты собрал загрузку данных очевидным способом: useEffect запускает fetch, результат падает в useState, флаг loading переключается. Для одного компонента это работает. Потом тот же пользователь нужен второму компоненту. Потом третьему. Каждый монтирует свой эффект, шлёт свой запрос, хранит свою копию. Теперь один и тот же ответ /me живёт в трёх местах, проходит по сети трижды, и три копии расходятся в тот же момент, как одну из них мутируют.
Лечение не в том, чтобы «добавить ещё useState». Лечение в том, чтобы вообще перестать относиться к серверным данным как к клиентскому состоянию. Библиотека запросов — TanStack Query, SWR — превращает удалённый эндпоинт в кэшируемый саморевалидирующийся ресурс, который каждый компонент читает из одного места. Кэширование, дедупликация, повторы и ревалидация идут в комплекте — бесплатно и корректно. Этот урок о том, что это тебе даёт, и какой анти-паттерн оно отправляет в отставку.
После этого урока ты можешь объяснить, что на самом деле даёт библиотека запросов — общий кэш, дедупликацию запросов, фоновую ревалидацию (stale-while-revalidate) и повторы, — ничего из этого не написав руками; написать чтение через useQuery и мутацию, которая инвалидирует нужный ключ кэша, чтобы зависимые чтения перезапросились; и распознать режим отказа «продублированное серверное состояние» (копирование загруженных данных в useState/Redux и ручная синхронизация) и понять, почему библиотека запросов — это senior-альтернатива.
Библиотека запросов — это кэш, ключённый по query-ключу, и сам ключ — весь механизм. Ты не загружаешь данные в компонент; ты объявляешь «этот ключ отображается в этот fetcher», а библиотека владеет записью кэша. Каждый компонент, который просит тот же ключ, читает ту же запись. Эта единственная идея и даёт тебе дедупликацию, шеринг и инвалидацию бесплатно.
"use client";
import { useQuery } from "@tanstack/react-query";
function useUser(id: string) {
return useQuery({
queryKey: ["user", id], // identity of the cached resource
queryFn: () => fetch(`/api/users/${id}`).then((r) => r.json()),
staleTime: 30_000, // 30s: data is "fresh", no refetch
});
}Смонтируй useUser("42") в трёх компонентах — и ты не получишь три запроса. Библиотека видит один промис в полёте для ["user","42"] и отдаёт всем трём один результат. Это дедупликация запросов — первое, что иначе ты переизобрёл бы плохо.
Фоновая ревалидация — это stale-while-revalidate: мгновенно отдать кэшированное значение, перезапросить под капотом, подменить, когда свежее готово. Твой компонент никогда не блокируется на перезапросе, которого не просил. Когда запрос устаревает (прошёл staleTime) и что-то снова его триггерит — повторный монтаж, возврат фокуса окну, ручной invalidate, — библиотека немедленно возвращает кэшированные данные и запускает фоновый fetch. UI показывает последние известные-хорошие данные без спиннера, затем обновляется.
function UserName({ id }: { id: string }) {
const { data, isPending, isFetching } = useUser(id);
if (isPending) return <Skeleton />; // first load, no cache yet
// isFetching === true here means a *background* revalidation is running;
// `data` is still the cached value, fully usable — no spinner needed
return <span>{data.name}{isFetching && <Dot title="refreshing" />}</span>;
}Заметь два разных состояния: isPending (данных нет вообще, показывай скелетон) против isFetching (данные есть, обновляются в фоне). Самописная загрузка схлопывает оба в один булев loading и мигает спиннером на каждой ревалидации. Библиотека их разделяет, потому что это разный UX.
Мутации не пишут в кэш напрямую — они инвалидируют ключи, а зависимые чтения сами ревалидируются. После записи истина — на сервере, поэтому правильный ход: пометить затронутые query-ключи устаревшими и дать библиотеке перезапросить. Ты объявляешь, какие ключи инвалидирует эта мутация; всё, что читает эти ключи, обновляется. Ты никогда вручную не лезешь в другие компоненты, чтобы «ещё и их копию обновить».
import { useMutation, useQueryClient } from "@tanstack/react-query";
function useRenameUser(id: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (name: string) =>
fetch(`/api/users/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["user", id] }); // this user refetches
qc.invalidateQueries({ queryKey: ["users"] }); // any list including them refetches
},
});
}Один вызов invalidateQueries веером расходится на каждого потребителя этого ключа. Нет проброса нового значения через пропсы, нет Redux-экшена, рябью идущего через редьюсеры, — кэш единственный источник, а инвалидация единственный механизм синхронизации.
Повторы и ось отказов встроены — а режим отказа при отсутствии библиотеки — это продублированное серверное состояние. Библиотеки запросов повторяют упавшие запросы с backoff, выставляют error/isError и позволяют настроить число повторов на запрос. Ты получаешь настоящий путь ошибки, а не недоделанный try/catch в каждом компоненте. Но более глубокий выигрыш — в том, что ты перестаёшь делать: в тот момент, как ты копируешь загруженный ответ в useState или диспатчишь его в Redux, ты создал второй источник истины, который сервер может инвалидировать в любую секунду, — и теперь ты владеешь кодом синхронизации, который вечно расходится.
// ANTI-PATTERN: server data copied into client state, synced by hand
const [user, setUser] = useState<User | null>(null);
useEffect(() => { fetch(`/api/users/${id}`).then(r => r.json()).then(setUser); }, [id]);
async function rename(name: string) {
await fetch(`/api/users/${id}`, { method: "PATCH", body: JSON.stringify({ name }) });
setUser((u) => u && { ...u, name }); // optimistic guess — now this copy can drift from the server,
// and any OTHER component holding its own copy won't update at all
}Этот setUser — ловушка: это догадка о серверном состоянии, она не распространяется на другие копии, и здесь нет дедупликации, нет ревалидации, нет повторов. Библиотека заменяет всё это на «прочитай ключ, инвалидируй ключ».
Экран профиля и шапка оба показывают текущего пользователя; переименование должно обновить оба. Смотри, как самописная версия расходится, а версия на запросах остаётся согласованной.
До — каждый компонент владеет копией, переименование патчит только одну:
// Header.tsx
function Header() {
const [user, setUser] = useState<User | null>(null);
useEffect(() => { fetch("/api/me").then(r => r.json()).then(setUser); }, []);
return <span>{user?.name}</span>;
}
// Profile.tsx — a SECOND copy, a SECOND request
function Profile() {
const [user, setUser] = useState<User | null>(null);
useEffect(() => { fetch("/api/me").then(r => r.json()).then(setUser); }, []);
async function rename(name: string) {
await fetch("/api/me", { method: "PATCH", body: JSON.stringify({ name }) });
setUser((u) => u && { ...u, name }); // updates Profile's copy only — Header still shows the old name
}
// ...
}Два запроса /api/me на загрузке, и после переименования шапка остаётся устаревшей до полной перезагрузки. Теперь версия на запросах — один ключ, оба его читают, мутация его инвалидирует:
function useMe() {
return useQuery({ queryKey: ["me"], queryFn: () => fetch("/api/me").then(r => r.json()) });
}
function Header() {
const { data } = useMe(); // same cache entry as Profile — ONE request, deduped
return <span>{data?.name}</span>;
}
function Profile() {
const { data } = useMe();
const qc = useQueryClient();
const rename = useMutation({
mutationFn: (name: string) =>
fetch("/api/me", { method: "PATCH", body: JSON.stringify({ name }) }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["me"] }), // BOTH refetch from one source
});
// ...
}Один запрос на загрузке (дедуплицирован), и после переименования invalidateQueries(["me"]) перезапрашивает единственную запись — Header и Profile обновляются вместе, потому что они никогда и не были раздельными копиями. Библиотека заменила тот код синхронизации, который ты собирался написать, тонко ошибиться в нём и поддерживать вечно.
▸Почему это работает
Почему серверное состояние категорически отличается от клиентского? Клиентским состоянием (открыта/закрыта ли модалка, черновик формы) владеет твоё приложение — ты источник истины. Серверное состояние одолжено: им владеет сервер, оно может измениться без участия твоего приложения, и твоя копия устаревает в тот же миг, как приходит. Относиться к одолженному состоянию как к собственному — закрепить его в useState/Redux — значит подписаться вечно пересинхронизировать его вручную, а ты не можешь, ведь тебе не приходит уведомление, когда сервер меняется. Библиотека запросов моделирует это заимствование корректно: кэш со сроком годности и стратегией ревалидации, а не переменная, которой ты владеешь.
▸Частая ошибка
Соблазнительная ошибка — использовать библиотеку запросов и всё равно копировать её data в useState «чтобы я мог это редактировать». В тот момент, как ты делаешь const [name, setName] = useState(data.name), ты заново создал проблему дублированного состояния, ради предотвращения которой библиотека и существует, — твоя локальная копия не отследит фоновые ревалидации и чужие мутации. Для редактируемых форм держи черновик в локальном состоянии (этот черновик действительно клиентский), но никогда не зеркаль весь серверный объект; читай его живьём из запроса и отправляй через мутацию. Правило: локальное состояние держит то, что пользователь редактирует, запрос держит то, что знает сервер, — и встречаются они только в момент сабмита.
Три компонента монтируются на одном экране, и каждый вызывает useUser('42') с одним и тем же query-ключом. Данные сейчас устаревшие. Что делает библиотека запросов?
Библиотека запросов (TanStack Query, SWR) превращает удалённый эндпоинт в кэшируемый саморевалидирующийся ресурс, адресуемый по query-ключу. Из этой одной идеи ты получаешь дедупликацию запросов (один ключ → один fetch, разделяемый всеми читателями), stale-while-revalidate (мгновенно отдать кэшированные данные, перезапросить в фоне, различать isPending и isFetching) и повторы с backoff — ничего из этого ты не пишешь. Записи используют мутацию, которая инвалидирует ключи: invalidateQueries(["user", id]) помечает запись устаревшей, и каждый потребитель перезапрашивает из единственного источника, так что нет ручной межкомпонентной синхронизации. Режим отказа, который это отправляет в отставку, — продублированное серверное состояние: копирование загруженных данных в useState/Redux и ручное их патчинг, что расходится в тот же миг, как сервер меняется или появляется вторая копия. Senior-правило: серверное состояние одолжено, а не собственное; моделируй его как кэш с ревалидацией, держи в useState только действительно клиентское состояние (черновики, UI-переключатели) и не переизобретай кэш, дедупликацию и повторы руками.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.