React Query: как работает под капотом и почему это важно в production
Разбираем React Query под капотом: QueryClient, кэш, observers, дедупликацию, invalidation, мутации и реальные production trade-off.
- Введение
- Короткий ответ: что такое React Query на уровне архитектуры
- Из каких компонентов состоит React Query
- QueryClient
- QueryCache
- Query
- QueryObserver
- Mutation-слой
- Как выглядит поток одного useQuery
- Почему одинаковые запросы не дублируются
- Архитектурный разбор: кто за что отвечает внутри
- Слой кэша
- Слой observers
- Слой уведомлений
- Слой мутаций
- Слой интеграции с React
- staleTime и gcTime: два таймера, которые путают чаще всего
- staleTime
- gcTime
- Как React Query обновляет UI после мутации
- Таблица: что именно React Query берет на себя
- Риски и сложности в продакшене: где непонимание внутренней модели ломает приложение
- 1. Нестабильные queryKey
- 2. Копирование server state в локальный store
- 3. Слишком грубая инвалидация
- 4. Большой staleTime как маскировка проблемы
- 5. Попытка хранить в React Query локальный UI state
- Производительность: где реальное узкое место
- Что обычно действительно стоит измерять
- Где React Query действительно помогает
- Где оптимизация преждевременна
- Практики, которые обычно работают лучше
- Архитектурные практики
- Практики кода
- Практики наблюдаемости
- Практики тестирования
- Rollout и rollback
- Частые ошибки
- Как отвечать на интервью про React Query
- FAQ
- React Query заменяет Suspense?
- Почему React Query не равен Redux или Zustand?
- Можно ли использовать React Query в server-first приложении?
- Когда лучше выбрать более простой подход?
- Какая одна мысль лучше всего объясняет React Query?
- Итоги
Введение
Тему 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
Ниже приведена упрощённая схема жизненного цикла.
- Компонент вызывает
useQuery. - React Query нормализует
queryKeyи ищет Query вQueryCache. - Если Query уже существует, создается новый observer и подписывается на текущий объект.
- Если Query не существует, он создается и регистрируется в кэше.
- Если данные отсутствуют или устарели по правилам политики, стартует
queryFn. - Пока запрос выполняется, Query хранит общий промис.
- После завершения Query обновляет внутреннее состояние и уведомляет observers.
- Компоненты получают новый снимок и перерендериваются только там, где подписка действительно изменилась.
Вот минимальный пример пользовательского кода, за которым скрывается этот конвейер:
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 + fetch | SWR | React Query |
|---|---|---|---|
| Дедупликация одинаковых запросов | Обычно вручную | Есть | Есть |
| Политика свежести данных | Вручную | Базовая | Гибкая |
| Управление мутациями | Вручную | Умеренное | Сильная сторона |
| Точечная invalidation | Почти всегда вручную | Частично | Да |
| Optimistic update и rollback | Вручную | Возможны, но беднее | Нативный сценарий |
| Devtools и наблюдаемость | Нет | Скромнее | Хорошие |
| Ментальная модель | Локальная загрузка | Resource cache | Runtime для 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, а вокруг внутренней модели.
Рабочая формулировка может быть такой:
- React Query - это рантайм для server state, а не просто обертка над
fetch. - Внутри есть
QueryClient,QueryCache, Query-объекты и наблюдатели, поэтому несколько компонентов могут делить один источник данных. - Дедупликация достигается тем, что одинаковые
queryKeyподписываются на один Query и общий промис. staleTimeотвечает за свежесть данных, аgcTime- за удержание неиспользуемого кэша в памяти.- После мутаций библиотека координирует оптимистичное обновление, откат и инвалидацию, поэтому особенно полезна на экранах со сложным жизненным циклом 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
Читайте также
frontend
SWR vs React Query: что выбрать для React-приложения в 2026
Сравнение SWR и React Query на практике: кэш, мутации, invalidation, SSR, производительность и критерии выбора для production и собеседования.
frontend
React data fetching паттерны: как выбрать подход без waterfalls и stale UI
Разбираем React data fetching паттерны: useEffect, query-библиотеки, route loaders, Suspense и server-first подход. Где какой паттерн уместен, какие trade-off важны и что отвечать на собеседовании.
frontend
React caching стратегии: как выбрать слой кэша и не сломать данные
Разбираем React caching стратегии: memoization, query cache, HTTP cache, SSR/RSC cache, invalidation, performance trade-off и типичные production-ошибки.