Где же держать бизнес логику приложения?

 "Та чего уж там париться - прямо в компонентах." - скажут некоторые. И в некоторых ситуациях это правильное и удобное решение.

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

Как написано на сайте reactjs.org, React - это библиотека для создания UI. 

А для модели и бизнес логики написано несколько других замечательных библиотек.

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

Слои

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

Что? Не храните в разных файлах? Не, ну пусть даже вы назвали это duck подходом и запихнули все в один файл, вы все равно имеете в виду что он состоит из разных слоев.

Деление на слои помогает ориентироваться в коде. Вы уже не просто смотрите 125-ю строчку кода вывода таблицы X, а слой контейнера таблицы X.

Деление на слои помогает заменять отдельные слои. Например, у вас есть стейтлесс компонент. Пока редакс не готов, вы можете временно подключить к стейтфулл компоненту, а потом стейтфулл компонент заменить на контейнер подключенный к редаксу.

Деление на слои помогает разделить работу между разработчиками.

Итак, деление на слои - это хорошо.

Что нам даёт отдельный слой бизнес логики?

Как правило когда мы выводим пользователю какою-то страницу с десятком компонентов, то все эти компоненты не сами по себе, они взаимодействуют друг с другом ради одного общего дела. Многие делают ошибку - инкапсулируют логику в отдельные соответствующие компоненты.

Например, представим страницу с таблицей и фильтрацией и Вы все-таки расположили логику фильтрации в компоненте FilterComponent.

И вдруг понадобилось добавить панель с кнопкой (BottomPanel) сброса фильтра в другой части UI. Часто я вижу такое решение: Оставляют FilterComponent с его состоянием и добавляют еще BottomPanel в которой дублируется тоже состояние, и потом пытаются эти два состояния синхронизировать. И первое время все работает как положено. Но постепенно появляются другие связанные компоненты и контроль над ситуацией теряется. Из такого подхода вырастает очень не тривиальный и сложный в поддержке код.

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

Размещать бизнес логику в корневом компоненте - это хорошо.

Слой  бизнес логики без Redux

До появления хуков общую логику компонентов выносили либо в HOC либо описывали прямо в корневом для этой логики компоненте . С появлением хуков все стало наглядней. Хуки отличный претендент для слоя бизнеса логики и переиспользования общей логики в разных компонентах. Они очень удобные и если бы не парочку нюансов, можно было бы ими ограничится.

Хуки для бизнес логики - это хорошо

Redux

Первое, чего не хватает при работе с хуками и то что даёт редакс - это общее состояние всего приложения.

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

Редакс предоставляет инструменты для дебага общего состояния.

Общее состояние позволяет передвигаться по истории, что открывает дополнительные возможности при тестировании. Можно сохранить состояние и потом восстанавливать для нескольких веток теста.

Можно пролистать все виды интерфейса пользователя просто меняя состояние.

Общее состояние приложения в одном месте - это хорошо.

Thunk, Saga и Акторы

Т.к. у нас redux, то часть логики приложения попала в редюсеры. Но с редюсерами одна проблема - они синхронные. Нельзя в редюсере сделать запрос на сервер и результат вернуть в редакс. Чтобы добавить асинхронности, разработали несколько расширений для редакса используя специальному механизм - middleware. Этот механизм позволяет выполнить асинхронные действия вместо/до/после реального диспатча экшена.

Я рассматривал два варианта Thunk и Saga и выбрал Thunk. Saga отпала по простой причине - мне не нравятся генераторы. Но принципиально Thunk и Saga ничем не отличаются. Они оба добавляют в поток данных асинхронные действия, при этом поток данных так и остается однонаправленным:

  1. Пользователь вызвал событие в UI.

  2. Выполнился редюсер (изменился стейт) либо вызвался экшен thunk или Saga.

  3. Экшен  thunk или Saga сделал асинхронные действия и вызвал один или несколько экшенов редакса что ведет обратно к пункту 1.

Но есть кардинально другой подход - Акторы. Акторы подписываются на изменение состояния и делают асинхронные действия в ответ на изменения какого-то значения в состоянии. Поток данных становится таким:

  1. Пользователь вызвал событие в UI

  2. Вызвался экшен redux и поменялось состояние стора.

  3. Актор подписанный на изменение этого состояния сделал асинхронные действия и вызвал один или несколько экшенов редакса.

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

Но те кто делал реальный проект на thunk, а не просто смотрел примеры в документации, знают, что нельзя просто так начать асинхронное действие. Пользователю надо сообщить что программа начала что то делать. И как правило в экшен thunk-а добавляют dispatch({type: ‘START’}) перед асинхронным действием и dispatch({type: ‘END’}) после.

Таким образом с точки зрения частоты смены состояния между мидлварой и актором нет никакой разницы.

Как ни крути, при появлении асинхронности будет как минимум два изменения состояния:

1. Чтобы сообщить что что-то начало грузится/обрабатываться

2. Непосредственно сохранение результата в редакс

Бизнес логика в Redux и Thunk

Тут все просто. Синхронную логику мы заносим в Redux, асинхронную - в Thunk.

Важно отметить, основное что делала бизнес логика в thunk actions - это сравнение предыдущего состояния с новым и в зависимости от результата вызывала другие action.

Что не так с Thunk и Saga?

Я ничего не имею против Thunk и Saga. 

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

Поэтому мой минималистичный дух считает что чем меньше подходов используется в приложении тем лучше. 

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

Разработка акторов

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

Я подписался на состояние редакса, При изменении состояния, я проверял что поменялось и вызывал dispatch(action).

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

И когда я решил вынести логику сравнения предыдущего состояния с новым в отдельные хелперные функции, я начал подозревать, что я делаю что-то очень знакомое, что-то, что уже есть готовое, что уже установлено у меня в npm модулях.

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

Призрак

Единственное что мне не нравилось - это определения. Я пытался вынести бизнес логику из react-компонентов, так как это слой UI, а в результате в проекте у меня опять могли получиться везде компоненты: и UI на react-компонентах и акторы на react-компонентах.

Мне хотелось четко разделить эти слои, поэтому я сделал две функции:

  • ghost - аналог createElement

  • ghosts - аналог Fragment

Да, я прям вот так просто присвоил:

export const ghost = createElement;
export const ghosts = createElement.bind(null, Fragment, null);

В переводе с английского означает “призрак”. Мне показалось подходящим названием для противопоставления видимым реакт компонентам.

С одной стороны - это отдельное понятие для слоя бизнес логики, не связанное с UI, а с другой стороны включает в себя весь опыт реакта.

Вот так выглядит использование:

const AppActor = ({ param1, param2, showModalX }) => ghosts(
   ghost(MenuActor, { param1 }),
   ghost(PagesActor, { param2 }),
   showModalX && ghost(ModalXActor)
)

Никакого jsx и можно спокойно пользоваться привычными хуками.

const HomePageActor = () => {
    const dispatch = useDispatch()
    useEffect(() => {
      const interval = setInterval(() => {
          dispatch({type: 'COUNTER_PLUS'})
      }, 1000)
      return () => clearInterval(interval)
    })

    // you should explicitly point that this ghost hasn't child ghosts
    return null 
}

Естественно, я создал себе npm модуль react-ghost с этими двумя строчками и уже применил его на нескольких проектах. Результат меня радует, мне получилось с минимальным усложнением провести четкую границу между бизнесс логикой и UI. Но людям в команде все же приходится объяснять что это, зачем это нужно и что вместо thunk достаточно использовать useEffect.

Собственно, поэтому и захотелось поделиться этим подходом и узнать ваше мнение.