React Query: как работает под капотом и почему это важно в production

Разбираем React Query под капотом: QueryClient, кэш, observers, дедупликацию, invalidation, мутации и реальные production trade-off.

07 апреля 2026 г.20 минLexicon Team

Введение

Тему React Query часто объясняют через внешний API: useQuery, useMutation, invalidateQueries. Для повседневной разработки этого хватает ровно до первого сложного экрана. Дальше начинаются вопросы, на которые поверхностного знания уже мало: почему два компонента не делают два одинаковых запроса, откуда берется stale UI, зачем разделять staleTime и gcTime, почему после мутации иногда достаточно точечной инвалидации, а иногда нужен optimistic rollback.

Если нужен общий контекст о месте query-слоя в архитектуре, полезно изучить React data fetching паттерны. Здесь же разберем более узкую и важную вещь: как React Query устроен под капотом и какие инженерные последствия это дает в production.

Больше вопросов в Telegram

Ежедневные разборы и реальные кейсы с интервью

Подписаться

Короткий ответ: что такое React Query на уровне архитектуры

React Query, который сейчас развивается как TanStack Query, удобнее всего воспринимать не как набор хуков, а как рантайм для server state. Он решает не только сетевой вызов, но и весь жизненный цикл данных после чтения:

  • идентификацию ресурса через queryKey;
  • хранение результата и статусов в кэше;
  • подписку нескольких компонентов на один источник данных;
  • дедупликацию одинаковых запросов;
  • политику свежести и устаревания;
  • инвалидацию после мутаций;
  • повторные запросы при событиях окружения;
  • обновление UI без ручного склеивания состояний loading/error/data в каждом компоненте.

Именно поэтому React Query стоит рассматривать вместе с React caching стратегиями: библиотека работает не как косметическая обертка над fetch, а как самостоятельный слой владения server state.

Из каких компонентов состоит React Query

Если упростить внутреннюю архитектуру, то почти все важное держится на пяти сущностях.

QueryClient

Это главный координационный объект. Он знает о query-кэше, mutation-кэше, настройках по умолчанию и операциях вроде invalidateQueries, prefetchQuery и setQueryData.

Когда разработчик пишет queryClient.invalidateQueries({ queryKey: ["todos"] }), он фактически обращается к центральному диспетчеру, который проходит по хранимым query и меняет их состояние.

QueryCache

Это контейнер, в котором хранятся все Query-объекты. Не просто данные, а именно сущности с метаданными:

  • текущий status;
  • fetchStatus;
  • data и error;
  • время последнего успешного обновления;
  • список подписчиков;
  • текущий promise запроса;
  • таймеры устаревания и удаления.

Query

Каждый конкретный queryKey превращается во внутренний Query-объект. Он отвечает за один ресурс и его жизненный цикл. Если три разных компонента вызывают useQuery с одинаковым ключом, внутри это не три независимые загрузки, а одна Query-сущность и несколько подписчиков.

QueryObserver

Observer связывает внутренний кэш с конкретным React-компонентом. Он подписывается на изменения Query и отдает наружу производное состояние: isPending, isFetching, isError, data, error, isStale и так далее.

Это важный момент: компонент не читает QueryCache напрямую. Он работает через observer-слой, который умеет вычислять итоговый снапшот для конкретного потребителя.

Mutation-слой

Отдельно живут MutationCache и mutation-объекты. Их задача другая: не переиспользование чтения, а координация записи, optimistic update, rollback, побочные эффекты и последующая invalidation.

Как выглядит поток одного useQuery

Ниже приведена упрощённая схема жизненного цикла.

  1. Компонент вызывает useQuery.
  2. React Query нормализует queryKey и ищет Query в QueryCache.
  3. Если Query уже существует, создается новый observer и подписывается на текущий объект.
  4. Если Query не существует, он создается и регистрируется в кэше.
  5. Если данные отсутствуют или устарели по правилам политики, стартует queryFn.
  6. Пока запрос выполняется, Query хранит общий промис.
  7. После завершения Query обновляет внутреннее состояние и уведомляет observers.
  8. Компоненты получают новый снимок и перерендериваются только там, где подписка действительно изменилась.

Вот минимальный пример пользовательского кода, за которым скрывается этот конвейер:

import { useQuery } from "@tanstack/react-query";

type Issue = {
  id: string;
  title: string;
  status: "open" | "closed";
};

async function fetchIssues(projectId: string): Promise<Issue[]> {
  const response = await fetch(`/api/projects/${projectId}/issues`);

  if (!response.ok) {
    throw new Error(`Issues request failed: ${response.status}`);
  }

  return response.json();
}

export function IssuesList({ projectId }: { projectId: string }) {
  const issuesQuery = useQuery({
    queryKey: ["issues", projectId],
    queryFn: () => fetchIssues(projectId),
    staleTime: 30_000,
  });

  if (issuesQuery.isPending) return <IssuesSkeleton />;
  if (issuesQuery.isError) return <ErrorMessage text="Не удалось загрузить задачи" />;

  return (
    <ul>
      {issuesQuery.data.map((issue) => (
        <li key={issue.id}>{issue.title}</li>
      ))}
    </ul>
  );
}

Снаружи это выглядит как один хук. Внутри уже живут кэш, подписка, общий promise, вычисление свежести и механизм уведомлений.

Почему одинаковые запросы не дублируются

Одна из причин популярности React Query в том, что библиотека с самого начала проектируется вокруг переиспользования server state. Дедупликация достигается не магией и не особенностью fetch, а архитектурой Query-объекта.

Если два компонента монтируются почти одновременно с одинаковым queryKey, библиотека не создает две независимые загрузки. Она находит один Query, видит, что у него уже есть активный fetch promise, и присоединяет второго подписчика к тому же процессу.

Практический эффект:

  • меньше сетевого шума;
  • предсказуемое состояние экрана;
  • нет гонки между двумя одинаковыми ответами;
  • проще профилировать поведение data layer.

Именно здесь React Query радикально отличается от наивного подхода useEffect + fetch. Если писать этот слой вручную, похожую логику дедупликации и координации нужно придумывать отдельно.

Архитектурный разбор: кто за что отвечает внутри

Чтобы не путаться в поведении библиотеки, полезно держать в голове явные границы ответственности.

Слой кэша

Хранит Query-объекты, их данные, статусы и таймеры. Он не знает про JSX и не должен решать, какой скелетон рисовать.

Слой observers

Связывает кэш и конкретных потребителей. Именно поэтому один и тот же Query может обслуживать несколько мест интерфейса с разной чувствительностью к обновлениям.

Слой уведомлений

React Query группирует обновления и старается не беспокоить React-дерево чаще, чем нужно. На практике это важно там, где один запрос влияет на несколько виджетов одновременно. В этой части тема пересекается с тем, когда React реально перерисовывает компонент: библиотека может уменьшить хаос в data-слое, но не отменяет цену широких подписок и тяжелого UI-дерева.

Слой мутаций

Отвечает за запись, промежуточное локальное состояние, отмену конфликтующих запросов, оптимистичное обновление, откат и последующую синхронизацию через invalidation или setQueryData.

Слой интеграции с React

Хуки useQuery, useMutation, useInfiniteQuery, useSuspenseQuery - это адаптеры. Они удобны, но не являются самой сутью библиотеки. Если понимать это, проще объяснять поведение React Query на интервью и дебажить production-баги.

staleTime и gcTime: два таймера, которые путают чаще всего

Эти настройки часто звучат похоже, но решают разные задачи.

staleTime

Определяет, как долго данные считаются свежими. Пока данные свежие, React Query обычно не будет автоматически перевызывать запрос при повторной подписке или при событиях вроде возврата вкладки в фокус.

gcTime

Определяет, как долго неиспользуемый Query остается в памяти после того, как на него больше никто не подписан. Это уже не про свежесть, а про удержание кэша в памяти.

Простой мысленный пример:

  • staleTime: 30_000 означает: в течение 30 секунд данные считаются свежими;
  • gcTime: 300_000 означает: даже если никто не использует query, он может еще 5 минут жить в кэше на случай быстрого возврата пользователя.

Из-за этой разницы React Query удобно рассматривать как систему с двумя отдельными политиками: политикой актуальности и политикой хранения в памяти. Если смешать их в голове, появляются странные настройки вроде огромного staleTime ради снижения сетевой нагрузки там, где на самом деле хотелось только дольше хранить кэш.

Как React Query обновляет UI после мутации

Здесь библиотека особенно сильна. В простом приложении после POST или PATCH можно просто снова вызвать fetch. На production-экране этого почти всегда недостаточно. Возникают дополнительные вопросы:

  • какие query теперь устарели;
  • какие запросы надо отменить, чтобы не затереть optimistic update;
  • можно ли обновить локальный кэш сразу;
  • нужен ли rollback, если сервер вернул ошибку.

Пример типичной мутации:

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

type Todo = {
  id: string;
  title: string;
  done: boolean;
};

async function toggleTodo(todoId: string): Promise<Todo> {
  const response = await fetch(`/api/todos/${todoId}/toggle`, {
    method: "POST",
  });

  if (!response.ok) {
    throw new Error(`Toggle failed: ${response.status}`);
  }

  return response.json();
}

export function useToggleTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: toggleTodo,
    onMutate: async (todoId: string) => {
      await queryClient.cancelQueries({ queryKey: ["todos"] });

      const previous = queryClient.getQueryData<Todo[]>(["todos"]);

      queryClient.setQueryData<Todo[]>(["todos"], (items = []) =>
        items.map((item) =>
          item.id === todoId ? { ...item, done: !item.done } : item
        )
      );

      return { previous };
    },
    onError: (_error, _todoId, context) => {
      queryClient.setQueryData(["todos"], context?.previous);
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });
}

Что здесь происходит под капотом:

  • mutation-слой создает отдельную сущность записи;
  • перед записью отменяются конфликтующие чтения;
  • кэш query обновляется оптимистически;
  • при ошибке кэш откатывается;
  • после завершения связанные Query помечаются устаревшими и могут refetch-нуться.

Это и есть причина, по которой React Query ближе к оркестрационному слою (orchestration), чем к просто удобному способу загрузки данных. Если нужен контраст с более легкой моделью, его удобно смотреть в сравнении SWR и React Query.

Таблица: что именно React Query берет на себя

КритерийРучной useEffect + fetchSWRReact Query
Дедупликация одинаковых запросовОбычно вручнуюЕстьЕсть
Политика свежести данныхВручнуюБазоваяГибкая
Управление мутациямиВручнуюУмеренноеСильная сторона
Точечная invalidationПочти всегда вручнуюЧастичноДа
Optimistic update и rollbackВручнуюВозможны, но беднееНативный сценарий
Devtools и наблюдаемостьНетСкромнееХорошие
Ментальная модельЛокальная загрузкаResource cacheRuntime для server state

Из таблицы видно, почему React Query окупается не на первом GET, а на экранах, где данные долго живут после чтения и участвуют в нескольких пользовательских сценариях.

Прокачай React за 7 дней

20 вопросов и разборов по React Hooks

Начать

Риски и сложности в продакшене: где непонимание внутренней модели ломает приложение

1. Нестабильные queryKey

Если ключи зависят от случайных объектов или каждый рендер создает новую структуру без ясной семантики, библиотека теряет способность нормально переиспользовать Query. В логах это выглядит как лишние запросы, а в UI - как неожиданные refetch и мерцание загрузки.

2. Копирование server state в локальный store

Одна из самых дорогих ошибок: команда использует React Query, но затем дублирует данные в Zustand, Redux или локальный useState без необходимости. После этого в системе уже два источника истины. Эта проблема подробно стыкуется с разбором state management в React: query cache и клиентский store не должны пытаться быть одним и тем же слоем.

3. Слишком грубая инвалидация

invalidateQueries() по слишком широкому префиксу работает как безопасный, но дорогой молоток. На маленьком проекте это терпимо. На большом дашборде это означает лишние refetch, сетевой шум и ухудшение UX после каждой мутации.

4. Большой staleTime как маскировка проблемы

Иногда команда замечает повторные запросы и просто поднимает staleTime до нескольких минут. Экран становится тише, но stale UI никуда не исчезает. Если данные действительно изменчивы, такое решение только маскирует архитектурную ошибку.

5. Попытка хранить в React Query локальный UI state

Запросный кэш не должен становиться хранилищем для модальных окон, чекбоксов интерфейса, активных вкладок и черновиков формы. Как только query cache начинает обслуживать не server state, его модель становится размытой, а отладка сложнее.

Производительность: где реальное узкое место

Про React Query часто спрашивают: "не слишком ли он тяжелый". Правильный ответ почти всегда зависит от профиля экрана.

Что обычно действительно стоит измерять

  • количество дублированных сетевых запросов;
  • ширину инвалидации после мутации;
  • число ререндеров подписанных компонентов;
  • время восстановления UI после записи;
  • давление на память из-за долгоживущего кэша;
  • поведение при возврате вкладки в фокус и при быстром переключении фильтров.

Где React Query действительно помогает

  • когда несколько частей интерфейса читают один и тот же ресурс;
  • когда много зависимых запросов и мутаций;
  • когда нужен предсказуемый background refetch;
  • когда важны devtools и наблюдаемость data layer.

Где оптимизация преждевременна

Если у вас один экран, один запрос и почти нет повторного использования данных, React Query может быть дороже, чем решаемая проблема. В такой ситуации честнее оставить простой fetch-слой. Но как только приложение дорастает до сложного server state, попытка сэкономить на query-рантайме обычно обходится дороже через месяц, чем в день внедрения. Для полной картины производительности тему полезно рассматривать рядом с React performance profiling.

Практики, которые обычно работают лучше

Архитектурные практики

  • Думайте о queryKey как о контракте, а не как о случайном массиве.
  • Держите server state в query-слое, а UI state - рядом с интерфейсом.
  • Проектируйте инвалидацию одновременно с mutation API, а не после релиза.
  • Явно решайте, когда нужно оптимистичное обновление, а когда достаточно refetch.

Практики кода

  • Выносите queryFn и фабрики ключей в предсказуемые модули.
  • Не создавайте новые объекты в ключах без необходимости.
  • Используйте setQueryData только там, где точно понятна семантика обновления.
  • Не лечите архитектурные ошибки глобальным повышением staleTime.

Практики наблюдаемости

  • Проверяйте поведение через React Query Devtools.
  • Логируйте дорогие мутации и связанные пути инвалидации.
  • Смотрите не только на сеть, но и на число перерендеров подписанных блоков.

Практики тестирования

  • Пишите сценарии для оптимистичного обновления и отката.
  • Проверяйте быстрые повторные переходы между экранами.
  • Тестируйте холодный кэш и тёплый кэш отдельно.

Rollout и rollback

  • Внедряйте сложные кэш-политики поэтапно.
  • Для рискованных optimistic сценариев оставляйте упрощенный запасной вариант через refetch.
  • Не раскатывайте агрессивную инвалидационную логику сразу на весь продукт.

Частые ошибки

  • Считать, что React Query - это просто удобный fetch-хук.
  • Не различать staleTime и gcTime.
  • Хранить локальное состояние интерфейса в query-кэше.
  • Инвалидировать слишком широкий набор ключей после любой мутации.
  • Дублировать server state в другом store "для удобства".
  • Оценивать библиотеку по одному демо-экрану, а не по реальному жизненному циклу данных.

Как отвечать на интервью про React Query

Сильный ответ обычно строится не вокруг пересказа API, а вокруг внутренней модели.

Рабочая формулировка может быть такой:

  1. React Query - это рантайм для server state, а не просто обертка над fetch.
  2. Внутри есть QueryClient, QueryCache, Query-объекты и наблюдатели, поэтому несколько компонентов могут делить один источник данных.
  3. Дедупликация достигается тем, что одинаковые queryKey подписываются на один Query и общий промис.
  4. staleTime отвечает за свежесть данных, а gcTime - за удержание неиспользуемого кэша в памяти.
  5. После мутаций библиотека координирует оптимистичное обновление, откат и инвалидацию, поэтому особенно полезна на экранах со сложным жизненным циклом server state.

Если добавить к этому честный компромисс - например, что для маленького экрана React Query может быть избыточен, - ответ обычно звучит на уровень сильнее.

Практика реальных технических собеседований по React

Разберите React Query, caching, invalidation, Suspense и performance trade-off в форматемок-интервью с разбором сильных ответов

Начать подготовку

FAQ

React Query заменяет Suspense?

Нет. Suspense управляет UX ожидания и границами загрузки, а React Query управляет server state: кэшем, свежестью, refetch и мутациями. Эти механики часто работают вместе. Для отдельного контекста полезен разбор Suspense для данных.

Почему React Query не равен Redux или Zustand?

Потому что он решает другую задачу. Redux и Zustand чаще координируют клиентское состояние приложения, а React Query управляет server state с кэшем, повторными запросами и инвалидацией.

Можно ли использовать React Query в server-first приложении?

Да, но осознанно. Если критические данные уже пришли с сервера, на клиенте React Query часто нужен как слой повторной синхронизации, фонового повторного получения (refetch) или мутаций, а не как единственный источник данных для первого рендера.

Когда лучше выбрать более простой подход?

Когда экран маленький, данные не переиспользуются, нет сложных мутаций и команда сознательно выбирает минимальную сложность. Но важно признать это решением, зависящим от контекста, а не универсальным правилом.

Какая одна мысль лучше всего объясняет React Query?

React Query хранит не просто JSON-ответы, а управляемые сущности server state с жизненным циклом, подписками и политикой актуальности. Именно это делает его полезным на сложных production-экранах.

Итоги

React Query становится понятнее и полезнее, если смотреть на него не как на красивый API, а как на внутреннюю систему управления server state. Под капотом у него есть Query-объекты, кэш, наблюдатели, общий promise для дедупликации, отдельный mutation-слой и явная политика свежести и удержания данных.

Из этого следуют практические выводы. Библиотека хорошо окупается там, где данные переживают первый запрос: разделяются между несколькими экранами, мутируются, инвалидируются и должны оставаться согласованными без ручного склеивания состояний. Если понимать эту модель, React Query проще внедрять, проще профилировать и заметно легче обосновывать на техническом собеседовании.

Больше вопросов в Telegram

Ежедневные разборы и реальные кейсы с интервью

Подписаться

Автор

Lexicon Team

Читайте также