Пользователь открывает список заказов, заходит в карточку, возвращается назад и снова открывает ту же карточку. Интерфейс каждый раз показывает крутящийся индикатор и ждёт ответа, хотя данные не менялись. Количество запросов растёт, скорость падает, а сервер получает нагрузку без всякой пользы. Есть способ организовать работу с данными так, чтобы этого не происходило.

Библиотека интерфейсов появилась в 2013 году и заботится только о выводе

Публичный выпуск библиотеки для построения интерфейсов состоялся в мае 2013 года. Её задача узкая: описывать, как выглядит экран при разных состояниях, и обновлять только изменившиеся части страницы.

Всё остальное выносится в отдельные средства. Маршрутизация, управление состоянием, работа с сетью. Именно поэтому вопрос о загрузке данных встаёт так остро: библиотека не предоставляет собственного механизма, и каждая команда решает его по-своему.

Наивный подход выглядит так. Компонент при появлении запускает запрос, складывает результат в локальное состояние и показывает его. При уходе со страницы состояние исчезает, при возврате запрос выполняется заново.

Проблемы этого подхода перечислить нетрудно.

Первая: данные не разделяются между экранами. Один и тот же список загружается в трёх местах независимо.

Вторая: нет кэша. Каждый переход означает новый сетевой запрос.

Третья: не обрабатываются повторные попытки. Обрыв связи приводит к пустому экрану.

Четвёртая: нет согласованности. После изменения записи другие экраны продолжают показывать старое значение.

Пятая: не отслеживается устаревание. Долго открытая вкладка показывает данные часовой давности как свежие.

Специализированное средство отделяет серверное состояние от состояния интерфейса

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

Разница принципиальна. Копию нужно обновлять, за ней нужно следить, её нужно разделять между экранами. Именно этим занимается специальное средство.

Основные понятия выглядят так.

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

Кэш хранит результаты по ключу. Если данные запрашиваются повторно с тем же ключом, они берутся из кэша без обращения к сети.

Состояние запроса описывается набором признаков: идёт загрузка, есть данные, есть ошибка, данные устарели.

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

function useOrders(status) {
  return useQuery({
    queryKey: ["orders", { status }],
    queryFn: async () => {
      const response = await fetch("/api/orders?status=" + status);
      if (!response.ok) {
        throw new Error("Сервер вернул " + response.status);
      }
      return response.json();
    },
    staleTime: 60_000,
  });
}

Ключ включает и тип данных, и параметры. Это важно: список оплаченных заказов и список доставленных хранятся отдельно.

Время устаревания в минуту означает, что данные считаются свежими шестьдесят секунд. В течение этого срока повторное обращение не вызывает запрос.

Свежесть данных определяется двумя настройками

Первая настройка это время, в течение которого данные считаются свежими. Пока оно не истекло, кэш отдаёт данные без обращения к сети.

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

Разница между настройками объясняет типичное поведение. Компонент открывается, средство видит свежие данные в кэше и сразу показывает их. Через минуту данные становятся устаревшими, но не удаляются. При следующем открытии экрана они показываются сразу, а в фоне идёт обновление.

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,
      gcTime: 5 * 60_000,
      retry: 2,
      refetchOnWindowFocus: true,
    },
  },
});

Повторные попытки задаются числом. Две попытки это разумный компромисс: кратковременный сбой сети переживается, а длительная недоступность не превращается в бесконечный цикл.

Обновление при возврате к окну решает задачу устаревших данных. Человек переключился на другую вкладку, вернулся через час, и данные обновились сами.

Изменения данных требуют управления кэшем

Третья часть работы связана с записью. После изменения записи нужно, чтобы интерфейс показал актуальное значение.

Первый способ это объявить, какие запросы устарели. Средство перезагрузит их автоматически.

import { useMutation, useQueryClient } from "@tanstack/react-query";

function useCancelOrder() {
  const client = useQueryClient();
  return useMutation({
    mutationFn: (orderId) =>
      fetch("/api/orders/" + orderId + "/cancel", { method: "POST" }),
    onSuccess: () => {
      client.invalidateQueries({ queryKey: ["orders"] });
    },
  });
}

Второй способ это ручное обновление кэша. Он быстрее, потому что не требует сетевого запроса, но требует знания структуры данных.

function useUpdateOrderTitle() {
  const client = useQueryClient();
  return useMutation({
    mutationFn: ({ id, title }) =>
      fetch("/api/orders/" + id, {
        method: "PATCH",
        body: JSON.stringify({ title }),
      }),
    onSuccess: (_, variables) => {
      client.setQueryData(["order", variables.id], (old) =>
        old ? { ...old, title: variables.title } : old,
      );
    },
  });
}

Третий способ это оптимистичное обновление. Интерфейс показывает результат сразу, до ответа сервера. Если ответ окажется неудачным, изменение откатывается.

function useMarkPaid() {
  const client = useQueryClient();
  return useMutation({
    mutationFn: (orderId) =>
      fetch("/api/orders/" + orderId + "/pay", { method: "POST" }),
    onMutate: async (orderId) => {
      await client.cancelQueries({ queryKey: ["order", orderId] });
      const previous = client.getQueryData(["order", orderId]);
      client.setQueryData(["order", orderId], (old) =>
        old ? { ...old, status: "оплачен" } : old,
      );
      return { previous };
    },
    onError: (_, orderId, context) => {
      client.setQueryData(["order", orderId], context.previous);
    },
  });
}

Отмена активных запросов в начале обязательна. Без неё параллельный запрос может завершиться позже и перезаписать оптимистичное значение старыми данными.

Страницы и бесконечные списки требуют отдельного подхода

Четвёртая часть работы касается постраничной загрузки. Обычный кэш здесь не подходит, потому что данные приходят порциями.

import { useInfiniteQuery } from "@tanstack/react-query";

function useFeed() {
  return useInfiniteQuery({
    queryKey: ["feed"],
    queryFn: ({ pageParam }) =>
      fetch("/api/feed?cursor=" + (pageParam || "")).then((r) => r.json()),
    initialPageParam: "",
    getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
  });
}

Первое: страницы хранятся вместе, и результат представляет список списков.

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

Третье: смещение вместо указателя тоже применяется, но при вставке новых записей возможны дубликаты.

Четвёртое: после изменения одной записи нужно обновить её во всех загруженных страницах. Средство предоставляет способ пройтись по страницам и заменить нужный элемент.

Пятое: кэш бесконечного списка стоит очищать при уходе со страницы, иначе память растёт вместе с прокруткой.

Предварительная загрузка делает переходы мгновенными

Пятая часть работы это подготовка данных заранее.

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

function OrderLink({ id, children }) {
  const client = useQueryClient();
  const prefetch = () => {
    client.prefetchQuery({
      queryKey: ["order", id],
      queryFn: () => fetch("/api/orders/" + id).then((r) => r.json()),
      staleTime: 30_000,
    });
  };
  return (
    <a href={"/orders/" + id} onMouseEnter={prefetch} onFocus={prefetch}>
      {children}
    </a>
  );
}

Второй приём: загрузка после действия. После оформления заказа можно сразу подтянуть данные для следующего экрана.

Третий приём: заполнение кэша из ответа. Если один запрос вернул данные, из которых можно собрать другую сущность, их стоит сразу разложить по нужным ключам.

Четвёртый приём: согласование ключей. Ключи стоит описывать по единому шаблону, иначе предварительная загрузка будет попадать в один ключ, а чтение идти по другому.

Пятый приём: ограничение по памяти. Предварительная загрузка десятков сущностей на медленном устройстве вредит. Стоит ограничиться самыми вероятными переходами.

Ошибки и повторные попытки требуют осмысленных сообщений

Шестая часть работы это поведение при сбоях.

Первое правило: различать виды ошибок. Проблема сети временна, ошибка сервера может быть временной, ошибка запроса постоянна.

Второе правило: не повторять бессмысленные попытки. Код четыреста один означает отсутствие прав, и повтор ничего не изменит.

const query = useQuery({
  queryKey: ["order", id],
  queryFn: fetchOrder,
  retry: (failureCount, error) => {
    if (error.status >= 400 && error.status < 500) {
      return false;
    }
    return failureCount < 3;
  },
  retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
});

Третье правило: задержка между попытками растёт. Мгновенные повторы при недоступности сервера только усиливают нагрузку.

Четвёртое правило: интерфейс показывает состояние. Индикатор загрузки, сообщение об ошибке, кнопка повтора. Пустой экран без пояснений недопустим.

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

Числовой пример показывает экономию запросов

Полезно посчитать, сколько обращений к серверу экономит кэш.

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

Без кэша это четырнадцать запросов. С кэшем и временем свежести в тридцать секунд большая часть обращений обслуживается из памяти. Если сессия длится десять минут, запросов остаётся около четырёх-пяти.

Экономия составляет более половины. На сервере это снятая нагрузка, на клиенте это мгновенные переходы.

Второй расчёт касается предварительной загрузки. Если данные начинают грузиться при наведении курсора, к моменту нажатия ответ обычно уже пришёл. Задержка перехода сокращается с сотен миллисекунд до нуля.

Третий расчёт связан с оптимистичным обновлением. Отклик интерфейса становится мгновенным, а сетевой запрос уходит в фон. Ощущение скорости меняется сильнее, чем от любой оптимизации сервера.

Отдельно про сочетание с локальным состоянием

Серверные данные и состояние интерфейса нужно держать раздельно, и это правило стоит закрепить в команде.

Первое: фильтры и сортировка это состояние интерфейса. Их хранение в кэше серверных данных приводит к путанице.

Второе: результат запроса это серверное состояние. Копирование его в локальное состояние компонента лишает приложение всех преимуществ кэша.

Третье: производные значения считаются на месте. Не нужно запрашивать у сервера то, что вычисляется из уже полученных данных.

Четвёртое: форма редактирования хранит черновик отдельно, а после сохранения обновляет кэш.

Пятое: ошибки формы относятся к интерфейсу, а ошибки запроса к серверному состоянию.

Такое разделение избавляет от большей части путаницы, которая возникает при росте приложения.

Короткая проверка перед выпуском

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

Второе: настроено время свежести и время хранения для основных типов данных.

Третье: после каждого изменения данных кэш обновляется или помечается устаревшим.

Четвёртое: проверены повторные попытки и разбор ошибок.

Пятое: предварительная загрузка работает для самых частых переходов.

Шестое: проверено потребление памяти при длительной работе.

Седьмое: убедитесь, что данные не копируются в локальное состояние без необходимости.

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

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