Оптимизация запросов данных в React: глубже, чем просто `useQuery`

Современные React-библиотеки для управления состоянием данных — не панацея. React Query и SWR автоматизируют кеширование, фоновое обновление и синхронизацию, но слепая вера в их «волшебство» приводит к утечкам памяти, гонкам данных и неоправданной нагрузке на API. Рассмотрим практические проблемы, которые возникают при работе с асинхронными данными в продакшен-приложениях, и способы их решения через призму внутреннего устройства этих инструментов.

Кеш: друг или враг?

Стандартный пример использования React Query выглядит безобидно:

javascript
const { data } = useQuery(['todos'], fetchTodos);

Но уже при переходе между роутами обнаруживаются дублирующиеся запросы. Причина — стандартное поведение staleTime: 0. При мгновенном переходе между компонентами, монтирующими один и тот же ключ запроса, мы получаем несколько параллельных вызовов вместо повторного использования кеша.

Решение: Увеличиваем staleTime до разумных значений и настраиваем cacheTime в зависимости от характера данных:

javascript
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000,
      cacheTime: 5 * 60 * 1000,
    },
  },
});

Но здесь таится ловушка: для данных с высокой волатильностью (например, рейтинги в реальном времени) увеличение staleTime приводит к устареванию информации. Глубокая настройка требует анализа конкретных сценариев использования данных.

The Ghost Request Problem: когда запросы не умирают

Классический сценарий:

  1. Пользователь быстро переходит со страницы A на страницу B
  2. Запрос со страницы A не успевает завершиться
  3. После размонтирования компонента запрос продолжает висеть в фоне

React Query по умолчанию отменяет такие запросы через AbortController, но для кастомных API-клиентов это нужно реализовывать вручную:

javascript
const fetchTodos = async ({ signal }) => {
  const response = await fetch('/api/todos', { signal });
  return response.json();
};

Особое внимание — к POST-запросам в обработчиках отправки форм. Фоновый запрос, оставшийся после перезагрузки компонента, может выполнить мутацию повторно.

Инвалидация как искусство

Автоматическая инвалидация кеша после мутаций — типичный источник ошибок. Рассмотрим цепочку:

javascript
const mutation = useMutation(updateTodo, {
  onSuccess: () => {
    queryClient.invalidateQueries(['todos']);
  },
});

Этот подход приводит к:

  1. Задержке обновления UI до завершения нового запроса
  2. Возможному мерцанию при быстром интернете
  3. Избыточной нагрузке при множественных мутациях

Альтернатива — оптимистичные обновления:

javascript
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: паттерны для сложных сценариев

Структура ключей запросов определяет гранулярность кеширования. Для пагинированных данных:

javascript
useQuery(['todos', { page, pageSize }], () => fetchPage(page, pageSize));

Но при реализации бесконечной ленты предпочтителен подход с накоплением данных:

javascript
const { data, fetchNextPage } = useInfiniteQuery(
  ['todos'],
  ({ pageParam = 0 }) => fetchPage(pageParam),
  {
    getNextPageParam: (lastPage) => lastPage.nextPage,
  }
);

React Query будет хранить каждую страницу отдельно, но объединять их при обращении через data.pages. Это предотвращает дублирование при повторных запросах на тех же страницах.

Производительность: скрытые расходы

Каждый useQuery — это:

  1. Подписка на изменение кеша
  2. Сравнение зависимостей через JSON.stringify
  3. Обновление компонента при изменениях

Для больших списков запросов (50+) это приводит к заметным лагам. Методы оптимизации:

  1. Объединение запросов через useQueries с ручным управлением:
javascript
const results = useQueries(
  items.map(item => ({
    queryKey: ['item', item.id],
    queryFn: () => fetchItem(item.id),
  }))
);
  1. Дедупликация идентичных параллельных запросов через queryClient.setDefaultOptions
  2. Отказ от избыточного перерендера через notifyOnChangeProps: 'tracked'

Рекомендации для архитектуры

  1. Слой API: Инкапсулируйте все запросы в отдельный модуль с обработкой ошибок, интерцепторами и логированием:
javascript
export const api = axios.create();
api.interceptors.response.use(
  response => response.data,
  error => {
    captureException(error);
    throw error;
  }
);
  1. Сериализация ключей: Используйте стабильную сериализацию для сложных ключей через JSON.stringify с сортировкой ключей:
javascript
const stableStringify = (obj) => JSON.stringify(obj, Object.keys(obj).sort());
  1. Эволюция схемы: Для долгоживущих приложений внедряйте версионирование схемы данных:
javascript
queryClient.setQueryData(
  ['user', userId],
  (old) => old && migrateUserSchema(old)
);

Работа с асинхронными данными в React — не просто обёртка вокруг fetch. Это постоянный баланс между свежестью данных, производительностью и сложностью состояния. Инструменты вроде React Query предлагают мощные абстракции, но их эффективное использование требует понимания как машинных циклов, так и бизнес-логики приложения. Заветный «оптимальный» подход всегда контекстуален — он рождается на стыке технических ограничений и продуктовых требований. Тестируйте не только функциональность, но и сценарии с прерванными запросами, плавающим соединением и конкурентным доступом. Помните: данные динамичны, а пользователи непредсказуемы.

text