Мы слишком много используем redux-селекторы

Автор оригинала: Guillaume Wuip
  • Перевод

Когда я заглядываю в файл {domain}/selectors.js в больших проектах на React/Redux, с которыми работаю, я часто встречаю огромный список redux-селекторов подобного вида:


getUsers(state)
getUser(id)(state)
getUserId(id)(state)
getUserFirstName(id)(state)
getUserLastName(id)(state)
getUserEmailSelector(id)(state)
getUserFullName(id)(state)
…

На первый взгляд использование селекторов не выглядит чем-то необычным, но с опытом мы начинаем понимать — селекторов может накопиться слишком много. И кажется, мы дожили до этого момента.


image

Redux и селекторы


Давайте рассмотрим Redux. Что он такое, зачем? Почитав сайт redux.js.org мы понимаем, что Redux — это "предсказуемый контейнер для хранения стейта приложений на JavaScript"


При использовании Redux нам рекомендуется использовать селекторы, даже если они не обязательны. Селекторы — это просто геттеры для получения некоторых частей из целого стейта, т.е. функции вида (State) => SubState. Обычно мы пишем селекторы чтобы не обращаться к стейту напрямую, а так же чтобы потом можно было комбинировать или мемоизировать результаты работы этих селекторов. Звучит разумно.


Сильно погружаясь в селекторы


Список селекторов, что я привел в введении к этой статье, носит характерные признаки кода, созданного в спешке.


Представьте, что у нас есть модель User, и мы захотим добавить к ней новое поле email. У нас есть компонент, который ожидал на вход firstName и lastName, а сейчас он станет ожидать еще email. Следуя логике, заложенной в коде с селекторами, вводя новое поле email автор должен добавить селектор getUserEmailSelector и использовать его для передачи этого поля в компонент. Бинго!


Но "бинго" ли? А если у нас появится еще один селектор, который будет более сложным? Мы скомбинируем его с другими селекторами, и возможно придем к такой картине:


const getUsers = (state) => state.users;
const getUser = (id) => (state) => getUsers(state)[id];
const getUserEmailSelector = (id) => (state) => getUser(id)(state).email;

Возникает первый вопрос: что должен вернуть селектор getUserEmailSelector в случае, если селектор getUser вернет undefined? А это вероятная ситуация — баги, рефакторинг, легаси — все может привести к ней. Вообще говоря, это ни разу не задача селекторов — обрабатывать ошибки или предоставлять значения "по-умолчанию".


Вторая проблема возникает с тестированием таких селекторов. Если мы захотим покрыть их юнит-тестами — нам понадобятся mock-данные, идентичные данным с продакшна. Мы должны будем использовать mock-данные всего стейта (т.к. на продакшне состояние не может быть неконсистентным) всего лишь для одного этого селектора. Это, в зависимости от архитектуры нашего приложения может быть весьма неудобно — затаскивать данные в тесты.


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


const mapStateToProps = (state, ownProps) => ({
  firstName: getUserFirstName(ownProps.userId)(state),
  lastName: getUserLastName(ownProps.userId)(state),

  // новые данные
  email: getUserEmailName(ownProps.userId)(state),
})

Следуя перечисленной выше логике мы и получили ту кучу селекторов, что была в начале статьи.
Мы зашли слишком далеко. В итоге мы написали псевдо-API для сущности User. Это API невозможно использовать вне контекста Redux, потому что оно требует полного слепка стейта. Кроме того, это API сложно расширять — при добавлении новых полей к сущности User мы должны создавать новые селекторы, добавлять их в mapStateToProps, писать все больше boilerplate-кода.


А может стоит обращаться к полям сущности напрямую?


Если проблема заключается только в том, что у нас слишком много селекторов — может быть нам просто использовать getUser и обращаться к нужным нам свойствам сущности напрямую?


const user = getUser(id)(state);
const email = user.email;

Такой подход решает проблему написания и поддержки огромного количества селекторов, однако создает другую проблему. Если нам понадобится изменить модель User, мы так же должны будем проконтролировать все места, где встречается user.email (примечание переводчика или другое изменяемое нами поле). При большом объема кода в проекте это может стать сложной задачей и затруднить даже небольшой рефакторинг. Когда у нас был селектор — он защищал нас от таких последствий изменений, т.к. брал на себя обязанность работы с моделью и код, использующий селектор, ничего о модели не знал.


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


image

Модель предметной области — во главе. Redux — вторичен


Прийти к такой картине можно ответив на два вопроса:


  • Как мы определим нашу модель предметной области?
  • Как мы будем хранить данные? (стейт-менеджмент, для него используем redux *примечание переводчика* то, что в DDD зовется persistence layer)

Отвечая на вопрос "Как мы определим модель предметной области" (в нашем случае User) давайте абстрагируемся от redux и решим, что такое "пользователь" и какое API необходимо для взаимодействия с ним?


// api.ts
type User = {
  id: string,
  firstName: string,
  lastName: string,
  email: string,
  ...
}

const getFirstName = (user: User) => user.firstName;
const getLastName = (user: User) => user.lastName;
const getFullName = (user: User) 
  => `${user.firstName} ${user.lastName}`;
const getEmail = (user: User) => user.email;
...
const createUser = (id: string, firstName: string, ...) => User;

Будет хорошо, если мы всегда будем использовать это API и считать модель User недоступной за пределами файла api.ts. Это значит, что мы никогда не обратимся напрямую к полям сущности т.к. код, который использует API даже не знает, какие у сущности есть поля.


Теперь мы можем вернуться к Redux и решить вопросы, касающиеся только стейта:


  • Какое место занимают пользователи в нашем стейте?
  • Как мы должны хранить пользователей? Списком? Словарем (key-value)? Как-нибудь иначе?
  • Как мы будем получать экземпляр User из стейта? Использовать ли мемоизацию? (в контексте селектора getUser)

Маленькое API с большими преимуществами


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


Хорошо документированная модель предметной области (модель User и её API) в файле api.ts. Она хорошо поддается тестированию, т.к. не имеет никаких зависимостей. Мы можем извлечь модель и API в библиотеку для переиспользования их в других приложениях.


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


Не произошло никакой магии и с API, оно все так же выглядит понятным. API имеет сходство с тем, что было сделано с помощью селекторов, но имеет одно ключевое отличие: ему не требуется весь стейт целиком, не нужно больше поддерживать полный стейт приложения для тестирования — API никак не связано с Redux и его boilerplate-кодом.


Пропсы компонента стали более чистыми. Вместо ожидания на вход свойств firstName, lastName и email компонент получает экземпляр User и внутри себя использует её API для доступа к нужным данным. Получается, нам нужен всего один селектор — getUser.


Есть польза и для редьюсеров и мидлварей от такого API. Суть пользы в том, что можно сперва получить экземпляр User, разобраться с недостающими в ней значениями, обработать или предотвратить все ошибки, а уже после этого использовать методы API. Это лучше, чем использовать получение каждого отдельного поля с помощью селекторов в отрыве от предметной области. Таким образом Redux действительно становится "предсказуемым контейнером" и перестает быть "божественным" объектом со знанием обо всем.


Заключение


Благими намерениями (здесь читай — селекторами) вымощена дорога в ад: мы не хотели обращаться к полям сущности напрямую и сделали для этого отдельные селекторы.


Хотя сама идея селекторов хороша, её чрезмерное использование ведет к сложности в поддержке нашего кода.


Описанное в статье решение предлагает решать задачу в два этапа — сперва опишите доменную модель и её API, затем разберитесь с тем, что касается Redux (хранение данных, селекторы). Таким образом вы напишете более качественный и меньший по объему код — нужен будет всего один селектор для создания более гибкого и масштабируемого API.


Примечания переводчика


  1. Я использовал слово стейт, тк кажется оно достаточно прочно вошло в лексикон русскоговорящих разработчиков.
  2. Автор использует слова upstream/downstream для обозначения "высокоуровневый/низкоуровневый код"(если по Мартину) или "код, которым пользуются ниже/код уровнем ниже, который использует то, что написано выше", но корректно придумать как использовать это в переводе я не смог, поэтому утешаю себя тем, что постарался не нарушить общего смысла.

Замечания и предложения по исправлениям с удовольствием приму в личку и поправлю.

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 11

    0

    Мы использовали подобные redux механизмы, а про redux я просто читал общее описание. Поэтому может глупые вопросы позадаю.


    мне не нравится вот этот паттерн


    const user = getUser(id)(state);

    правильно я понимаю что state — это [условно] глобальная переменная из которой достаются собственно эти юзеры итп?


    почему не спрятать это куда-то?


    и ещё. да мы получаем сущность из стейтхранилища по какому-то id. На практике, как правило эти id являются константами.


    Например


    мы храним в хранилище


    • профиль залогиненного юзера (константа 'current_user' — для упрощения строковая)
    • состояние меню сайта (какой пункт выбран) (константа 'menu')

    итп


    и вот и для выборки и для изменений хранилища не лучше ли использовать паттерн


    user = storage('current_user');
    //....
    
    storage('current_user', new_current_user);

    и еще надо подписать заинтересованные стороны на изменение по current_user. Как-то так.


    а state снаружи — это какая-то внутренняя кухня видная пользователю. Нет?

      0
      правильно я понимаю что state — это [условно] глобальная переменная из которой достаются собственно эти юзеры итп?

      Да, верно. Redux он про то, что есть огромный объект, который хранит абсолютно все данные вашего приложения и он иммутабелен. Ну, там потом вариации с multi-store есть, но общий смысл именно таков. Главные бонусы redux — это изменение стейта через маленькие редьюсеры и EventSourcing.

      В вашем примере кажется все примерно то же самое, только в другой форме и другими словами :)
      и вот и для выборки и для изменений хранилища не лучше ли использовать паттерн

      Это похоже на линзу. А линза — это селектор + установка значения. Т.е. похоже )
      подписать заинтересованные стороны на изменение

      это то, что в redux делать mapStateToProps — эта функция создает компонент-обертку над вашим компонентом, в которой каждый раз когда стейт меняется — она достает из него нужные данные и перерисовывает с ними компонент. Изменился стейт, redux знает все места, где был mapStateToProps, вызывает эти места.

      а state снаружи — это какая-то внутренняя кухня видная пользователю. Нет?

      Не понял вопрос…

      От себя добавлю, что мы в отличии от автора оригинальной статьи у себя вообще не используем Redux, обходимся выделением предметных областей и в них в соответствии с заветами DDD уже делаем хранение, предметные службы, прикладные службы, вот это все. А с JS/TS/React это подружить уже дело техники. У нас для передачи данных из домена в компоненты работает RxJS.
        0
        Не понял вопрос…

        я про семантику и инкапсуляцию.


        разбиваем наш код на 100500 мелких JS файлов. и все они зависят на то что есть такая переменная, названная state.


        причем из 100500 мест где обращаемся к этому state 99% — это обращения на чтение (плюс подписки на изменение) и 1% — это изменение этого стейта.


        соответственно если чисто семантически построить над этим надстройку


        storage(NAME);   // возвращает значение из хранилища по имени
        storage.set(NAME, new_value);  // устанавливает значение
        storage.on(NAME, my_handler);   // устанавливает подписку

        то не будет ли это удобнее?


        а state — где-то есть в виде уже локальной переменной одного из 100500 модулей.


        нет?

          0
          Теперь понял :)

          Да, подход, который вы предлагаете — хорош, и мы подобным как раз пользуемся(только не в самом UI, а в службах), и конечно этот storage должен быть инкапсулирован внутри домена, чтоб coupling не повышать.

          Т.е. у нас есть слой хранения данных, он доступен только слою бизнес-логики, а слой бизнес-логики уже имеет порты для того, чтобы с ним UI связался. Упрощенная схема, в жизни чуть посложнее :)
            0
            Более того, react-redux подход практически так и работает.

            // глобальный Store (State)
            const store = createStore(todoApp)
            
            render(
              <Provider store={store}>
                <App />          <-- все дочерние компоненты, которые зависят от Store
              </Provider>,
              document.getElementById('root')
            )
            

            источник: redux.js.org/basics/usage-with-react

            Есть некий глобальный Store (со своим глобальным State), а есть локальные State внутри каждого конкретного компонента. Глобальный State инкапсулирован в компоненте Provider, а все остальные компоненты добавляются как дочерние по отношению к Provider.

            Компонент Provider подписывается на изменение внутри Store/State и отвечает за обновление всех подписанных (через high order component react-redux: connect()) компонентов.

            Селектор – это маппер данных из глобального State в локальный State конкретного компонента (добавляется обычно через ту же функцию connect()).

            При необходимости изменить глобальный State, кидается событие (event) с конкретным типом (action: redux.js.org/basics/actions). Это событие обрабатывается, изменяет глобальный State и приводят к оповещению всех подписанных на изменения и вызову селекторов (см. выше).
          +1
          правильно я понимаю что state — это [условно] глобальная переменная из которой достаются собственно эти юзеры итп?

          В оригинальной концепции используется понятие Store, у которого уже есть текущий State.

          Этот Store создается в точности как Вы описали: let store = createStore(counter)
          и на него вешается подписчик изменений: store.subscribe(() => console.log(store.getState()))
          в первом же примере это показано: redux.js.org/introduction/getting-started

          Но так как State внутри Store – неизменяемый, то при необходимости изменить какое-то значение внутри State создается новый объект State, соответственно подписчик (listener) вешается на изменение всего State.
          А селекторы уже приводят текущий State к необходимой модели конкретного компонента.

          В статье описан и осуждается оверинжениринг при описании мапперов(селекторов) для приведения одной модели к другой.
          +6
          Если честно, уже давно не хватает статей с заголовками:
          Мы слишком много используем redux

            +1
            Согласен, согласен :) Но это всего-лишь перевод, который мне захотелось сделать из-за момента, в котором автор приходит к DDD и надеюсь приходит к пониманию, что инструмент(в данном случае redux) вторичен.
            0

            Почему не написано ничего про useSelector? Это же гораздо проще

              0
              Ну, потому что автор оригинальной статьи не написал про useSelector :)
              От себя добавлю — потому что статья не про инструмент, а про идею.
              0
              Нормально проектировать модель — это супер идея! Я в восторге, автору респект.

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

              Самое читаемое