Современные React-библиотеки для управления состоянием данных — не панацея. React Query и SWR автоматизируют кеширование, фоновое обновление и синхронизацию, но слепая вера в их «волшебство» приводит к утечкам памяти, гонкам данных и неоправданной нагрузке на API. Рассмотрим практические проблемы, которые возникают при работе с асинхронными данными в продакшен-приложениях, и способы их решения через призму внутреннего устройства этих инструментов.
Кеш: друг или враг?
Стандартный пример использования React Query выглядит безобидно:
const { data } = useQuery(['todos'], fetchTodos);
Но уже при переходе между роутами обнаруживаются дублирующиеся запросы. Причина — стандартное поведение staleTime: 0
. При мгновенном переходе между компонентами, монтирующими один и тот же ключ запроса, мы получаем несколько параллельных вызовов вместо повторного использования кеша.
Решение: Увеличиваем staleTime
до разумных значений и настраиваем cacheTime
в зависимости от характера данных:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
cacheTime: 5 * 60 * 1000,
},
},
});
Но здесь таится ловушка: для данных с высокой волатильностью (например, рейтинги в реальном времени) увеличение staleTime
приводит к устареванию информации. Глубокая настройка требует анализа конкретных сценариев использования данных.
The Ghost Request Problem: когда запросы не умирают
Классический сценарий:
- Пользователь быстро переходит со страницы A на страницу B
- Запрос со страницы A не успевает завершиться
- После размонтирования компонента запрос продолжает висеть в фоне
React Query по умолчанию отменяет такие запросы через AbortController
, но для кастомных API-клиентов это нужно реализовывать вручную:
const fetchTodos = async ({ signal }) => {
const response = await fetch('/api/todos', { signal });
return response.json();
};
Особое внимание — к POST-запросам в обработчиках отправки форм. Фоновый запрос, оставшийся после перезагрузки компонента, может выполнить мутацию повторно.
Инвалидация как искусство
Автоматическая инвалидация кеша после мутаций — типичный источник ошибок. Рассмотрим цепочку:
const mutation = useMutation(updateTodo, {
onSuccess: () => {
queryClient.invalidateQueries(['todos']);
},
});
Этот подход приводит к:
- Задержке обновления UI до завершения нового запроса
- Возможному мерцанию при быстром интернете
- Избыточной нагрузке при множественных мутациях
Альтернатива — оптимистичные обновления:
useMutation(updateTodo, {
onMutate: async (newTodo) => {
await queryClient.cancelQueries(['todos']);
const prevTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) =>
old.map(todo =>
todo.id === newTodo.id ? { ...todo, ...newTodo } : todo
));
return { prevTodos };
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.prevTodos);
},
});
Важно: такой подход требует идеально синхронизированной логики между клиентом и сервером. Некорректная реализация приводит к рассинхронизации данных.
Query Key Calculus: паттерны для сложных сценариев
Структура ключей запросов определяет гранулярность кеширования. Для пагинированных данных:
useQuery(['todos', { page, pageSize }], () => fetchPage(page, pageSize));
Но при реализации бесконечной ленты предпочтителен подход с накоплением данных:
const { data, fetchNextPage } = useInfiniteQuery(
['todos'],
({ pageParam = 0 }) => fetchPage(pageParam),
{
getNextPageParam: (lastPage) => lastPage.nextPage,
}
);
React Query будет хранить каждую страницу отдельно, но объединять их при обращении через data.pages
. Это предотвращает дублирование при повторных запросах на тех же страницах.
Производительность: скрытые расходы
Каждый useQuery
— это:
- Подписка на изменение кеша
- Сравнение зависимостей через
JSON.stringify
- Обновление компонента при изменениях
Для больших списков запросов (50+) это приводит к заметным лагам. Методы оптимизации:
- Объединение запросов через
useQueries
с ручным управлением:
const results = useQueries(
items.map(item => ({
queryKey: ['item', item.id],
queryFn: () => fetchItem(item.id),
}))
);
- Дедупликация идентичных параллельных запросов через
queryClient.setDefaultOptions
- Отказ от избыточного перерендера через
notifyOnChangeProps: 'tracked'
Рекомендации для архитектуры
- Слой API: Инкапсулируйте все запросы в отдельный модуль с обработкой ошибок, интерцепторами и логированием:
export const api = axios.create();
api.interceptors.response.use(
response => response.data,
error => {
captureException(error);
throw error;
}
);
- Сериализация ключей: Используйте стабильную сериализацию для сложных ключей через
JSON.stringify
с сортировкой ключей:
const stableStringify = (obj) => JSON.stringify(obj, Object.keys(obj).sort());
- Эволюция схемы: Для долгоживущих приложений внедряйте версионирование схемы данных:
queryClient.setQueryData(
['user', userId],
(old) => old && migrateUserSchema(old)
);
Работа с асинхронными данными в React — не просто обёртка вокруг fetch. Это постоянный баланс между свежестью данных, производительностью и сложностью состояния. Инструменты вроде React Query предлагают мощные абстракции, но их эффективное использование требует понимания как машинных циклов, так и бизнес-логики приложения. Заветный «оптимальный» подход всегда контекстуален — он рождается на стыке технических ограничений и продуктовых требований. Тестируйте не только функциональность, но и сценарии с прерванными запросами, плавающим соединением и конкурентным доступом. Помните: данные динамичны, а пользователи непредсказуемы.