Статья о том, как фронтенд-команде компании Чиббис, выдалась возможность построить с нуля новый проект и использовать в нем новые(для нас в компании) подходы и инструменты, в частности 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 простым в использовании:

  1. Хуки для работы с данными: useQuery, useMutation, usePaginatedQuery и другие хуки упрощают выполнение запросов и получение данных в компонентах React.

  2. Декларативный подход: при использовании RQ нет необходимости писать много кода для управления состоянием и выполнения запросов. Библиотека позволяет декларативно описывать данные, которые вам нужны, и предоставляет простые способы их получения и обновления, освобождая от рутинных задач.

  3. Встроенное кэширование: RQ автоматически кэширует полученные данные и обновляет кэш при необходимости. Это позволяет избежать повторных запросов к серверу и снижает нагрузку на сеть и сервер. Кроме того, кэшированные данные можно легко инвалидировать и обновлять при изменении данных на сервере, а так же отключить для тех запросов, где они не нужны.

  4. Умное управление состоянием: библиотека предоставляет удобные способы управления состоянием при выполнении асинхронных операций. RQ позволяет легко отслеживать статус запроса (загрузка, успех, ошибка), обрабатывать и отображать соответствующие состояния в пользовательском интерфейсе.

  5. Расширяемость: 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)


  1. rtatarinov
    15.12.2024 06:10

    Ну хрен его знает! Раньше у вас было четкое разделение слоев логики и вью. В RQ это все намешано… Раньше вы вообще могли все в эпих редакс обсервабл описывать, например работы с фильтрами и т.д. там при помощи withLatestStore забирать данные и кидать запросы на сервер! Сейчас запросы будут кидаться из компонента, и в случае например с фильтрами вам придется тащить туда 100500 селекторов, чтобы сделать один запрос… И при этом от редакса вы никуда не уходите, вам все равно придется где-то это хранить.. например, вот фильтры те же. То есть как бы бойлер плейт не уменьшается, только для запросов на сервер и все.

    И еще много много вопросов! По сути ради того, чтобы не писать лишних много строк вы решили мешать логику и вью ради кеширования и меньшего количества строк.. вот и все! Интересно будет посмотреть как вы FSD туда прикрутили, где есть сегмент модель, который как раз отвечает за логику..


    1. sovaz1997
      15.12.2024 06:10

      Зачем тащить 100500 селекторов? Почему смешивается логика и вью? Проблем не вижу, можно удобно распределить по необходимости


    1. Alex_D_L Автор
      15.12.2024 06:10

      Мы сейчас очень стараемся как раз таки структурно все реализовать так - чтобы смешивания не было - как пример вдохновлялись вот этой статьей . Все технологии (fsd, rq, tramvai), мы обкатали на новом проекте, который сейчас уже в статусе "эксплуатация" - и команда признает положительный опыт - разделение по слоям и сегментам позволяет не замешивать все в одном компоненте, бойлерплэйта минимум


  1. jakobz
    15.12.2024 06:10

    Мне показалось что в Tanstack Query - странный набор фичей для такого хука. Мне, например, непонятно зачем нужен кеш везде и всюду. С другой стороны, я бы хотел встроенный debouncing запросов. Ну и намучался я с ленивой подгрузкой с кешем.

    Хочется чего-то такого же, но более модульного. В базе мне хватит и штуки без кеша совсем. Я на одном маленьком проектике сам себе накидал, и для меня даже удобнее вышло.


    1. kacetal
      15.12.2024 06:10

      Ну по усолчанию он вообще отключен staleTime равно 0 насколько я помню. Так что если вам он не нужен, просто не устанавливайте его.


  1. markelov69
    15.12.2024 06:10

     но, уже есть мысли об отказе от Redux, в пользу чего то попроще (React Context ?)

    React Context? Вы прикалываетесь? Ясно понятно.
    MobX? Не, не слышал.


    1. Alex_D_L Автор
      15.12.2024 06:10

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


  1. Mox
    15.12.2024 06:10

    Я не совсем понял вот что. У вас был императивный код, который вы могли вызывать из какого-то обработчика, и делать модификации, в зависимости от ответа бэка - делать еще какую-то логику. А то что я вижу - это только fetch данных в компоненте, а как вы модифицируете?


    1. Alex_D_L Автор
      15.12.2024 06:10

      для мутации данных на сервере - есть https://tanstack.com/query/v4/docs/framework/react/guides/mutations (если я верно вопрос понял) При мутации - можно также отображать процесс отправки запроса (условный лоадер) - или не отображать сделав optimistic update


      1. Mox
        15.12.2024 06:10

        Cпасибо!

        А еще пара use-cases

        Я пишу на React Native, есть еще 2 вещи, не связанные с монтированием компонента
        - Вывод приложения из фона - в этот момент я делаю event listener и обновляю состояние некоторых сущностей.
        - Фокусировка экрана. На мобиле есть "стек" экранов и компонент может быть на экране, который где-то внизу стека. Я сделал свайп назад - экран получил событие "фокус" - (например изменилось значение isFocused = useIsFocused() )

        Вообщем все сводится к тому - можно ли из useEffect вызывать получение данных с сервера?


        1. Alex_D_L Автор
          15.12.2024 06:10

          Хуки в useEffect не вызываются. Не силен в нэйтив - но как я понял из контекста вопроса - ремаунта компонента при свайпе не будет (а значит и вызова useQuery повторно), но есть необходимость заново получить данные серверва. Если в момент свайпа и показа его что то меняется (хз - индекс показываемого стэка или еще чтото) - то можно его прописать в ключи квери - queryKey: ['updateSomeServerDataKey', index], тогда с каждым новым index (точнее с изменением) - квери апдейтнется


    1. levanevskogo79
      15.12.2024 06:10

      У кверей есть колбеки onSuccess, onMutate или onError - можно в них передавать на глобальном уровне логику или в каждом конкретном месте определять


  1. Grikus
    15.12.2024 06:10

    Да ваще фигня. Ради того чтобы отлавливать состояние запроса - тащить целую библу.

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

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


    1. Alex_D_L Автор
      15.12.2024 06:10

      Соглашусь - можно написать хук для чего угодно, можно много чего написать самостоятельно. Но тут мы можем влететь в проблему масштабирования команды - когда приходят новые разработчики на проект, где много чего написано самостоятельно, время "вкатывания" повышается, особенно, если написавшие не оставили документации и уволились с компании... Опять же - ваш подход также рабочий


  1. mikhailmolchanov
    15.12.2024 06:10

    Если не секрет, почему не стали использовать RTK Query с похожей философией, если уже есть Redux на проекте?


    1. Alex_D_L Автор
      15.12.2024 06:10

      Возможно он и заедет - НО - есть большой план уехать на Tramvai (мотивация и прочее - отдельной заметкой) - а там есть свой стэйт менеджер для клиента


    1. kacetal
      15.12.2024 06:10

      У React Query гораздо больше вещей уже готовы в добавок они сделаны с нормальным api. Хотя на первый взгляд они похоже особенно в синтетических примерах где качают пару объектов с fakejson.com. Но когда речь заходит о минимальной кастомизации RTK Query просто деревянный, обращение к кэшу у RQ гораздо проще, работа с сокетами в RTK делается во много строк кода с непонятными подписками в коллбэках конфигурации. Ленивая загрузка когда у тебя в ответе приходит параметр для следующей страницы практически невозможна, он хранит это как отдельные записи кэша а не связный список. И для всего этого приходится писать огромное количество кода. Причём для ленивой загрузке, на гитхабе, просят сделать api как у RQ но насколько я понял подвижек нет особых.


      1. mikhailmolchanov
        15.12.2024 06:10

        Благодарю за подробный ответ.