Современные веб-приложения всё чаще превращаются в сложные SPA с десятками экранов и сотнями API-вызовов. Типичная картина: компоненты верхнего уровня обрастают useEffect
с повторяющимися запросами, приложение начинает получать одни и те же данные в разных местах, а состояние сервера смешивается с локальной бизнес-логикой. Результат — непредсказуемые ререндеры, конкурирующие запросы и лавинообразный рост сложности.
Главная недооценённая проблема здесь — отсутствие чёткой границы между клиентским и серверным состоянием. Файлы cookies — клиентское состояние, список пользователей из API — серверное. Первое можно изменять произвольно, второе требует синхронизации с источником истины.
Рассмотрим реалистичный пример с типичными ошибками:
function UserProfile() {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
useEffect(() => {
fetch('/api/user')
.then(res => res.json())
.then(setUser);
}, []);
useEffect(() => {
fetch('/api/posts?userId=123')
.then(res => res.json())
.then(setPosts);
}, []);
// Рендер пользователя и постов
}
Кажется безобидным? Но здесь:
- Нет обработки ошибок
- Нет инвалидации устаревших данных
- Неизбежны дублирующиеся запросы при нескольких экземплярах компонента
- Сложность с префетчингом данных
Решение — выделение слоя серверного состояния с помощью специализированных библиотек. React Query и Apollo Client дают три ключевых преимущества:
- Декларативное описание данных
- Автоматическое кэширование
- Фоновое обновление по стратегиям
Перепишем пример с React Query:
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<UserProfile />
</QueryClientProvider>
);
}
function UserProfile() {
const { data: user } = useQuery(['user'], () =>
fetch('/api/user').then(res => res.json())
);
const { data: posts } = useQuery(['posts', user?.id], () =>
fetch(`/api/posts?userId=${user.id}`).then(res => res.json()),
{ enabled: !!user?.id }
);
// Бонус: префетчинг при наведении на кнопку
const prefetchPosts = () =>
queryClient.prefetchQuery(['posts', user.id], fetchPosts);
}
Что изменилось:
- Кэш живет вне компонентов, управляется уникальными ключами
- Запросы автоматически дедуплицируются
- Зависимые запросы (posts после user) реализуются через
enabled
- Появилась возможность префетчинга
Структура кэша — важнейший архитектурный аспект. Ключи должны отражать сущность данных, а не их местоположение в коде. Хороший паттерн — соглашение об именовании:
['user', id]
для конкретного пользователя['posts', { userId, page }]
для пагинированных данных['config', 'theme']
для глобальных настроек
Рекомендации по работе с RQ в продакшене:
- Инвалидация после мутаций: после изменения данных через POST/PUT вызывать
queryClient.invalidateQueries(['posts'])
для фонового обновления - Оптимистичные обновления: при удалении элемента обновлять кэш локально до ответа сервера
- Группировка ошибок: перехватывать ошибки HTTP на уровне клиента, а не в каждом запросе
- Пагинация и бесконечная лента: использовать
useInfiniteQuery
с обработкой страниц как связанного списка
Типичная ошибка новичков — хранить серверный и клиентский state в одном месте. Контрпример:
// Антипаттерн!
const [user, setUser] = useState(initialUser);
const { data } = useQuery(['user'], fetchUser);
// Путаница: user может быть из кэша или из локального состояния
Правильный подход — разделение:
- Данные из API — только через Query Client
- Локальные изменения — через
useState
или Formik - Синхронизация через
onSuccess
в мутациях
Производительность приложения напрямую зависит от стратегий обновления данных. Сравним варианты:
- Stale-While-Revalidate (по умолчанию): мгновенная отрисовка кэша с фоновым запросом
- Строгая актуальность:
staleTime: 0
— данные всегда свежие, ценой задержек - Оффлайн-режим:
cacheTime: Infinity
для сохранения данных между сессиями
При миграции с legacy-подхода важны два шага:
- Постепенная замена глобального состояния (Redux) на кэш-менеджер
- Анализ повторяющихся запросов через DevTools (в RQ есть встроенная панель)
Вывод не как шаблонная фраза, а как инженерное правило: серверное состояние должно управляться как внешняя синхронизируемая база данных. Его жизненный цикл (запрос, кэширование, обновление) нужно инкапсулировать, а не раскидывать эффектами по компонентам. Для большинства приложений React Query становится оптимальным выбором, заменяя 80% кода, связанного с данными, при сохранении полного контроля над edge-кейсами.