Вступление
Реализовав бесчисленное количество дашбордов и интерактивных интерфейсов, я заметил повторяющуюся проблему: мерцание контента при переходах между страницами. Это особенно заметно в React-приложениях, использующих кэширование данных.
Холодная реальность взаимодействия с данными
Рассмотрим типичный сценарий:
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
, чтобы отделить "черствые" данные от "несвежих":
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
:
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 и активных запросов
Для критически важных данных, где показ устаревшей информации недопустим:
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>
);
}
Характеристики:
- Полное отключения кэширования для конкретного запроса
- Недвусмысленное состояние загрузки
- Идеально для финансовых или конфиденциальных данных
Оптимизация не только загрузки: Управление мутациями
Мерцание возникает не только при загрузке данных, но и после мутаций. Решение:
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']);
}
});
}
Детали подхода:
- Оптимистичное обновление: сразу применяем изменения к UI до ответа сервера
- Отмена запросов: предотвращаем конфликты между мутацией и активными запросами
- Восстановление при ошибке: возврат к предыдущему состоянию при неудаче
- Фоновая валидация: окончательная синхронизация с сервером
Архитектурные решения
Централизованная конфигурация кэширования
Объедините настройки запросов в общем месте:
// 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 для жизненных циклов
Персистентность кэша с контролем сроков:
const localStoragePersister = createSyncStoragePersister({
storage: window.localStorage,
key: 'app-cache',
});
persistQueryClient({
queryClient,
persister: localStoragePersister,
maxAge: 1000 * 60 * 30, // 30 минут
});
При этом данные автоматически очистятся по истечении установленного времени.
Оценка компромиссов
-
Стабильность vs Актуальность:
Более длинныйcacheTime
уменьшает мерцание, но повышает риск показа устаревших данных. Требуется регуляция: 2-5 минут для статических данных, 0 для динамических. -
Производительность vs Чувствительность:
Индикаторы загрузки улучшают UX, но перегружают интерфейс при частых обновлениях. Используйте миниатюрные индикаторы для фоновых обновлений. -
Сложность реализации vs Устойчивость:
Оптимистичные обновления требуют на 40% больше кода, но полностью устраняют мерцание после действий пользователя.
Ключевые рекомендации
-
Разделяйте данные по категориям:
- Мгновенные: данные форм, фильтры (
staleTime: 0
) - Статические: конфигурации, справочники (
staleTime: Infinity
) - Пользовательские: заказы, профили (
staleTime: 30-120s
)
- Мгновенные: данные форм, фильтры (
-
Используйте семейство
isFetching
реактивно:tsx// Вместо: {isLoading ? <Spinner> : <Content>} // Индикатор прогресса поверх контента: <Content /> {isFetching && <InlineSpinner />}
-
Реализуйте интеллектуальную предзагрузку:
tsxuseEffect(() => { queryClient.prefetchQuery( ['user', nextUserId], () => fetchUser(nextUserId) ); }, [nextUserId]);
Заключение
Различные подходы к работе с данными требуют и различных стратегий борьбы с визуальными артефактами. Нет универсального решения: чувствительные финансовые транзакции требуют немедленных обновлений, тогда как медиа-каталоги сохраняют плавность восприятия через keepPreviousData
.
Наиболее эффективный подход — комбинаторный: централизованные политики кэширования определяют базовые правила, а использование staleTime
, cacheTime
и keepPreviousData
адаптирует поведение для конкретных сценариев. С добавлением умной предзагрузки и оптимистичных обновлений вы превращаете затерянные миллисекунды продуктивности пользователя в ощутимый опыт плавности.