Покончим с мерцанием устаревших данных: Продвинутые стратегии React Query

Вступление

Реализовав бесчисленное количество дашбордов и интерактивных интерфейсов, я заметил повторяющуюся проблему: мерцание контента при переходах между страницами. Это особенно заметно в React-приложениях, использующих кэширование данных.

Холодная реальность взаимодействия с данными

Рассмотрим типичный сценарий:

tsx
function UserProfile({ userId }) {
  const { data, isLoading } = useQuery(['user', userId], () => fetchUser(userId));
  
  if (isLoading) return <Spinner />;
  
  return (
    <div>
      <h1>{data.name}</h1>
      <p>{data.email}</p>
    </div>
  );
}

Код кажется правильным, но он содержит скрытую ловушку: мерцание устаревших данных. Когда пользователь переходит между профилями, они могут мимолетно увидеть данные предыдущего пользователя перед их заменой актуальными.

Корень проблемы — поведение кэширования. В React Query по умолчанию:

  • При монтировании компонента сразу возвращаются кэшированные данные (если есть)
  • Одновременно запускается фоновый запрос для обновления
  • Новые данные отображаются по получении

Этот подход улучшает воспринимаемую скорость, но создаёт артефакты при изменении ключа запроса.

Стратегия 1: Предотвращение возврата безнадёжно устаревших данных

Установим staleTime и cacheTime, чтобы отделить "черствые" данные от "несвежих":

tsx
const queryOptions = {
  staleTime: 60 * 1000, // 1 минута, после которой данные считаются устаревшими
  cacheTime: 5 * 60 * 1000 // 5 минут хранение в кэше
};

function UserProfile({ userId }) {
  const { data, isInitialLoading, isFetching } = useQuery(
    ['user', userId],
    () => fetchUser(userId),
    queryOptions
  );
  
  // Новые данные не загружены, старые устарели - показываем скелетон
  if (isInitialLoading && !data) {
    return <UserProfileSkeleton />;
  }
  
  return (
    <div>
      <h1>{data.name}</h1>
      {isFetching && <UpdateIndicator />} 
    </div>
  );
}

Ключевые моменты:

  • isInitialLoading отличается от isFetching: первый флаг - только при первоначальной загрузке
  • Условный рендеринг: скелетон показываем только если данных нет совсем
  • Фоновые обновления просто добавляют индикатор вместо смены всего контента

Стратегия 2: Сохранение предыдущего состояния при обновлении

Для списков с элементами, использующими данные с сервера, применяем keepPreviousData:

tsx
function UserList({ page }) {
  const { data, isPreviousData } = useQuery(
    ['users', page],
    () => fetchUsers(page),
    {
      keepPreviousData: true,
      staleTime: 10 * 1000
    }
  );

  return (
    <div>
      {data?.users.map(user => (
        <UserCard key={user.id} {...user} />
      ))}
      {isPreviousData && <ListSkeleton overlay={true} />}
    </div>
  );
}

Эффект:

  • При переходе между страницами предыдущие данные остаются видимыми
  • Поверх отображается полупрозрачный скелетон
  • После получения данных скелетон исчезает, контент плавно обновляется

Это исключает моргание интерфейса и обеспечивает плавный переход.

Стратегия 3: Комбинирование CacheTime и активных запросов

Для критически важных данных, где показ устаревшей информации недопустим:

tsx
const alwaysFreshOptions = {
  staleTime: 0,
  cacheTime: 0,
  initialData: undefined
};

function PaymentDetails({ paymentId }) {
  const { data, isLoading } = useQuery(
    ['payment', paymentId],
    () => fetchPayment(paymentId),
    alwaysFreshOptions
  );

  if (isLoading) return <SecureDataLoader />;

  return (
    <div>
      <h2>Payment ${data.amount}</h2>
      <StatusIndicator status={data.status} />
    </div>
  );
}

Характеристики:

  • Полное отключения кэширования для конкретного запроса
  • Недвусмысленное состояние загрузки
  • Идеально для финансовых или конфиденциальных данных

Оптимизация не только загрузки: Управление мутациями

Мерцание возникает не только при загрузке данных, но и после мутаций. Решение:

tsx
function UpdateProfileForm() {
  const queryClient = useQueryClient();
  
  const mutation = useMutation(updateProfile, {
    onMutate: async (newData) => {
      await queryClient.cancelQueries(['profile', newData.id]);
      
      const previousData = queryClient.getQueryData(['profile', newData.id]);
      
      queryClient.setQueryData(['profile', newData.id], old => ({
        ...old,
        ...newData
      }));
      
      return { previousData };
    },
    onError: (err, newData, context) => {
      queryClient.setQueryData(
        ['profile', newData.id],
        context.previousData
      );
    },
    onSettled: () => {
      queryClient.invalidateQueries(['profile']);
    }
  });
}

Детали подхода:

  1. Оптимистичное обновление: сразу применяем изменения к UI до ответа сервера
  2. Отмена запросов: предотвращаем конфликты между мутацией и активными запросами
  3. Восстановление при ошибке: возврат к предыдущему состоянию при неудаче
  4. Фоновая валидация: окончательная синхронизация с сервером

Архитектурные решения

Централизованная конфигурация кэширования

Объедините настройки запросов в общем месте:

tsx
// queryClient.js
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000 * 5, 
      refetchOnWindowFocus: false,
      retry: 2,
    },
  },
});

// А затем, для отдельных запросов:
queryClient.setDefaultOptions({
  queries: {
    ...queryClient.getDefaultOptions().queries,
    cacheTime: 0,
  },
});

Применение localStorage для жизненных циклов

Персистентность кэша с контролем сроков:

tsx
const localStoragePersister = createSyncStoragePersister({
  storage: window.localStorage,
  key: 'app-cache',
});

persistQueryClient({
  queryClient,
  persister: localStoragePersister,
  maxAge: 1000 * 60 * 30, // 30 минут
});

При этом данные автоматически очистятся по истечении установленного времени.

Оценка компромиссов

  1. Стабильность vs Актуальность:
    Более длинный cacheTime уменьшает мерцание, но повышает риск показа устаревших данных. Требуется регуляция: 2-5 минут для статических данных, 0 для динамических.

  2. Производительность vs Чувствительность:
    Индикаторы загрузки улучшают UX, но перегружают интерфейс при частых обновлениях. Используйте миниатюрные индикаторы для фоновых обновлений.

  3. Сложность реализации vs Устойчивость:
    Оптимистичные обновления требуют на 40% больше кода, но полностью устраняют мерцание после действий пользователя.

Ключевые рекомендации

  1. Разделяйте данные по категориям:

    • Мгновенные: данные форм, фильтры (staleTime: 0)
    • Статические: конфигурации, справочники (staleTime: Infinity)
    • Пользовательские: заказы, профили (staleTime: 30-120s)
  2. Используйте семейство isFetching реактивно:

    tsx
    // Вместо:
    {isLoading ? <Spinner> : <Content>}
    
    // Индикатор прогресса поверх контента:
    <Content />
    {isFetching && <InlineSpinner />}
    
  3. Реализуйте интеллектуальную предзагрузку:

    tsx
    useEffect(() => {
      queryClient.prefetchQuery(
        ['user', nextUserId],
        () => fetchUser(nextUserId)
      );
    }, [nextUserId]);
    

Заключение

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

Наиболее эффективный подход — комбинаторный: централизованные политики кэширования определяют базовые правила, а использование staleTime, cacheTime и keepPreviousData адаптирует поведение для конкретных сценариев. С добавлением умной предзагрузки и оптимистичных обновлений вы превращаете затерянные миллисекунды продуктивности пользователя в ощутимый опыт плавности.