React Query как State Manager
20.08.2021 — ReactJs, React Query, JavaScript, TypeScript — 7 min read - TkDodo
Этот пост является частью блога TkDodo и является переводом на русский язык.
Многие любят React Query за значительное упрощение получения данных в приложениях React. Поэтому для вас может стать неожиданностью, если я скажу, что React Query на самом деле не является библиотекой для извлечения данных. Он сам по себе не извлекает никаких данных, и лишь небольшой набор функций напрямую связан с сетью (например, OnlineManager, refetchOnReconnect или повторная попытка при оффлайн-мутации). Это также становится очевидным, когда вы пишете свой первый queryFn и вам нужно использовать что-то для получения данных, например fetch, axios, ky или даже graphql-request. Таким образом, если React Query не является библиотекой для извлечения данных, то что же это такое?
Асинхронный менеджер состояния
React Query — это асинхронный менеджер состояния. Он может управлять любой формой асинхронного состояния и работает, пока получает Promise. Да, большую часть времени мы создаем Promise при извлечении данных, и в этом его преимущество. Но React Query делает для вас больше, чем просто обрабатывает состояния загрузки и ошибоки. Это полноценный, «глобальный менеджер состояния». QueryKey уникально идентифицирует ваш запрос, поэтому, если вы вызываете запрос с тем же ключом в двух разных местах, они получат одинаковые данные. Лучше всего абстрагировать это с помощью пользовательского хука, чтобы не обращаться к функции получения данных дважды.
1 export const useTodos = () =>
2 useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
3
4 function ComponentOne() {
5 const { data } = useTodos()
6 }
7
8 function ComponentTwo() {
9 // ✅ will get exactly the same data as ComponentOne
10 const { data } = useTodos()
11 }
12
13 const queryClient = new QueryClient()
14
15 function App() {
16 return (
17 <QueryClientProvider client={queryClient}>
18 <ComponentOne />
19 <ComponentTwo />
20 </QueryClientProvider>
21 )
22 }Эти компоненты могут быть где угодно в вашем дереве компонентов. Пока они находятся под одним и тем же QueryClientProvider, они будут получать одинаковые данные. React Query также устранит дублирование запросов, которые происходят одновременно, поэтому в приведенном выше сценарии, даже если два компонента запрашивают одни и те же данные, будет выполнен только один сетевой запрос.
Данные, как инструмент синхронизации
Поскольку React Query управляет асинхронным состоянием (или, в терминах получения данных — серверным состоянием), он предполагает, что фронтенд-приложение не «владеет» данными. И это совершенно правильно. Когда мы отображаем данные, полученные из API, мы показываем лишь «снимок» этих данных — версию того, как они выглядели на момент получения. Поэтому вопрос, который мы должны себе задать:
Актуальны ли эти данные после их получения?
Ответ полностью зависит от нашей предметной области. Если мы получаем пост из Twitter со всеми его лайками и комментариями, то он, вероятнее всего, быстро устареет. Если мы загружаем обменные курсы, которые обновляются ежедневно, то наши данные будут достаточно точными какое-то время даже без повторного запроса.
React Query предоставляет средства для синхронизации нашего представления с реальным владельцем данных — бэкендом. И, делая это, он предпочитает чаще обновлять данные, чем не обновлять их достаточно часто.
До React Query
До появления таких библиотек, как React Query, было два распространенных подхода к получению данных:
-
Запросить один раз, распространить глобально, редко обновлять Именно так я часто работал с Redux. Где-то я вызываю действие, которое инициирует получение данных, обычно при монтировании приложения. После получения данных мы сохраняем их в глобальном менеджере состояния, чтобы получить к ним доступ в любом месте приложения. Ведь многим компонентам нужен доступ к нашему списку дел. Нужно ли запрашивать эти данные снова? Нет, мы их «загрузили», значит, они у нас уже есть — зачем нам их снова запрашивать? Может, если отправить POST-запрос на бэкенд, он выдаст нам «самое актуальное» состояние. Если же нужно что-то более точное, всегда можно перезагрузить страницу в браузере...
-
Запрашивать при каждом монтировании, держать локально Иногда мы можем подумать, что хранить данные в глобальном состоянии — это «слишком». Они нужны только в этом модальном окне, так почему бы не запросить их в тот момент, когда окно открывается? Вы знаете, как это делается: useEffect, пустой массив зависимостей (если ESLint ругается, можно временно отключить правило), setLoading(true) и так далее... Конечно, теперь при каждом открытии окна мы показываем индикатор загрузки, пока данные не загрузятся. Что поделаешь, локальное состояние исчезает…
Оба эти подхода довольно неоптимальны. Первый не обновляет наш локальный кеш достаточно часто, а второй потенциально выполняет повторную выборку слишком часто, а также имеет сомнительный пользовательский интерфейс, поскольку данных нет при второй выборке.
Так как же React Query решает эти проблемы?
Устаревший при повторной проверке
Возможно, вы уже слышали об этом раньше — это механизм кэширования, который использует React Query. Ничего нового — подробнее о расширениях HTTP Cache-Control для устаревшего контента можно прочитать здесь. Вкратце, это значит, что React Query кэширует данные для вас и предоставляет их, когда они вам нужны, даже если эти данные уже могут быть неактуальными (устаревшими). Принцип заключается в том, что устаревшие данные лучше, чем их отсутствие, так как отсутствие данных обычно приводит к появлению индикатора загрузки, что пользователи воспринимают как «медленное» приложение. В то же время React Query выполняет фоновую повторную выборку, чтобы актуализировать эти данные.
Умные повторные загрузки
Аннулирование кэша — это довольно сложная задача: как определить, когда именно нужно запрашивать у бэкенда новые данные? Конечно, мы не можем делать это каждый раз, когда компонент, вызывающий useQuery, повторно отрисовывается. Это было бы слишком затратно, даже по современным меркам. Поэтому React Query подходит к этому с умом и выбирает стратегические моменты для повторной выборки данных — те точки, которые служат хорошими индикаторами того, что «да, сейчас самое время получить свежие данные». Вот эти моменты:
refetchOnMount
Каждый раз, когда монтируется новый компонент, вызывающий useQuery, React Query выполняет повторную выборку данных.
refetchOnWindowFocus
Каждый раз, когда вы возвращаетесь к вкладке браузера, происходит повторная выборка. Это мой любимый момент для проверки актуальности данных, но его часто недооценивают. Во время разработки мы часто переключаем вкладки, поэтому может показаться, что обновлений слишком много. Однако в рабочей среде это означает, что пользователь, который оставил наше приложение открытым в одной из вкладок, теперь вернулся после проверки почты или просмотра Twitter. Показ последних обновлений будет полезен в такой ситуации.
refetchOnReconnect
Если вы потеряли соединение с сетью и восстановили его, это тоже хороший момент для повторной проверки актуальности данных на экране. Наконец, если вы как разработчик знаете подходящее время для повторной выборки, вы можете вызвать ручное аннулирование через queryClient.invalidateQueries. Это особенно удобно после выполнения мутации.
Позвольте React Query творить чудеса
Мне нравятся эти значения по умолчанию, но, как я уже говорил, они направлены на поддержание актуальности данных, а не на минимизацию количества сетевых запросов. Это в основном потому, что значение staleTime по умолчанию равно нулю, что означает, что каждый раз, когда, например, монтируется новый экземпляр компонента, будет запускаться фоновая повторная выборка данных. Если это происходит часто, особенно при монтировании в быстрой последовательности, не находящейся в одном цикле рендеринга, вы можете увидеть много запросов в сети. Это связано с тем, что React Query не может устранить дублирование выборок в таких ситуациях.
1 function ComponentOne() {
2 const { data } = useTodos()
3
4 if (data) {
5 // ⚠️ mounts conditionally, only after we already have data
6 return <ComponentTwo />
7 }
8 return <Loading />
9 }
10
11 function ComponentTwo() {
12 // ⚠️ will thus trigger a second network request
13 const { data } = useTodos()
14 }
15
16 const queryClient = new QueryClient()
17
18 function App() {
19 return (
20 <QueryClientProvider client={queryClient}>
21 <ComponentOne />
22 </QueryClientProvider>
23 )
24 }Что здесь происходит, я только что получил свои данные 2 секунды назад, почему происходит еще один сетевой запрос? Это безумие!
— Законная реакция при первом использовании React Query
В этот момент может показаться хорошей идеей либо передать данные через props, либо поместить их в React Context, чтобы избежать "сверления" props, либо просто отключить флаги refetchOnMount и refetchOnWindowFocus, ведь этих выборок слишком много!
В целом, передача данных через props — это вполне нормальный подход. Это явное и понятное решение, которое хорошо сработает для приведенного выше примера. Но что, если мы слегка изменим пример в сторону более реалистичной ситуации:
1 function ComponentOne() {
2 const { data } = useTodos()
3 const [showMore, toggleShowMore] = React.useReducer(
4 (value) => !value,
5 false
6 )
7
8 // yes, I leave out error handling, this is "just" an example
9 if (!data) {
10 return <Loading />
11 }
12
13 return (
14 <div>
15 Todo count: {data.length}
16 <button onClick={toggleShowMore}>Show More</button>
17 // ✅ show ComponentTwo after the button has been clicked
18 {showMore ? <ComponentTwo /> : null}
19 </div>
20 )
21 }В этом примере наш второй компонент (который также зависит от данных todo) будет монтироваться только после того, как пользователь нажмет кнопку. Теперь представьте, что пользователь нажмет на эту кнопку через несколько минут. Разве не было бы полезно в этой ситуации выполнить фоновую повторную выборку, чтобы увидеть актуальные значения списка todo?
Это было бы невозможно, если бы вы выбрали один из вышеупомянутых подходов, которые, по сути, обходят задуманный механизм React Query.
Так как же нам и "пирог сохранить, и съесть его"?
Настройка staleTime
Возможно, вы уже догадались, в каком направлении я двигаюсь: решением будет установить staleTime на значение, которое подходит для вашего конкретного случая использования. Главное, что нужно помнить:
Пока данные актуальны, они всегда будут поступать только из кэша. Вы не увидите сетевой запрос для актуальных данных, независимо от того, как часто вы хотите их получить.
Также не существует «правильного» значения для staleTime. Во многих случаях значения по умолчанию работают отлично. Лично я предпочитаю устанавливать его минимум на 20 секунд, чтобы дедуплицировать запросы в этом временном интервале, но это полностью зависит от ваших предпочтений.
Бонус: использование setQueryDefaults
Начиная с версии 3, React Query поддерживает отличный способ установки значений по умолчанию для каждого ключа запроса через QueryClient.setQueryDefaults. Следуя шаблонам, которые я описал в #8: Эффективные ключи запроса в React, вы сможете устанавливать значения по умолчанию для любой желаемой детализации, поскольку передача ключей запроса в setQueryDefaults следует стандартному частичному соответствию, аналогично тому, как это происходит с фильтрами запросов:
1 const queryClient = new QueryClient({
2 defaultOptions: {
3 queries: {
4 // ✅ globally default to 20 seconds
5 staleTime: 1000 * 20,
6 },
7 },
8 })
9
10 // 🚀 everything todo-related will have
11 // a 1 minute staleTime
12 queryClient.setQueryDefaults(
13 todoKeys.all,
14 { staleTime: 1000 * 60 }
15 )Примечание о разделении интересов
Казалось бы, вполне обоснованное беспокойство заключается в том, что добавление хуков, таких как useQuery, к компонентам всех уровней вашего приложения смешивает обязанности того, что должен делать компонент. В старые времена шаблон "умных и глупых" компонентов, а также "контейнерных и презентационных", был повсеместным. Он обещал четкое разделение, разъединение, повторное использование и простоту тестирования, поскольку презентационные компоненты просто "получали пропсы". Это также привело к большому количеству "сверлений" пропсов, сложным для статической типизации шаблонам (👋 компоненты высшего порядка) и произвольным разделениям компонентов.
С появлением хуков ситуация сильно изменилась. Теперь вы можете использовать useContext, useQuery или useSelector (если вы используете Redux) повсюду, и таким образом внедрять зависимости в свой компонент. Можно утверждать, что это делает ваш компонент более связанным. Однако также можно сказать, что он стал более независимым, поскольку вы можете свободно перемещать его в вашем приложении, и он будет работать сам по себе.
Я с уверенностью рекомендую посмотреть "Hooks, HOCs и компромиссы" (⚡️) / React Boston 2019 от поддерживающего Redux Марка Эриксона.
В итоге, это все компромиссы. Бесплатного обеда не бывает. То, что может сработать в одной ситуации, может не сработать в другой. Должен ли повторно используемый компонент Button выполнять выборку данных? Вероятно, нет. Имеет ли смысл разделять вашу панель управления на DashboardView и DashboardContainer, который передает данные вниз? Также, вероятно, нет. Поэтому нам нужно понимать компромиссы и применять правильный инструмент для правильной задачи.
Выводы
React Query отлично подходит для управления асинхронным состоянием на глобальном уровне вашего приложения, если вы ему это позволите. Отключайте флаги refetch только в том случае, если вы уверены, что это имеет смысл для вашего варианта использования, и не поддавайтесь искушению синхронизировать данные с другим менеджером состояния. Обычно настройка staleTime — это все, что вам нужно для обеспечения отличного пользовательского опыта и контроля частоты фоновых обновлений.
На сегодня все. Не стесняйтесь обращаться ко мне в bluesky, если у вас есть какие-либо вопросы, или просто оставьте комментарий ниже. ⬇️