Рассмотрение стратегий кэширования в React Query

Рассмотрение стратегий кэширования в React Query

1. Введение: Состояние сервера (Server State) — когда оно устаревает

При эксплуатации фронтенда пользователи часто сообщают о двух типах проблем. Одна из них: «Я только что зарегистрировался, но не вижу в списке», а другая — «Я просто сидел, и экран вдруг мигнул и перезагрузился». На первый взгляд эти два симптома кажутся не связанными, но на самом деле они происходят из одной и той же причины: отсутствия политики кэширования (Cache), определяющей, «как долго и на основании каких критериев клиент будет считать данные сервера валидными» .

Многие команды решают эту проблему с помощью React Query (ныне TanStack Query), и наш проект не исключение. Однако, если посмотреть на код, выясняется, что важнейший вопрос «насколько долго хранить кэш» распространен по отдельным хукам и нет единого централизованного подхода.

Ценность React Query начинается с различия между «состоянием сервера (Server State)» и «состоянием клиента (Client State)». Например, значение, указывающее на то, открыт ли модал, является состоянием клиента, тогда как данные, такие как списки, детали и информация о пользователе, берутся с сервера, и мы всего лишь временно берем их на прокат. Эти копии могут изменяться на сервере даже в то время, как я их просматриваю. Это, useState + useEffect требует от нас вручную управлять флагами загрузки и ошибок, а повторные запросы одних и тех же данных на нескольких экранах будут происходить одновременно. React Query хранит ответ в кэше под меткой queryKey и обрабатывает это дублирование вместо нас. В конечном итоге «как управлять кэшом» является сутью эффективного использования этой библиотеки.

2. Основные концепции: свежесть (fresh) и устаревшие данные (stale), а также срок действия кэша

2.1 staleTime — «срок годности» данных

Все данные в React Query либо свежие (fresh), либо устаревшие (stale). Только что полученные данные являются fresh, а по истечении staleTime они переходят в статус stale. Это проще всего понять, сравнивая с сроком годности продуктов питания. Ключевой момент в том, что даже если данные становятся stale, они не удаляются. Продукты, срок годности которых истёк, могут быть на столе, но с пометкой «при следующей возможности закуплю новые». Однако есть и подводный камень. Значение по умолчанию для staleTime равно 0, что значит, что данные становятся stale сразу после получения.

2.2 gcTime — «период хранения» кэширования

Когда компонент, использующий любой запрос, исчезает с экрана, этот запрос становится неактивным (inactive). gcTime — это время ожидания, необходимое для очистки неактивного кэша из памяти, а значение по умолчанию составляет 5 минут. Это период хранения. Если вы вернётесь на тот же экран в течение этого времени, кэш останется активным и будет показан сразу, если же пройдет, он станет объектом сборки мусора (Garbage Collection), и в следующий раз данные будут получены заново.

Когда происходит повторный запрос (refetch)

Данные становятся устаревшими в четырех случаях: когда компонент монтируется заново, когда фокус возвращается в окно браузера, когда разорванная сеть восстанавливается, и когда мы явно вызываем инвалидирование (Invalidation). Однако все эти триггеры работают только тогда, когда данные устарели. Если они свежие, то повторный запрос не произойдет, даже если вы будете постоянно переключаться между окнами. Поэтому то, как часто происходит повторный запрос, в значительной степени зависит от того, как мы определяем staleTime.

Раздел

staleTime

gcTime

Вопросы

Насколько свежие?

На сколько долго хранить?

Значение по умолчанию

0 (немедленно устаревшие)

5 минут

Область влияния

Момент повторного запроса (refetch)

Использование памяти / Скорость повторного входа

Прошедшее время

Данные сохраняются на экране

Кэш сам по себе удаляется

3. Распространенный антипаттерн кэша

3.1 Распределение staleTime среди хуков без глобальной нормы

Если не установить глобальное значение по умолчанию, staleTime будет равен 0. Поэтому каждый раз, когда вы возвращаетесь на экран, или когда компонент монтируется заново, отправляется тот же самый запрос. Позже разработчики, заметившие это, начинают вручную вводить staleTime для каждого экрана, указывая разное время: кто-то — 1 минуту, кто-то — 5 минут, а большинство остаются с настройками по умолчанию. Из-за отсутствия обоснования значения новые участники команды теряют ориентир в том, «как часто должны обновляться эти данные».

3.2 Ключ запроса и ключ аннулирования не совпадают

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

// 목록은 이 키로 캐싱했는데
useQuery({ queryKey: ['posts'], ... })
// 글 등록 후엔 다른 키를 무효화한다면
queryClient.invalidateQueries({ queryKey: ['postList'] }); // 매칭 안 됨 → 목록 그대로

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

3.3 Сосуществование ручного получения данных вне кэша

Некоторые экраны получают одни и те же данные через React Query, а другие по-прежнему используют useState и useEffect. В этом случае кэш будет только на одной стороне, что может привести к несоответствиям в данных, и преимущества удаления дубликатов и автоматического обновления исчезают на этом экране. Это особенно часто наблюдается в кодовой базе, где миграция не завершена.

4. Эффективная стратегия управления кэшем

4.1 Согласовать глобальные значения по умолчанию (Default Options)

Первое, что нужно сделать, это явно указать базовую линию кэша в defaultOptions QueryClient. Установите staleTime на разумное значение, отличное от 0, и refetchOnWindowFocusРекомендуется использовать эту настройку только в экстренных случаях, когда реальное время критично, после отключения на глобальном уровне. С этой единственной настройкой 3.1 станет упорядоченным, и необходимость повторного указания одинаковых опций в отдельных хуках исчезнет.

new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 30, // 기준선: 30초 동안은 신선하다고 본다
      refetchOnWindowFocus: false, // 기본은 끄고, 필요한 화면만 켠다
      retry: 1, // 실패 시 과한 재시도 방지
    },
  },
});

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

4.2 Классификация staleTime по характеру данных

Не следует определять “сколько минут staleTime” для каждого экрана. Критерием является не экран, а как часто данные меняются. Если разделить данные на три категории, это уберет сомнения при создании новых запросов. Значения, которые почти не меняются, как мастер-данные, можно установить с большим staleTime (иногда Infinity), а вместо этого лучше взять на себя обязательство «недействительно сразу при фактическом изменении значения». Тогда в обычное время сеть не используется вообще, и обновление происходит только в момент реального изменения. gcTime обычно достаточно устанавливать на 5 минут, но если это экран, где долго задерживаются, как в киосках или встроенных системах, следует увеличить его, чтобы избежать «перезагрузки при возвращении», а в условиях ограниченной памяти - уменьшить.

Классификация

Пример

Рекомендуемый staleTime

Мастер-данные (почти неизменные)

Категории, таблицы кодов, структуры организаций/отделов

5-10 минут и более + немедленное недействие при изменении

Полустатические (редко меняются)

Мой профиль, права доступа/настройки

1-3 минуты

Транзакционные (часто меняются)

Список заказов/публикаций, уведомлений, запасов

0~30 секунд

Ключ 4.3 унифицирован под ‘фабрику’

Способ структурно устранить ошибку несоответствия ключей в 3.2 прост: не писать ключи вручную, а воспользоваться функцией, создающей ключи (фабрикой). Если спроектировать иерархически, то аннулирование будет гораздо мощнее.

export const postKeys = {
  all: ['posts'] as const,
  lists: () => [...postKeys.all, 'list'] as const,
  list: (filters) => [...postKeys.lists(), filters] as const,
  detail: (id) => [...postKeys.all, 'detail', id] as const,
};

Здесь полезно знать одну механику.invalidateQueriesработает по принципу ‘частичного соответствия ключа (prefix match)’. То есть invalidateQueries({ queryKey: postKeys.all })одной строкой охватывает все кэшированные списки и детали ['posts','list', ...] сразу. Наоборот, если хотите обновить только одну конкретную запись, достаточно аннулировать только postKeys.detail(id)Так как запросы и аннулирование извлекают ключи из одной фабрики, вероятность расхождения символов полностью отсутствует. Ключи определяются в одном месте, что также помогает с автозаполнением и проверкой типов.

4.4 Аннулирование — это не единственный ответ: прямое обновление и оптимистичное обновление

Аннулирование (invalidate) — это инструкция: “Кэш устарел, поэтому нужно получить его заново”. Это безопасно, но вызывает ещё один сетевой запрос. Однако, если в ответе API изменения уже есть актуальные данные, нет нужды их запрашивать снова.setQueryDataНельзя заставлять кэш работать вручную, это быстро и выгодно. Экономия одного запроса.

Далее, для действий, где мгновенная реакция важна, таких как переключатели или флажки, мы используем оптимистичное обновление (Optimistic Update).onMutateНа этапе мы меняем кэш заранее, не дожидаясь сервера, чтобы мгновенно обновить экран и в случае неудачи запроса вернуться к предыдущему значению. Пользователь почти не ощущает задержки. В общем, это «инвалидировать (invalidate) → заменять ответом (setQueryData) → предварительно менять и откатывать в случае неудачи (optimistic)», чем более важна мгновенность, тем вправо мы выбираем.

4.5 Плавный переход: keepPreviousData и placeholderData

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

4.6 Скрытие ожидания заранее: prefetch и initialData

Если пользователю заранее предсказуемо, что он увидит дальше, можно заранее загрузить эти данные. В момент, когда курсор наводится на элемент в списке, prefetchQueryпредварительно загружает детали, и когда вы действительно кликаете, они уже находятся в кэше и открываются мгновенно. Напротив, если у вас уже есть данные (если в ответе на список есть часть деталей или если они были загружены SSR), можно заполнить начальное значение кэша с помощью initialDataчтобы пропустить первую загрузку. Оба метода «перемещают время ожидания в незаметное место для пользователя».

4.7 Кэш наглядно: Devtools

Наконец, я настоятельно рекомендую добавить это только в сборку разработчика.@tanstack/react-query-devtoolsЭто наглядно показывает, в каком состоянии находятся каждая из запросов: fresh, stale или inactive, действительно ли происходила инвалидизация, и сколько ключей сейчас активны. Несоответствие ключей, как в 3.2, вскоре становится очевидным, просто открыв Devtools один раз. Проблему с кэшем, которую вы пытались отладить предположительно, теперь можно проверить визуально.

5. Заключение

Хорошее использование React Query не связано с использованием множества впечатляющих функций, а больше напоминает формализацию обязательств по кэшу в одном месте кода. Во-первых, установите глобальные значения по умолчанию для создания базовой линии, во-вторых, классифицируйте staleTime в зависимости от характеристик данных, и в-третьих, собирайте ключи запросов в фабрике. Даже этого достаточно, чтобы устранить большинство таких симптомов, как 'зарегистрировано, но список не обновляется' или 'экран мигает каждый раз при переключении'.

Наложить еще один уровень - это эффективность. Вместо обработки всех изменений как инвалидизации и повторного запроса каждый раз, обновляйте кэш напрямую в ответе (setQueryData), обрабатывайте то, что требует мгновенности, оптимистичными обновлениями, и предзагружайте ожидающие экраны (prefetch). Как только вы начнете смотреть на проблему не как 'когда очистить кэш?', а как 'когда и как заполнить кэш?', вы сможете создать экраны, которые работают гораздо быстрее и тише даже на одном сервере и в одной сети. В конечном итоге хорошая стратегия кэша - это то, что делает ожидания пользователя незаметными.

Справочные материалы

  1. TanStack Query – Важные значения по умолчанию — https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults

  2. TanStack Query – Механизм кэширования — https://tanstack.com/query/latest/docs/framework/react/guides/caching

  3. TanStack Query – Инвалидизация запросов — https://tanstack.com/query/latest/docs/framework/react/guides/query-invalidation

  4. TanStack Query – Оптимистичные обновления — https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates

  5. TanStack Query – Предзагрузка — https://tanstack.com/query/latest/docs/framework/react/guides/prefetching

  6. TkDodo – Эффективные ключи запросов React — https://tkdodo.eu/blog/effective-react-query-keys

  7. TkDodo – Практический React Query — https://tkdodo.eu/blog/practical-react-query

toffeeman

Site footer