От харуданных состояний к свежим данным: перенимаем React Query для клиентской стороны выполнения запросов

Большинство фронтенд-разработчиков ненавидят две вещи:
ложные повторные запросы в данных и уверенное поведение по кэшированию. Обычное решение — ручное управление состоянием через Redux или Context, перегруженное useEffect, и неисчезающая вероятность возникновения гонки данных. Тут начинается свет в конце тоннеля: React Query не просто библиотека для запросов. Это система управления асинхронным состоянием, которая пересматривает наши приемы работы с серверными данными.

Почему ручное управление проваливается

Предположим, вы загружаете список пользователей:

javascript
const [users, setUsers] = useState([]);
const [isLoading, setIsLoading] = useState(false);

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

Уже здесь проблемы:

  • Кэширование отсутствует — при переходе между маршрутами данные запрашиваются повторно
  • Фоновое обновление не происходит — вкладка открыта час, а данные устарели
  • Кол-во состояния управления — isLoading, error, isRefetching нужно добавлять вручную
  • Критическая ошибка: данные сбрасываются при изменении стейта компонента (например, при переключении вкладки внутри компонента)

React Query заменяет данный шаблон с помощью декларативного API.

Основы: Запросы как изначальные состояния

Установите и подключите QueryClientProvider к приложению. Затем:

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

const fetchUsers = async () => {
  const res = await fetch('/api/users');
  if (!res.ok) throw new Error('Network response error');
  return res.json();
};

function UsersList() {
  const { 
    data: users, 
    isLoading, 
    isError, 
    error 
  } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers
  });

  // Автоматические возвраты отображения через состояние
  if (isLoading) return <Spinner />;
  if (isError) return <Error message={error.message} />;

  return users.map(user => <UserCard key={user.id} {...user} />);
}

Кажется знакомо? Но под капотом произошло следующее:

  • Результат запроса кэшируется под ключом ['users']
  • При повторном использовании используется кэш, в фоне делается повторный запрос (stale-while-revalidate)
  • Данные остаются в кеше и после анмаунта компонента (настраиваемое время хранения с помощью gcTime)

Инвалидация: Принудительное обновление без боли

Добавить нового пользователя? Обновить список после мутации:

javascript
const queryClient = useQueryClient();

const { mutate } = useMutation({
  mutationFn: addNewUser,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['users'] });
    // Только компоненты, подписанные на ['users'], перерендерятся
  }
});

invalidateQueries помечает запрос как невалидный. Все активные компоненты выполнят фоновый перезапрос. Никаких ручных setState, никаких лишних сетевых вызовов на компонентах, которые скрыты.

Префетчинг: Предотавка данных для мгновенного UX

Когда пользователь наводится на ссылку профиля, загрузка данных начинается заранее:

javascript
const queryClient = useQueryClient();

const prefetchUser = (userId) => {
  queryClient.prefetchQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 5 * 60 * 1000 // Храним как "свежие" 5 минут
  });
};

// Кнопки-карточки юзеров со слоем hover
const UserLink = ({ id, name }) => (
  <Link 
    to={`/user/${id}`}
    onMouseEnter={() => prefetchUser(id)}
  >
    {name}
  </Link>
);

Это устраняет индикаторы загрузки при переходе — данные уже в кеше.

Типизация с TypeScript: Предиктивные строки для ключей

React Query интегрирован с TypeScript через дженерики:

typescript
type User = { id: number; name: string };
type UserError = Error;

const { data } = useQuery<User[], UserError>({
  queryKey: ['users', { activeOnly: true }] // Ключ детализируется автоматически
  queryFn: fetchUsers
});

Ключи запросов сериализуются детерминированно: ['users', { activeOnly: true }] и ['users', { activeOnly: true }] считаются идентичными. Нелепые JSON.stringify не требуются.

Оптимизации, которые работают как по волшебству

  • Дублирующие запросы: Запросы с одинаковым ключом внутри одного интервала staleTime объединяются в один сетевой вызов.
  • Переиспользование кеша: При одинаковых ключах разных компонентов происходит синхронное обновление (observer радиального состояния).
  • Умный перезапрос: Фоновый запрос происходит только когда компонент видим или при позиционировании окна (refetchOnWindowFocus по умолчанию true).

Когда не использовать React Query?

Если данные синхронны (например, геолокация из браузера) или состояние полностью локально (draggable UI), задействовать хук состояния useState вполне достаточно. Также для сложных сетевых особенностей (WebSockets) может потребоваться сочетание с useWebSocket.

Вывод: Меньше копоти, больше свежих данных

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

Применение элементарно:

  1. Замените все useEffect запросы на useQuery
  2. Мутации централизуйте через useMutation
  3. Требуйте от бэкенда различимые ключи (user:id)
  4. Настройте префетчинг для ключевых пользовательских путей

Не верьте на слово — замените свои буксующие fetch в одном компоненте. Динамическое кэширование демпфирует бэкендный поток, сокращает цепи рендеринга и возвращает фронтенду его правдивое сердце — UI.