open atlas
↑ К треку
Паттерны React RXP · 13 · 02

Библиотеки запросов и дедупликация

Библиотека запросов превращает серверное состояние в кэшируемый саморевалидирующийся ресурс — кэш, дедупликация, фоновая ревалидация, повторы и мутация-с-инвалидацией, — чтобы ты перестал копировать серверные данные в useState/Redux и синхронизировать их вручную.

RXP Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Ты собрал загрузку данных очевидным способом: useEffect запускает fetch, результат падает в useState, флаг loading переключается. Для одного компонента это работает. Потом тот же пользователь нужен второму компоненту. Потом третьему. Каждый монтирует свой эффект, шлёт свой запрос, хранит свою копию. Теперь один и тот же ответ /me живёт в трёх местах, проходит по сети трижды, и три копии расходятся в тот же момент, как одну из них мутируют.

Лечение не в том, чтобы «добавить ещё useState». Лечение в том, чтобы вообще перестать относиться к серверным данным как к клиентскому состоянию. Библиотека запросов — TanStack Query, SWR — превращает удалённый эндпоинт в кэшируемый саморевалидирующийся ресурс, который каждый компонент читает из одного места. Кэширование, дедупликация, повторы и ревалидация идут в комплекте — бесплатно и корректно. Этот урок о том, что это тебе даёт, и какой анти-паттерн оно отправляет в отставку.

Цель

После этого урока ты можешь объяснить, что на самом деле даёт библиотека запросов — общий кэш, дедупликацию запросов, фоновую ревалидацию (stale-while-revalidate) и повторы, — ничего из этого не написав руками; написать чтение через useQuery и мутацию, которая инвалидирует нужный ключ кэша, чтобы зависимые чтения перезапросились; и распознать режим отказа «продублированное серверное состояние» (копирование загруженных данных в useState/Redux и ручная синхронизация) и понять, почему библиотека запросов — это senior-альтернатива.

1

Библиотека запросов — это кэш, ключённый по 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"] и отдаёт всем трём один результат. Это дедупликация запросов — первое, что иначе ты переизобрёл бы плохо.

2

Фоновая ревалидация — это 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.

3

Мутации не пишут в кэш напрямую — они инвалидируют ключи, а зависимые чтения сами ревалидируются. После записи истина — на сервере, поэтому правильный ход: пометить затронутые 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-экшена, рябью идущего через редьюсеры, — кэш единственный источник, а инвалидация единственный механизм синхронизации.

4

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

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.