Статья о том, как фронтенд-команде компании Чиббис, выдалась возможность построить с нуля новый проект и использовать в нем новые(для нас в компании) подходы и инструменты, в частности React-Query(про FSD и Tramvai в следующих статьях). Какие преимущества нам дал RQ, нашлись ли недостатки, целесообразность использования его в новых и существующих проектах.
Общая информация, предыстория, терминология
Этим летом команда Чиббиса успешно выпустила новый проект - обновленный личный кабинет партнера (ЛКП). Это приложение, которым пользуются наши партнеры - рестораны для работы с сервисом. Там они могут работать со своими заведениями (открывать/закрывать, обрабатывать заказы и работать с меню). Нашей основной целью было отказаться от старого ЛК, верой и правдой прослужившего компании 9 лет, но неизбежно превратившегося в сложно поддерживаемое легаси. При этом в разработке нового приложения мы ставили себе также и исследовательские задачи: попробовать новый стек и новые подходы к разработке. Мы хотели, чтобы при удачном раскладе новый ЛКП стал образцово показательным проектом, по образу и подобию которого мы бы подались в рефакторинг существующих и создание новых продуктов. В результате предварительно проделанной исследовательской работы в качестве основного фреймворка мы выбрали Tramvai, а архитектуру приложения решили строить по FSD-подходу. Помимо этого мы решили пересмотреть работу со стейт-менеджментом в приложении, о чем подробнее и расскажу.
Исторически сложилось, что на web-проектах в Чиббисе для стейт-менеджмента используется Redux. Redux в целом справляется с возложенными на него обязанностью хранить стор приложения, однако тянет за собой большое количество бойлер-плейта. Разработчики, работавшие с существенным количеством редьюсеров поймут, о чем речь: файлы констант, экшенов, селекторов, структурных селекторов, редюсеров, типов и т.д. могут занимать сотни файлов и тысячи строк.
Поскольку при разработке нового проекта целью было найти способы оптимизации кодовой базы, упростить работу и повысить производительность разработчиков, появилась идея попробовать работать не с общим, а с разделенным на серверную и клиентскую части стором. Так мы перешли на React-Query.
React-Query (далее RQ) - JavaScript-библиотека, упрощающая работу с получением и кэшированием данных в React-приложении. Разработана компанией TanStack и активно развивается: в 2020 году была выпущена версия 1.0, а текущая версия - 5.0.
Клиентский стор (локальный) отвечает за хранение состояния ui-компонетов и шаринг этого состояния между ними: показ/скрытие, счетчики (например, таймер на форме), выбор пользователя и т.д. Например, модальные окна часто нуждаются в глобальном сторе, чтобы их можно было скрывать и показывать из любого компонента.
Серверный стор нужен для хранения на серверной стороне данных, необходимых для использования в веб-приложении: профиль пользователя, список товаров и т.д. Особенностью работы с серверным стором является необходимость учитывать состояние запросов по конкретным данным (успех, в процессе, ошибка и т.п.), на хранение которых отводятся (обычно) отдельные ключи в сторе.
Идея об отдельном сторе для серверных данных появилась неслучайно: по мере роста приложений их общие сторы сильно разрастаются, в основном за счет данных, полученных по API с бэкенда.
На рисунке ниже представлена часть стора реального приложения. Видно, что серверных данных гораздо больше, чем клиентских. Редьюсер, хранящий ответы API, содержит также ключ “ui“, в котором хранится состояние запроса.
Пример общего стора
Посмотрим, как реализована работа с запросами в приложении. Рассмотрим пример получения неоплаченных заказов:
// ...imports
export const unpaidInfoEpic: TFetchUnpaidInfoEpic = (action$) =>
action$.pipe(
ofType(CHECKOUT_UNPAID_INFO_FETCH),
debounceTime(REQUEST_MS),
mergeMap(({ payload }) =>
concat(
of(checkoutUnpaidInfoFetchPending()),
combineLatest([timer(MIN_LOADING_MS), fetchUnpaidInfo(payload)]).pipe(
map((x) => x[1]),
switchMap((response) => {
const { status, response: data } = response;
if ([200].includes(status)) {
const result = [
checkoutUnpaidInfoFetchSuccess({
status,
}),
checkoutUnpaidInfoSave(data),
];
return result;
}
return [
checkoutUnpaidInfoFetchError({ status }),
];
}),
catchError((error) => {
const { status } = error;
console.log(error);
return [
checkoutUnpaidInfoFetchError({ status }),
];
}),
),
)),
);
Этот код выполняет свои обязанности: он посылает запрос к API, получает данные и сохраняет их в нужное место, параллельно записывая состояние запроса.
Код не существует в вакууме, для его работы нужна “обвязка“ из actions (делаем изменения в редьюсере), constants, reducer (реагирует на actions), selectors (для удобного получения данных компонентами и их (данных) мемоизации). Так же не стоит забывать, что для всего нужны еще и типы.
Взяв один конкретный пример работы с запросом - unpaidInfoEpic, я посчитал количество обслуживающего только его кода (без учета функции обращения к апи fetchUnpaidInfo ): получилось…
...более 280 строк! Многовато для простого похода за данными на сервер. Хотя объем кода - это не проблема и нет цели именно в сокращении его объемов. Однако разработка идет вперед, в современных реалиях приложение должно легко модифицироваться и масштабироваться. Хорошо бы как то упростить процесс взаимодействия с API.
Преимущества React-Query
Итак, вернемся к RQ. В чем же состоят его сильные стороны?
Забирает на себя работу по хранению серверного состояния, синхронизацию хранимых данных и упрощает работу с запросами к API
Простота использования
Кэширование данных
Автоматическая инвалидация
Отслеживание статуса запроса в режиме реального времени
Возможности более тонкой настройки
Интеграция с React
Рассмотрим эти преимущества поподробнее.
Одним из главных плюсов RQ является простота его использования. Библиотека предоставляет интуитивно понятный API и простые концепции, которые делают работу с асинхронными запросами и управлением состоянием очень удобной. Рассмотрим основные аспекты, которые делают RQ простым в использовании:
Хуки для работы с данными: useQuery, useMutation, usePaginatedQuery и другие хуки упрощают выполнение запросов и получение данных в компонентах React.
Декларативный подход: при использовании RQ нет необходимости писать много кода для управления состоянием и выполнения запросов. Библиотека позволяет декларативно описывать данные, которые вам нужны, и предоставляет простые способы их получения и обновления, освобождая от рутинных задач.
Встроенное кэширование: RQ автоматически кэширует полученные данные и обновляет кэш при необходимости. Это позволяет избежать повторных запросов к серверу и снижает нагрузку на сеть и сервер. Кроме того, кэшированные данные можно легко инвалидировать и обновлять при изменении данных на сервере, а так же отключить для тех запросов, где они не нужны.
Умное управление состоянием: библиотека предоставляет удобные способы управления состоянием при выполнении асинхронных операций. RQ позволяет легко отслеживать статус запроса (загрузка, успех, ошибка), обрабатывать и отображать соответствующие состояния в пользовательском интерфейсе.
Расширяемость: RQ предоставляет API для настройки и расширения его функциональности в соответствии с вашими потребностями. Вы можете настроить параметры запросов, управлять временем жизни кэша, расширять логику обновления данных и добавлять дополнительные функции.
Все это делает RQ очень простым в использовании и интуитивно понятным инструментом для разработчиков. Библиотека позволяет сосредоточиться на создании функционала, не тратя много времени на рутинные задачи по работе с данными и управлению состоянием.
Немного практики
Вернёмся к примеру с неоплаченными заказом. Попробуем реализовать его, используя RQ.
Запрос к API fetchUnpaidInfo уже реализован, менять его нет необходимости. Нет нужды и в каких-то редьюсерах, коннекторах к стору и экшенах - будем получать данные непосредственно в компоненте:
const UnpaidOrder = ({ orderId }) => {
const { data, isLoading, error } = useQuery(['unpaidOrder', orderId], () =>
fetchUnpaidInfo(orderId);
);
if (isLoading) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div>{data.order}</div>
);
};
Использовав хук useQuery, мы получили то, что хотели, написав только саму функцию обращения к API. Весь код обработки запроса свелся к 3м строчкам (2 - 4 строки). RQ забрал на себя: получение данных, отслеживание статуса, а также хранение данных.
Вы спросите: "А если полученные данные понадобятся в другом месте приложения? Как мы их расшарим между компонентами?"
Очень просто. Библиотека хранит (кэширует) по ключу 'unpaidOrder' нужные нам данные - как сам неоплаченный заказ, так и состояние запроса, поэтому в любом другом компоненте мы вызываем следующий query:
const { data } = useQuery(['unpaidOrder'], () =>
fetchUnpaidInfo(orderId);
);
и получаем данные заказа или состояние запроса.
При этом не важно, существует ли еще инстанс нашего самого первого запроса, RQ автоматически предоставит нам всё, что нужно в любом месте приложения, при любом количестве вызовов useQuery для ключа 'unpaidOrder'.
Механизм работы
При первом вызове useQuery происходит следующее:
Отправляется запрос к серверу (fetchUnpaidInfo)
состояние query - isLoading (isFetching). Можем отрендерить лоадер.
Получен ответ от сервера (состояние query isError || isSuccess || ….). Можем рендерить данные.
Полученный результат кэшируется по ключу ['unpaidOrder'] (по умолчанию cacheTime = 5 минут)
результат сразу маркируется как stale (по умолчанию staleTime = 0)
При втором вызове useQuery в другом компоненте до истечения cacheTime (для того же ключа):
Данные лежат в кэше, поэтому мгновенно доступны. Можем запускать рэндер.
Так как параметр stale равен 0, данные считаются устаревшими и RQ считает нужным в фоне сделать запрос для обновления состояния. Имеем isFetching == true, isLoading == false - это важный для UI нюанс.
запрос уходит
Состояние обновилось, обновился кэш и время жизни записи в кэше вновь становится 5 минут
При третьем вызове после истечения cacheTime:
Garbage Collector получил оповещение удалить данные по ключу 'unpaidOrder'
-
При повторном вызове начинаем всё сначала
Схема работы RQ
Подробнее о механизме кэширования, инвалидации кэша и его настройке расскажу в следующий раз.
Минусы
В уже существующий проект мы привносим новую библиотеку со своей философией и правилами работы. Их необходимо держать в голове при проектировании и рефакторинге.
Не является полноценной заменой классического стора (Redux, Mobx), так как не предназначен для хранения клиентских данных.
Хранилище не персистентное по умолчанию. Важно настраивать время кэширования (данные могут “протухать”)
Возможное усложнение онбординга новых сотрудников (+1 технология для изучения)
Выводы
Сейчас, когда запуск личного кабинета партнера состоялся, и начался процесс эксплуатации - я понимаю, что мы не ошиблись, выбрав React-Query. Инструмент избавил нас от менеджа серверного стейта, в разы сократив кодовую базу. Нам не нужно думать о состоянии запроса, о хранении серверных данных, достаточно помнить о времени жизни кэша и, при необходимости, делать инвалидацию. Использование RQ значительно облегчает процесс доставки и хранения данных между сервером и клиентом. Важным плюсом считаю, что интеграция может проходить поэтапно, без возникновения серьёзных проблем.
Как известно, нет предела совершенству, связка Redux - React-Query, покрывает все наши потребности, но, уже есть мысли об отказе от Redux, в пользу чего то попроще (React Context ?). Напишу что получилось - в следующх статьях.
Ссылки
Официальная документация: Overview | TanStack Query React Docs
The consequences of using State over Cache and its impact on data consistency.
Комментарии (18)
jakobz
15.12.2024 06:10Мне показалось что в Tanstack Query - странный набор фичей для такого хука. Мне, например, непонятно зачем нужен кеш везде и всюду. С другой стороны, я бы хотел встроенный debouncing запросов. Ну и намучался я с ленивой подгрузкой с кешем.
Хочется чего-то такого же, но более модульного. В базе мне хватит и штуки без кеша совсем. Я на одном маленьком проектике сам себе накидал, и для меня даже удобнее вышло.
kacetal
15.12.2024 06:10Ну по усолчанию он вообще отключен staleTime равно 0 насколько я помню. Так что если вам он не нужен, просто не устанавливайте его.
markelov69
15.12.2024 06:10но, уже есть мысли об отказе от Redux, в пользу чего то попроще (React Context ?)
React Context? Вы прикалываетесь? Ясно понятно.
MobX? Не, не слышал.Alex_D_L Автор
15.12.2024 06:10или Effector, или - да в целом выбор то богатый. Про это напишу заметку - о том что выбрали, и какие плюсы / минусы были найдены. Про RC я упомянул - потому что захотелось внимательнее оценить использование общего клиентского стэйта - если его будет мало - может и не стоит оно того- чтобы тащить еще один стейт менеджер
Mox
15.12.2024 06:10Я не совсем понял вот что. У вас был императивный код, который вы могли вызывать из какого-то обработчика, и делать модификации, в зависимости от ответа бэка - делать еще какую-то логику. А то что я вижу - это только fetch данных в компоненте, а как вы модифицируете?
Alex_D_L Автор
15.12.2024 06:10для мутации данных на сервере - есть https://tanstack.com/query/v4/docs/framework/react/guides/mutations (если я верно вопрос понял) При мутации - можно также отображать процесс отправки запроса (условный лоадер) - или не отображать сделав optimistic update
Mox
15.12.2024 06:10Cпасибо!
А еще пара use-cases
Я пишу на React Native, есть еще 2 вещи, не связанные с монтированием компонента
- Вывод приложения из фона - в этот момент я делаю event listener и обновляю состояние некоторых сущностей.
- Фокусировка экрана. На мобиле есть "стек" экранов и компонент может быть на экране, который где-то внизу стека. Я сделал свайп назад - экран получил событие "фокус" - (например изменилось значение isFocused = useIsFocused() )
Вообщем все сводится к тому - можно ли из useEffect вызывать получение данных с сервера?Alex_D_L Автор
15.12.2024 06:10Хуки в useEffect не вызываются. Не силен в нэйтив - но как я понял из контекста вопроса - ремаунта компонента при свайпе не будет (а значит и вызова useQuery повторно), но есть необходимость заново получить данные серверва. Если в момент свайпа и показа его что то меняется (хз - индекс показываемого стэка или еще чтото) - то можно его прописать в ключи квери - queryKey: ['updateSomeServerDataKey', index], тогда с каждым новым index (точнее с изменением) - квери апдейтнется
levanevskogo79
15.12.2024 06:10У кверей есть колбеки onSuccess, onMutate или onError - можно в них передавать на глобальном уровне логику или в каждом конкретном месте определять
Grikus
15.12.2024 06:10Да ваще фигня. Ради того чтобы отлавливать состояние запроса - тащить целую библу.
Что мешает создать хук для работы с запросами и там отслеживать состояние абсолютно любого запроса. И пробрасывай в сервис хук его, а уже оттуда в нужный компонент, где вызывается запрос. Все! Там же в кастомном хук и работу с ошибками прописать можно, если грамотно сделать работу на бэке, то текст ошибок можно присылать по одной и той же структуре и выводить юзеру для понимания че случилось
Весь огород этот городить хз, для хранения глобального состояния даже не всегда нужен редукс или мобх, можно провайдеры создать и хранить глоб стейт.
Alex_D_L Автор
15.12.2024 06:10Соглашусь - можно написать хук для чего угодно, можно много чего написать самостоятельно. Но тут мы можем влететь в проблему масштабирования команды - когда приходят новые разработчики на проект, где много чего написано самостоятельно, время "вкатывания" повышается, особенно, если написавшие не оставили документации и уволились с компании... Опять же - ваш подход также рабочий
mikhailmolchanov
15.12.2024 06:10Если не секрет, почему не стали использовать RTK Query с похожей философией, если уже есть Redux на проекте?
kacetal
15.12.2024 06:10У React Query гораздо больше вещей уже готовы в добавок они сделаны с нормальным api. Хотя на первый взгляд они похоже особенно в синтетических примерах где качают пару объектов с fakejson.com. Но когда речь заходит о минимальной кастомизации RTK Query просто деревянный, обращение к кэшу у RQ гораздо проще, работа с сокетами в RTK делается во много строк кода с непонятными подписками в коллбэках конфигурации. Ленивая загрузка когда у тебя в ответе приходит параметр для следующей страницы практически невозможна, он хранит это как отдельные записи кэша а не связный список. И для всего этого приходится писать огромное количество кода. Причём для ленивой загрузке, на гитхабе, просят сделать api как у RQ но насколько я понял подвижек нет особых.
rtatarinov
Ну хрен его знает! Раньше у вас было четкое разделение слоев логики и вью. В RQ это все намешано… Раньше вы вообще могли все в эпих редакс обсервабл описывать, например работы с фильтрами и т.д. там при помощи withLatestStore забирать данные и кидать запросы на сервер! Сейчас запросы будут кидаться из компонента, и в случае например с фильтрами вам придется тащить туда 100500 селекторов, чтобы сделать один запрос… И при этом от редакса вы никуда не уходите, вам все равно придется где-то это хранить.. например, вот фильтры те же. То есть как бы бойлер плейт не уменьшается, только для запросов на сервер и все.
И еще много много вопросов! По сути ради того, чтобы не писать лишних много строк вы решили мешать логику и вью ради кеширования и меньшего количества строк.. вот и все! Интересно будет посмотреть как вы FSD туда прикрутили, где есть сегмент модель, который как раз отвечает за логику..
sovaz1997
Зачем тащить 100500 селекторов? Почему смешивается логика и вью? Проблем не вижу, можно удобно распределить по необходимости
Alex_D_L Автор
Мы сейчас очень стараемся как раз таки структурно все реализовать так - чтобы смешивания не было - как пример вдохновлялись вот этой статьей . Все технологии (fsd, rq, tramvai), мы обкатали на новом проекте, который сейчас уже в статусе "эксплуатация" - и команда признает положительный опыт - разделение по слоям и сегментам позволяет не замешивать все в одном компоненте, бойлерплэйта минимум