Оптимизация серверного состояния в React-приложениях: паттерны и анти-паттерны с React Query

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

Декларативное управление данными

React Query вводит концепцию QueryClient — центрального хранилища серверного состояния с автоматической синхронизацией. Вместо ручного вызова fetch в useEffect:

jsx
// Проблемный подход
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(false);

useEffect(() => {
  setIsLoading(true);
  fetch('/api/data')
    .then(res => res.json())
    .then(setData)
    .finally(() => setIsLoading(false));
}, []);

Используем декларативный запрос:

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

const { data, isLoading, error } = useQuery({
  queryKey: ['data'],
  queryFn: () => fetch('/api/data').then(res => res.json())
});

Ключевое отличие: React Query автоматически обрабатывает кэширование, повторные запросы при восстанавливаемом соединении и дублирующиеся вызовы. Однако даже эта абстракция требует глубокого понимания её внутренней механики.

Критические параметры конфигурации

Большинство проблем с неожиданным поведением возникает из-за непонимания двух ключевых параметров:

  1. staleTime (по умолчанию 0) — как долго данные считаются свежими без фоновой перепроверки
  2. cacheTime (по умолчанию 5 минут) — как долго сохранять данные в кэше после unmount компонента
jsx
// Пример тонкой настройки для редактирования профиля
useQuery({
  queryKey: ['user', userId],
  queryFn: fetchUser,
  staleTime: 30 * 60 * 1000, // 30 минут
  cacheTime: Infinity // Намеренно сохранять данные даже после закрытия компонента
});

Анти-паттерн: Установка cacheTime меньше staleTime приводит к немедленному удалению данных из кэша, требуя полного перезапроса при повторном обращении.

Инвалидация и оптимистичные обновления

Главное преимущество React Query — согласованное обновление состояний. Рассмотрим сценарий обновления пользовательского профиля:

tsx
const queryClient = useQueryClient();

const mutation = useMutation({
  mutationFn: updateUser,
  onMutate: async (newUser) => {
    await queryClient.cancelQueries(['user', newUser.id]);
    const prevUser = queryClient.getQueryData(['user', newUser.id]);
    
    queryClient.setQueryData(['user', newUser.id], (old) => ({
      ...old,
      ...newUser
    }));
    
    return { prevUser };
  },
  onError: (err, newUser, context) => {
    queryClient.setQueryData(['user', newUser.id], context.prevUser);
  },
  onSettled: () => {
    queryClient.invalidateQueries(['user']);
  }
});

Здесь применяется оптимистичное обновление: интерфейс мгновенно отражает изменения, а при ошибке — откатывается. Реализация требует глубокого понимания жизненного цикла мутаций.

Распространённые ошибки при работе с Query Keys

Структура ключей запросов — фундамент для правильного кэширования. Ошибки в настройке ключей часто остаются незамеченными до продакшн-сценариев.

❌ Плохо:

js
useQuery(['data', filters], queryFn); // Порядок свойств в filters не детерминирован

✅ Правильно:

js
useQuery(['data', { ...filters, _sort: 'id' }], queryFn); // Стабильная сериализация

Для сложных объектов используйте библиотеки типа fast-json-stable-stringify для генерации стабильных ключей.

Проблемы с параллельными запросами

При работе с зависимыми запросами разработчики часто впадают в «callback hell», цепляя then-обработчики. Решение — композиция запросов через useQueries или useSuspense:

jsx
const results = useQueries({
  queries: [
    { queryKey: ['user', userId], queryFn: fetchUser },
    { queryKey: ['posts', userId], queryFn: fetchPosts },
  ]
});

// Или с зависимыми запросами
const userQuery = useQuery(['user', userId], fetchUser);
const postsQuery = useQuery(
  ['posts', userQuery.data?.interests], 
  () => fetchPosts(userQuery.data.interests),
  { enabled: !!userQuery.data }
);

Проблема: Каскадные запросы (enabled: false до готовности предыдущих) могут приводить к race conditions при быстром изменении параметров.

Интеграция с TypeScript

Полноценное использование TypeScript требует аннотаций как для данных запросов, так и для ошибок:

tsx
interface User {
  id: string;
  name: string;
}

const { data } = useQuery<User, AxiosError>({
  queryKey: ['user', userId],
  queryFn: () => axios.get(`/users/${userId}`).then(res => res.data)
});

// Вывод типов для мутаций с динамическими параметрами
const mutation = useMutation<
  UpdateUserResponse,
  Error,
  { userId: string; data: UserUpdate }
>({
  mutationFn: ({ userId, data }) => 
    axios.patch(`/users/${userId}`, data)
});

Ошибка: Не указанный тип по умолчанию (unknown) приводит к необходимости постоянных проверок типов при работе с data.

Выводы и рекомендации

React Query значительно упрощает управление серверным состоянием, но требует:

  1. Точной настройки параметров кэширования под специфику приложения
  2. Дисциплинированного подхода к структуре Query Keys
  3. Планирования архитектуры запросов с учётом зависимостей и race conditions
  4. Полноценной интеграции системы типов для предотвращения runtime-ошибок

Для сложных сценариев (real-time обновления, оффлайн-режим) стоит комбинировать React Query с дополнительными инструментами типа WebSocket-адаптеров или LocalForage для работы с IndexedDB. Главное — избегать соблазна абстрагировать запросы «на будущее»: начните с минимально необходимой логики и расширяйте её по мере возникновения конкретных требований.

text