Работа с данными: fetch, React Query, кэширование, оптимистичные обновления
В предыдущей статье мы разбирали состояние: где его держать, как не протащить контекст через всё дерево, зачем нужны сторы. Но там речь шла о состоянии, которым владеет ваша вкладка: открыт ли сайдбар, что набрано в поле поиска, какая тема выбрана. Такое состояние синхронно, вы его единственный хозяин, и оно всегда верно по определению.
Данные с сервера устроены иначе, и попытка засунуть их в тот же Redux-стор — самая дорогая архитектурная ошибка среднего фронтенд-проекта. Список заказов, который вы показываете, вам не принадлежит. Он лежит в чужой базе, его прямо сейчас меняет другой пользователь, ваша копия устарела в момент получения, запрос за ней может не дойти, дойти дважды или прийти в неправильном порядке. Всё, что вы держите на клиенте, — это кэш. Не «состояние», а именно кэш: копия чужих данных с неизвестным сроком годности.
Как только вы произносите слово «кэш», из шкафа выпадают все классические проблемы: инвалидация, дедупликация, устаревание, согласованность, сборка мусора. Именно поэтому наивный useEffect(() => { fetch(...) }) в реальном проекте разваливается, а библиотеки вроде TanStack Query существуют и весят больше, чем кажется разумным. Эта статья — про то, что именно они решают, и как это устроено внутри, чтобы вы могли и пользоваться ими осознанно, и написать своё, когда библиотека избыточна.
Серверное состояние не равно клиентскому
Разница не косметическая. Сведём её в таблицу — из неё вытекает вся дальнейшая архитектура.
| Свойство | Клиентское состояние | Серверное состояние |
|---|---|---|
| Владелец | ваша вкладка | удалённый сервис, много писателей |
| Доступ | синхронный | асинхронный, с задержкой и отказами |
| Актуальность | всегда верно | устаревает сразу после получения |
| Дублирование | одно место | одни и те же данные нужны десяти компонентам |
| Сохранность | не нужна | нужна дедупликация, ретраи, отмена |
| Жизненный цикл | пока жив компонент | должно переживать unmount и возвращаться мгновенно |
Практический вывод: серверные данные не «кладут в стор», их кэшируют по ключу, а компонент лишь подписывается на запись кэша. Это меняет модель мышления с «загрузи и положи» на «объяви, какие данные мне нужны, и подпишись на них».
Слой платформы: что на самом деле умеет fetch
Прежде чем звать библиотеку, надо честно понимать базовый API. fetch — часть платформы, а не React, и у него есть три особенности, на которых спотыкаются почти все.
Первая: fetch не считает HTTP-ошибку ошибкой. Промис реджектится только при сетевом сбое, обрыве, нарушении CORS или отмене. Ответ 500 — это успешный Response с ok === false. Забыли проверить res.ok — и catch никогда не сработает, а в состояние уляжется HTML страницы ошибки вместо JSON.
Вторая: у fetch нет таймаута. По умолчанию запрос будет висеть столько, сколько позволит ОС и браузер (десятки секунд). Таймаут делается через AbortSignal.
Третья: тело можно прочитать один раз. res.json() расходует поток; чтобы прочитать текст при ошибке и JSON при успехе, нужно ветвление или res.clone().
Соберём минимальный, но продакшн-годный клиент. Он пригодится независимо от того, какую библиотеку вы возьмёте сверху — все они принимают вашу функцию запроса.
// api/http.ts — тонкая обёртка над fetch: типы, ошибки, таймаут, отмена
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: string, // машиночитаемый код от бэкенда
readonly details: unknown, // тело ответа: ошибки валидации полей и т.п.
) {
super(`HTTP ${status} ${code}`);
this.name = 'ApiError';
}
// 4xx повторять бессмысленно — кроме 408 и 429
get retryable(): boolean {
return this.status >= 500 || this.status === 408 || this.status === 429;
}
}
const BASE = import.meta.env.VITE_API_URL ?? '/api';
export async function request<T>(
path: string,
init: RequestInit & { timeoutMs?: number } = {},
): Promise<T> {
const { timeoutMs = 10_000, signal, ...rest } = init;
// AbortSignal.any объединяет отмену «сверху» (уход со страницы)
// и собственный таймаут. Поддержка — все актуальные браузеры и Node 20+.
const timeout = AbortSignal.timeout(timeoutMs);
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
const res = await fetch(`${BASE}${path}`, {
...rest,
signal: combined,
credentials: 'include', // куки сессии; требует Access-Control-Allow-Credentials
headers: {
Accept: 'application/json',
...(rest.body ? { 'Content-Type': 'application/json' } : {}),
...rest.headers,
},
});
if (!res.ok) {
// Тело ошибки может быть не-JSON (nginx отдал HTML) — защищаемся
const body = await res.json().catch(() => null);
throw new ApiError(res.status, body?.code ?? 'unknown', body);
}
// 204 No Content: тела нет, res.json() бросит SyntaxError
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
Про типизацию: возвращать Promise<T> по вере в дженерик — это ложное чувство безопасности, сервер может прислать что угодно. В проде поверх ставят рантайм-валидацию схемой (Zod, Valibot, ArkType) — тот же приём мы применим к формам в следующей статье. Подробно про сами типы и дженерики — трек TypeScript.
Ретраи: не «повторить три раза», а экспонента с джиттером
Наивный ретрай в цикле превращает деградацию сервиса в отказ: тысяча клиентов одновременно бьют повторами в одну и ту же секунду. Нужны экспоненциальная задержка и случайный разброс.
export async function withRetry<T>(
fn: (signal: AbortSignal) => Promise<T>,
{ attempts = 3, baseMs = 300, signal }: { attempts?: number; baseMs?: number; signal?: AbortSignal } = {},
): Promise<T> {
let lastError: unknown;
for (let i = 0; i < attempts; i++) {
try {
return await fn(signal ?? new AbortController().signal);
} catch (err) {
// Отмену пользователем повторять нельзя — это не сбой
if (err instanceof DOMException && err.name === 'AbortError') throw err;
if (err instanceof ApiError && !err.retryable) throw err;
lastError = err;
if (i === attempts - 1) break;
// 300, 600, 1200 мс + до 50% случайного разброса против «эффекта стада»
const delay = baseMs * 2 ** i * (1 + Math.random() * 0.5);
await new Promise((r) => setTimeout(r, delay));
}
}
throw lastError;
}
Важное ограничение: повторять можно только идемпотентные запросы. GET, PUT, DELETE — безопасно. POST «создать заказ» при ретрае создаст два заказа, если бэкенд не поддерживает ключ идемпотентности (Idempotency-Key в заголовке). Это не фронтендерская мелочь, а контракт с бэкендом; механика разобрана в статье Идемпотентность и доставка.
Наивный useEffect и его пять багов
Вот код, который пишут в каждом туториале и который нельзя выпускать в прод.
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
request<User>(`/users/${userId}`)
.then(setUser) // ← гонка
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <Spinner />;
return <h1>{user!.name}</h1>;
}
Что здесь сломано:
- Гонка ответов. Пользователь быстро переключил профиль: ушли два запроса, ответы вернулись в обратном порядке — на экране данные предыдущего пользователя.
- Нет отмены. Компонент размонтировался, запрос продолжает жить и жечь трафик.
- Нет дедупликации. Три компонента на странице просят одного и того же пользователя — три одинаковых запроса.
- Нет кэша. Ушли на другую страницу и вернулись — снова спиннер, хотя данные были в памяти секунду назад.
- Ошибки не обработаны.
user!рухнет, если запрос упал;loadingпри этом ужеfalse.
Плюс шестое, поверх: в StrictMode эффект выполняется дважды, и разработчики обычно «чинят» это отключением StrictMode вместо исправления гонки.
Гонка — самое коварное, потому что воспроизводится только на медленной сети. Вот она на диаграмме.
хотя выбран user=2
Минимальное честное исправление — флаг актуальности и отмена:
useEffect(() => {
const ac = new AbortController();
let stale = false; // защита от «поздний ответ перезаписал свежий»
setLoading(true);
request<User>(`/users/${userId}`, { signal: ac.signal })
.then((data) => { if (!stale) setUser(data); })
.catch((err) => { if (!stale && err.name !== 'AbortError') setError(err); })
.finally(() => { if (!stale) setLoading(false); });
return () => { stale = true; ac.abort(); }; // cleanup при смене userId и при unmount
}, [userId]);
Гонка закрыта, отмена есть. Но дедупликация, кэш, фоновое обновление и ретраи — по-прежнему нет. Именно этот набор и составляет предмет библиотек.
Мини-кэш на 60 строк: как устроен любой data-layer
Чтобы React Query перестал быть магией, соберём его ядро. Идея простая: кэш — это Map от ключа к записи, компоненты подписываются на записи, а useSyncExternalStore связывает внешний стор с рендером React.
// tiny-query.ts — учебная модель кэша запросов
import { useCallback, useEffect, useSyncExternalStore } from 'react';
type Entry<T> = {
data?: T;
error?: unknown;
status: 'pending' | 'success' | 'error';
updatedAt: number;
promise?: Promise<void>; // in-flight: для дедупликации
listeners: Set<() => void>;
};
const cache = new Map<string, Entry<unknown>>();
function getEntry<T>(key: string): Entry<T> {
let e = cache.get(key) as Entry<T> | undefined;
if (!e) {
e = { status: 'pending', updatedAt: 0, listeners: new Set() };
cache.set(key, e as Entry<unknown>);
}
return e;
}
function notify(e: Entry<unknown>) {
e.listeners.forEach((l) => l());
}
export function fetchQuery<T>(key: string, fn: () => Promise<T>, staleTime = 0): Promise<void> {
const e = getEntry<T>(key);
const fresh = e.status === 'success' && Date.now() - e.updatedAt < staleTime;
if (fresh) return Promise.resolve();
if (e.promise) return e.promise; // дедупликация: один запрос на ключ
e.promise = fn().then(
(data) => { e.data = data; e.status = 'success'; e.updatedAt = Date.now(); },
(error) => { e.error = error; e.status = 'error'; },
).finally(() => { e.promise = undefined; notify(e as Entry<unknown>); });
return e.promise;
}
export function useQuery<T>(key: string, fn: () => Promise<T>, staleTime = 0) {
const e = getEntry<T>(key);
const subscribe = useCallback((cb: () => void) => {
e.listeners.add(cb);
return () => { e.listeners.delete(cb); }; // здесь же живёт логика gcTime
}, [key]);
// useSyncExternalStore — корректная подписка на внешний источник,
// без разрывов (tearing) в конкурентном рендеринге React 18+
const snapshot = useSyncExternalStore(subscribe, () => cache.get(key));
useEffect(() => { fetchQuery(key, fn, staleTime); }, [key]);
return { data: snapshot?.data as T | undefined, status: snapshot?.status ?? 'pending' };
}
Шестьдесят строк уже дают дедупликацию, общий кэш и корректную подписку. Остальные тридцать килобайт TanStack Query — это ретраи, окна фокуса, онлайн-статус, сборка мусора, бесконечные списки, оптимистичные обновления, дегидратация для SSR, structural sharing и devtools. Каждый из этих пунктов вы бы всё равно написали сами, только хуже.
TanStack Query: модель, а не рецепты
Библиотека (бывшая React Query, актуальна v5) строится на трёх понятиях: queryKey, queryFn, QueryClient.
queryKey — это адрес записи в кэше и одновременно её зависимости. Массив сериализуется детерминированно (порядок ключей в объектах не важен), поэтому в ключ кладут всё, что влияет на результат.
import { QueryClient, QueryClientProvider, useQuery, queryOptions } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // 30 с считаем данные свежими; дефолт 0 — слишком агрессивно
gcTime: 5 * 60_000, // 5 мин держим в памяти после ухода последнего подписчика
retry: (count, err) => err instanceof ApiError && !err.retryable ? false : count < 2,
refetchOnWindowFocus: true, // спорная опция: полезна в дашбордах, раздражает в формах
},
},
});
// queryOptions — типобезопасная фабрика; один источник правды для useQuery,
// prefetchQuery, setQueryData и useSuspenseQuery
export const userQuery = (id: string) =>
queryOptions({
queryKey: ['users', id] as const,
queryFn: ({ signal }) => request<User>(`/users/${id}`, { signal }), // отмена бесплатно
});
function UserProfile({ userId }: { userId: string }) {
const { data, status, error, isFetching } = useQuery(userQuery(userId));
if (status === 'pending') return <ProfileSkeleton />;
if (status === 'error') return <ErrorBox error={error} />;
return (
<>
<h1>{data.name}</h1>
{isFetching && <span className="hint">обновляем…</span>}
</>
);
}
Все пять багов наивного варианта закрыты одной строкой useQuery. Обратите внимание на signal — библиотека сама отменяет устаревшие запросы, и гонка невозможна структурно: результат кладётся по ключу, а компонент читает актуальную запись, а не результат своего промиса.
Ключевое различие: status против fetchStatus
Главный источник путаницы в v5 — два независимых поля. status отвечает «есть ли у меня данные», fetchStatus — «идёт ли прямо сейчас сеть».
| Комбинация | Что показывать |
|---|---|
status: 'pending' + fetchStatus: 'fetching' |
первый вход: скелетон |
status: 'success' + fetchStatus: 'fetching' |
данные есть, обновляем фоном: старые данные + тонкий индикатор |
status: 'success' + fetchStatus: 'idle' |
обычное состояние |
status: 'error' + fetchStatus: 'fetching' |
ретрай после ошибки |
любой + fetchStatus: 'paused' |
оффлайн, запрос ждёт сети |
Отсюда правило UX: спиннер на весь экран рисуют только при pending. Фоновое обновление не должно мигать — иначе каждый возврат на вкладку выглядит как перезагрузка страницы.
Жизненный цикл записи кэша
Два таймера — staleTime и gcTime — тикают независимо и отвечают на разные вопросы. Их путают чаще всего:
Практическая калибровка staleTime:
- 0 (дефолт) — данные меняются постоянно и цена лишнего запроса нулевая: счётчик уведомлений, лента.
- 30–60 с — обычный CRUD: список заказов, карточка товара. Разумный дефолт для всего приложения.
- 5–30 мин — справочники: список стран, категорий, валют.
Infinity— данные меняются только через вашу же мутацию: настройки текущего пользователя, права. Обновляются явной инвалидацией.
Инструменты, которые закрывают остальные 20% задач
// 1. select — трансформация без лишних ререндеров: компонент перерисуется,
// только если изменился результат select, а не весь ответ
const count = useQuery({ ...ordersQuery(), select: (orders) => orders.length });
// 2. placeholderData — сохраняем предыдущую страницу вместо скелетона при пагинации
import { keepPreviousData } from '@tanstack/react-query';
const page = useQuery({
queryKey: ['orders', { page: p }],
queryFn: ({ signal }) => request<Page<Order>>(`/orders?page=${p}`, { signal }),
placeholderData: keepPreviousData, // список не «схлопывается» при переходе
});
// 3. useQueries — параллельные запросы вместо водопада
const results = useQueries({ queries: ids.map((id) => userQuery(id)) });
// 4. useInfiniteQuery — бесконечная лента с курсорами
const feed = useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam, signal }) => request<Page<Post>>(`/feed?cursor=${pageParam}`, { signal }),
initialPageParam: '',
getNextPageParam: (last) => last.nextCursor ?? undefined, // undefined = конец
maxPages: 5, // не держим в памяти сотню страниц — иначе лента съест RAM и рендер
});
// 5. Предзагрузка по наведению: данные приезжают, пока палец идёт к кнопке
<Link
to={`/users/${id}`}
onMouseEnter={() => queryClient.prefetchQuery(userQuery(id))}
/>
Для SSR данные, собранные на сервере, переносятся в клиентский кэш дегидратацией — это убирает «двойную загрузку», когда сервер отрисовал список, а браузер тут же запросил его снова. Механика рендеринга и гидратации разобрана в статье Роутинг и стратегии рендеринга; здесь достаточно знать конструкцию:
// server: prefetch → dehydrate → отдать сериализованный кэш в HTML
const qc = new QueryClient();
await qc.prefetchQuery(userQuery(id));
const state = dehydrate(qc);
// client: обернуть дерево, кэш «оживает» без единого запроса
<HydrationBoundary state={state}><App /></HydrationBoundary>
Водопады запросов — главная причина «тормозит»
Кэш решает повторные заходы. Первый заход упирается в другое: request waterfall — цепочку последовательных запросов, где каждый следующий начинается только после предыдущего.
Водопад возникает не от глупости, а от компонентной композиции: <Profile> грузит пользователя, внутри него <Orders> грузит заказы, внутри — <OrderItems>. Каждый уровень монтируется только после того, как отрисовался родитель. На локальной машине с задержкой 5 мс это незаметно, на 4G с RTT 150 мс — это лишние полторы секунды до LCP.
Как ломать водопады, по возрастанию усилий:
- Поднять запросы вверх и распараллелить —
useQueriesв родителе вместо четырёх вложенныхuseQuery. - Prefetch на границе роутинга — загрузчики маршрутов (React Router
loader, TanStack Routerloader) стартуют запросы одновременно с загрузкой чанка страницы, а не после его рендера. - Агрегация на бэкенде — один endpoint вместо четырёх, GraphQL или BFF-слой. Про выбор стиля API — Стили API.
- Серверные компоненты — запросы выполняются рядом с базой, RTT падает с 150 мс до 2 мс, а водопад из четырёх шагов становится незаметным.
Как увидеть водопад: DevTools → Network, включите колонку Waterfall и посмотрите на ступеньки. Параллельные запросы — вертикальный столбик, водопад — лесенка. Дополнительно полезен фильтр Fetch/XHR и троттлинг «Slow 4G»: без него вы физически не увидите проблему.
Мутации: изменить данные и починить кэш
Запись отличается от чтения тем, что после неё кэш заведомо неверен. Библиотека не знает, какие ключи затронула ваша мутация, — это решаете вы.
обновлённый объект?"} B -- нет --> C["invalidateQueries
по затронутым ключам"] B -- да --> D{"Ответ содержит
ВСЕ поля сущности?"} D -- нет --> C D -- да --> E["setQueryData: кладём объект в кэш
без сетевого запроса"] E --> F{"Есть списки,
где эта сущность видна?"} F -- да --> G["invalidateQueries списков
сортировка и фильтры могли измениться"] F -- нет --> H["Готово: 0 лишних запросов"] C --> I["Активные запросы перезапрашиваются сразу,
неактивные — при следующем монтаже"]
function useCreateComment(postId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (text: string) =>
request<Comment>(`/posts/${postId}/comments`, {
method: 'POST',
body: JSON.stringify({ text }),
headers: { 'Idempotency-Key': crypto.randomUUID() }, // защита от двойной отправки
}),
onSuccess: (created) => {
// Точечно: кладём созданный объект в кэш деталей
qc.setQueryData(['comments', created.id], created);
// И помечаем списки устаревшими — их пересортирует сервер
qc.invalidateQueries({ queryKey: ['posts', postId, 'comments'] });
},
});
}
Важная тонкость: invalidateQueries({ queryKey: ['posts'] }) работает по префиксу. Ключ ['posts', postId, 'comments'] под него попадёт. Поэтому ключи проектируют иерархически: от общего к частному — ['orders'] → ['orders', 'list', filters] → ['orders', 'detail', id]. Это фактически схема адресации, и её стоит вынести в один модуль queryKeys.ts, иначе через полгода никто не сможет ответить, что именно инвалидирует конкретный вызов.
Оптимистичные обновления
Идея: не ждать сервер. Пользователь нажал «лайк» — сердечко закрашивается мгновенно, запрос уходит фоном, а если он упадёт — интерфейс откатывается. Психологически это разница между «приложение живое» и «приложение подтормаживает»: 200 мс ожидания заметны, 0 мс — нет.
иначе они перезапишут наш прогноз C->>C: snapshot = getQueryData (для отката) C->>C: setQueryData — рисуем ожидаемый результат Note over U: UI обновился за 0 мс C->>S: POST /posts/1/like alt успех S-->>C: 200 OK C->>C: invalidateQueries — сверяемся с истиной else ошибка S-->>C: 500 / сеть недоступна C->>C: setQueryData(snapshot) — откат C->>U: тост «не удалось, попробуйте ещё раз» end
Канонический код на четырёх колбэках:
function useToggleLike(postId: string) {
const qc = useQueryClient();
const key = ['posts', postId] as const;
return useMutation({
mutationFn: (liked: boolean) =>
request<void>(`/posts/${postId}/like`, { method: liked ? 'POST' : 'DELETE' }),
// 1. До запроса: применяем прогноз и готовим путь отката
onMutate: async (liked) => {
// Обязательно: иначе фоновый рефетч вернёт старое значение поверх нашего
await qc.cancelQueries({ queryKey: key });
const previous = qc.getQueryData<Post>(key);
qc.setQueryData<Post>(key, (old) =>
old ? { ...old, liked, likes: old.likes + (liked ? 1 : -1) } : old,
);
return { previous }; // это context для onError
},
// 2. Ошибка: возвращаем ровно тот снимок, который был до нашего вмешательства
onError: (_err, _vars, context) => {
if (context?.previous) qc.setQueryData(key, context.previous);
toast.error('Не удалось поставить лайк');
},
// 3. В любом исходе: сверяем прогноз с реальностью
// (другой пользователь мог лайкнуть одновременно)
onSettled: () => {
qc.invalidateQueries({ queryKey: key });
},
});
}
Три ошибки, которые убивают этот паттерн:
- Забыли
cancelQueries. Летевший рефетч приземляется через 50 мс после вашегоsetQueryDataи стирает прогноз — сердечко мигает и гаснет. - Откатывают «обратной операцией» (
likes - 1) вместо восстановления снимка. При двух параллельных мутациях счётчик уезжает. Всегда храните снимок. invalidateQueriesтолько вonSuccess. После ошибки кэш остаётся расходиться с сервером до следующего фокуса окна.
В v5 есть более простой вариант для одиночных элементов — без записи в кэш вообще. Мутация сама хранит variables и isPending, и вы рисуете прогноз прямо в разметке:
const { mutate, isPending, variables } = useAddTodo();
// показываем «призрачную» строку, пока запрос летит
{isPending && <TodoRow title={variables.title} pending />}
Это заметно безопаснее: кэш не трогается, откат не нужен вовсе — при ошибке призрачная строка просто исчезает.
Когда оптимизм противопоказан. Оптимистичное обновление — обещание пользователю от вашего имени. Нарушать его нельзя там, где цена ошибки высока: платежи и переводы, необратимые удаления, операции с квотами и остатками («последний билет»), любые действия, где сервер имеет право отказать по бизнес-правилу, о котором клиент не знает. Признак-индикатор: если вы не можете предсказать результат операции с вероятностью выше 99%, показывайте честную загрузку.
Слои кэша: почему «я же сбросил кэш», а данные старые
Кэш запросов — только один из семи слоёв между компонентом и строкой в базе. Инвалидация работает лишь в том слое, которым вы управляете.
Самая частая ловушка — HTTP-кэш браузера. invalidateQueries заставит библиотеку сделать fetch, но если сервер прислал Cache-Control: max-age=3600, браузер вернёт ответ с диска, даже не выйдя в сеть. Симптом: «в DevTools запрос есть, размер (disk cache), данные старые». Лечится на сервере:
Cache-Control: no-store # приватные данные пользователя: не кэшировать нигде
Cache-Control: private, max-age=0, must-revalidate # кэшировать, но всегда переспрашивать
ETag: "a3f9c1" # валидатор: браузер пришлёт If-None-Match → 304 без тела
Cache-Control: public, max-age=60, stale-while-revalidate=600 # для CDN: отдаём старое, обновляем фоном
stale-while-revalidate на уровне HTTP — та же идея, что staleTime в React Query, только этажом ниже. Хорошая архитектура использует оба уровня согласованно: справочники кэшируются на CDN на часы, персональные данные ходят с no-store и живут в памяти вкладки. Подробнее про серверные слои — Кэширование и масштабирование.
Почему тормозит и как это измерять
«Тормозит загрузка данных» — это четыре разных диагноза, и лечатся они по-разному. Разделять их надо измерением, а не интуицией.
1. Долгий сервер (высокий TTFB). Смотрим Server-Timing — бэкенд может прислать разбивку прямо в заголовке, и она видна и в DevTools, и из JS:
// Читаем реальные тайминги ресурса из Resource Timing API
const [entry] = performance.getEntriesByName(url) as PerformanceResourceTiming[];
console.table({
ожиданиеОчереди: entry.requestStart - entry.startTime, // очередь + DNS + TLS
TTFB: entry.responseStart - entry.requestStart, // работа сервера
загрузкаТела: entry.responseEnd - entry.responseStart, // размер и канал
сжатыйРазмер: entry.encodedBodySize,
распакованный: entry.decodedBodySize,
серверныеФазы: entry.serverTiming, // требует Timing-Allow-Origin для кросс-доменных
});
2. Толстый ответ. 2 МБ JSON на 4G — это ~4 секунды только на передачу, плюс блокирующий главный поток JSON.parse. Парсинг 5 МБ — это 100–300 мс long task, который напрямую бьёт по INP. Лечится пагинацией, полями (?fields=id,title), сжатием (brotli), а если данные действительно большие — переносом парсинга в Web Worker.
3. Водопад. См. выше: лесенка в Network. Влияет в первую очередь на LCP, потому что главный контент ждёт последнего запроса цепочки.
4. Рендер после данных. Данные пришли за 100 мс, но React рисует таблицу на 5000 строк 600 мс. Это уже не проблема загрузки — идём в React DevTools Profiler и виртуализируем список.
Соберите базовые метрики в проде — синтетика в Lighthouse не покажет реальных пользователей на дешёвых Android:
import { onLCP, onINP, onCLS } from 'web-vitals';
const send = (m: { name: string; value: number; rating: string }) =>
navigator.sendBeacon('/rum', JSON.stringify(m)); // sendBeacon переживает уход со страницы
onLCP(send); onINP(send); onCLS(send);
И отдельная связка данных с CLS: скелетон обязан занимать ровно столько же места, сколько итоговый контент. Скелетон в три строки, сменившийся карточкой в семь строк, — это сдвиг макета, который считается вам в метрику. Проще всего резервировать место через min-height или aspect-ratio. Подробнее про бюджеты и метрики — Производительность фронтенда.
Реальное время: polling, SSE, WebSocket
Не всё нужно тянуть по кнопке. Три варианта по возрастанию сложности:
// 1. Поллинг — 90% случаев. Дёшево, надёжно, работает через любые прокси.
useQuery({
...jobStatusQuery(id),
refetchInterval: (query) => (query.state.data?.done ? false : 3000), // остановка по условию
refetchIntervalInBackground: false, // не жечь батарею на скрытой вкладке
});
// 2. SSE / WebSocket — не заменяют кэш, а питают его.
useEffect(() => {
const es = new EventSource('/events');
es.addEventListener('order.updated', (e) => {
const order: Order = JSON.parse(e.data);
qc.setQueryData(['orders', 'detail', order.id], order); // точечное обновление
qc.invalidateQueries({ queryKey: ['orders', 'list'] }); // списки пусть пересчитает сервер
});
return () => es.close();
}, [qc]);
Ключевая мысль: сокет — это транспорт, а не хранилище. Не заводите параллельный стор «данные из сокета»; пишите в тот же кэш, и весь UI обновится сам. И помните про переподключение: после разрыва SSE вы пропустили события — на open нужно инвалидировать всё, что могло измениться.
Чем заменить React Query: честное сравнение
Как выбирать без фанатизма:
- Голый
fetchв эффекте — оправдан ровно в двух случаях: один-два запроса на всё приложение или вы уже используете загрузчики роутера. Во всех остальных вы пишете свой React Query, только без тестов. - SWR — легче (~4 КБ), API минималистичнее, отлично для чтения. Мутаций и оптимистичных сценариев ощутимо меньше «из коробки».
- TanStack Query — де-факто стандарт для REST. Есть порты на Vue, Svelte, Solid и Angular, так что знание переносится.
- RTK Query — рационален, если у вас уже Redux Toolkit: генерирует хуки из описания эндпоинтов, кэш живёт в том же сторе, отдельная сущность не появляется. Проигрывает в гибкости ключей и бесконечных списках.
- Apollo Client / urql — только для GraphQL. Apollo даёт нормализованный кэш (одна сущность — одна запись, обновилась в одном месте — обновилась везде), но платите весом (~35 КБ), сложностью настройки политик и специфичной отладкой. urql легче и проще, нормализация — опциональный пакет.
- tRPC — end-to-end типы без кодогенерации, но требует TypeScript-монолита: один репозиторий, общий тип роутера. Идеален для продуктовых команд, невозможен, если бэкенд на Go или Java.
- Загрузчики роутера — данные начинают грузиться до рендера компонента, водопад ломается архитектурно. Отлично сочетаются с кэшем:
loaderвызываетqueryClient.ensureQueryData, компонент читает тот же ключ черезuseQuery.
Важный водораздел: нормализованный кэш против документного. TanStack Query кэширует ответы целиком («документами») — просто и предсказуемо, но одна сущность лежит в десяти ответах, и обновлять её приходится инвалидацией. Apollo и Relay нормализуют по id — обновление приезжает всюду само, но платят сложностью политик слияния и загадочными багами вида «кэш вернул объект без поля, потому что фрагмент его не запрашивал». Для типичного продукта документный кэш плюс аккуратные ключи — правильный компромисс.
Типичные ошибки
- Копировать
dataвuseState. Появляется вторая копия истины, она немедленно расходится с кэшем. Производные значения считайте на рендере или черезselect. - Класть в
queryKeyне всё, от чего зависит запрос. Забыли фильтр — получаете чужие данные под своим ключом. Правило: если переменная используется внутриqueryFn, она обязана быть в ключе. - Нестабильные ключи.
queryKey: ['users', {}]создаёт новый объект каждый рендер — но библиотека сравнивает структурно, так что это как раз безопасно; опасныDate.now()и несортированные массивы. staleTime: 0по умолчанию плюсrefetchOnWindowFocus. Каждое переключение вкладки — шквал запросов. Поставьте разумныйstaleTimeглобально.- Ретраить POST без ключа идемпотентности. Дубли заказов на проде — классика.
- Показывать спиннер при фоновом обновлении. Отличайте
pendingотisFetching, иначе интерфейс мигает при каждом возврате на вкладку. - Оптимизм там, где сервер может отказать. Списание баллов, бронирование последнего места, платёж — только честное ожидание.
- Игнорировать оффлайн.
fetchStatus: 'paused'существует именно для этого: покажите «нет сети», а не вечный скелетон. - Хранить в кэше запросов то, что не приходит с сервера. Открыт ли модал — это клиентское состояние, ему место в
useStateили сторе.
Мини-итог
Данные с сервера — это не состояние, а кэш чужих данных, и все сложности растут отсюда. Голый fetch даёт транспорт, но не решает ни гонок, ни отмены, ни дедупликации, ни повторного входа без спиннера: наивный useEffect содержит пять багов, из которых гонка ответов воспроизводится только на медленной сети. Любой data-layer — это Map от ключа к записи плюс подписка через useSyncExternalStore; шестьдесят строк дают дедупликацию и общий кэш, остальное в библиотеке — ретраи, окна фокуса, оффлайн, бесконечные списки, дегидратация. В TanStack Query смысл сосредоточен в трёх вещах: иерархический queryKey как адрес и как единица инвалидации, staleTime как ответ на «идти ли в сеть» и gcTime как ответ на «когда освободить память» — путаница между ними даёт либо лишние запросы, либо мигающие скелетоны. Мутации всегда сопровождаются решением, что делать с кэшем: точечный setQueryData, если сервер вернул полный объект, и invalidateQueries по префиксу для списков. Оптимистичные обновления держатся на четырёх шагах — отменить летящие запросы, снять снимок, применить прогноз, откатиться на снимок при ошибке — и запрещены там, где сервер имеет право сказать «нет». Наконец, «тормозит» почти никогда не значит «медленный fetch»: это либо водопад из вложенных компонентов, либо толстый ответ с блокирующим JSON.parse, либо рендер после данных, — и различить их можно только по Network waterfall, Resource Timing и полевым Core Web Vitals.
Источники
- TanStack Query — Overview и Guides, особенно Caching, Optimistic Updates, Query Invalidation
- Dominik Dorfmeister (TkDodo), Practical React Query — цикл статей мейнтейнера, лучший источник по теме
- MDN: Using the Fetch API, AbortSignal
- MDN: HTTP caching и RFC 9111
- web.dev: stale-while-revalidate, Server-Timing
- react.dev: You Might Not Need an Effect и useSyncExternalStore
- SWR, RTK Query, Apollo Client caching
- web-vitals — сбор LCP, INP и CLS с реальных пользователей
Что дальше
Читать данные научились. Обратное направление — отправка данных пользователем — своя большая тема: контролируемые и неконтролируемые поля, валидация схемой на клиенте и её согласование с серверной, дебаунс, состояния «отправляется / отправлено / отклонено сервером» и то, как показать ошибку так, чтобы человек понял, что именно исправить.