Структура и подходы к организации экшенов, селекторов, редьюсеров и эффектов в Ngrx и Nx
В данной статье рассмотрим структуру Ngrx State, которую рекомендует Nx. Поговорим о создании экшенов, а также рассмотрим применение фасада для управления состоянием stat’а.
В одной из предыдущих статей, я рассматривал организацию state’а — Организация Stat’ов в Angular c Ngrx и Nx. В статье на примерах приведена структура, которая была рекомендована Nx. Но изменение подходов в Ngrx и Nx привело к созданию новой структуры, которая позволяет упростить store. В этой статье пересмотрим и доработаем принципы приведённые в статье, а также приведём несколько примеров для демонстрации описанных возможностей.
Если вы не знакомы с Redux, то сначала прочтите введение в Redux на официальном сайте — redux.js.org. Также для понимания процесса, вам необходимо понимание принципов работы Ngrx, с которыми можно ознакомиться на сайте — ngrx.io. Информацию о реализации Redux в Angular можно посмотреть в статье — Redux в Angular. Управление состояниями в Angular с помощью Ngrx и Nx.
Архитектура Ngrx
Более детально опишем принципы работы с Ngrx, прокомментировав основные моменты.
Если вы знакомы с Ngrx, то можете пропустить данный раздел и перейти к разделу — Структура state.
Обычно процесс создания state происходит следующим образом:
- Создание Action’ов
- Создание Reducer’а
- Создание Selector’ов
- Создание Effect’ов
- Создание Facade
Опишем все стадии создания state согласно списку.
Создание экшенов (Actions)
Экшены являются одной из главных составляющих Redux. Action формально представляют события, которые происходят в приложении, и при возникновении которых должно произойти изменение состояния (state).
Action представляет собой простой интерфейс, который из обязательных полей имеет только type, который должен быть уникальным во всем проекте. Именно с помощью type, reducer понимает, какой action перед ним и что с ним нужно сделать.
Примером простого экшена может быть объект:
{
type: '[User] Load User'
}
Action может содержать необходимые данные, помимо типа. Например userId
:
{
type: '[User] Load User',
userId: 55
}
Обычно, все дополнительные свойства называются payload, который формально объединён с action’ом.
Хорошей практикой считается вынесение всех данных в отдельный объект payload:
{
type: '[User] Load User',
payload: {
userId: 55
...
}
}
В Ngrx есть 2 пути создания экшенов:
- Использование функции createAction
- Использование класса (подход применяется в версии ngrx 7 или ниже)
Создание экшена с помощью createAction является рекомендуемым*.
Создание экшена с помощью класса выглядит следующим образом:
Как уже не сложно заметить, использование класса существенно увеличивает количество кода при одном action’е.
Представьте что будет, когда экшенов будет больше десяти. Поддержка такого количества экшенов становиться проблематичной, и появляется желание все отрефакторить. Думаю из-за этого, разработчики Ngrx отказались от использования классов и взамен этому, предоставили инструментарий для быстрого создания экшенов с помощью createAction.
Если исходить из практики, то нужно отметить пару вещей:Важны правильные названия для Action’ов, так как халатное отношение к наименованию, может привести к замешательству разработчика и породить множество нежелательных side эффектов. Например использование грамматики в наименовании (LoadX, LoadingX, LoadedX) как правило не только мешает, но не даёт чёткого ответа на тип Action’а. Есть множество действий (неправильных глаголов), которые не имеют второй/третьей формы и поэтому, для описания подобного рода экшенов, приводит разработчика к придумыванию решений (костылей), которые могли бы отражать суть экшена. Подобный подход был представлен разработчиками Nx, хотя и возможно они просто использовали примеры Ngrx.
- Важны правильные названия для Action’ов, так как посредственное отношение к наименованию, может привести к замешательству разработчика и породить множество нежелательных side эффектов. Например использование грамматики в наименовании (LoadX, LoadingX, LoadedX) как правило не только мешает, но не даёт чёткого ответа на тип Action’а. Есть множество действий (неправильных глаголов), которые не имеют второй/третьей формы и поэтому, для описания подобного рода экшенов, приводит разработчика к придумыванию решений (костылей), которые могли бы отражать суть экшена. Подобный подход был представлен разработчиками Nx, хотя и возможно они просто использовали примеры Ngrx.
- Хоть и с первого взгляда, создание action’ов с помощью классов привлекает внимание сторонников ООП, практика показывает, что использование enum будет нужно только для создания action’а и его связи с reducer’ом. Однако, поддержка enum’а и порождение классов существенно увеличивают время и размер store, что как правило, не является оправданным.
Решение описанных проблем приведём в следующем разделе — Структура state.
Создание редьюсера (Reducer)
Reducer в Ngrx отвечает за переход state из одного состояния в другое, основываясь на типе action’а.
Reducer это pure
функция, которая для одних и тех же параметров предоставит одно и тоже состояние. Также редьюсер является синхронной функцией (не реактивной) и не содержит реактивной логики. Вся реактивная логика в Ngrx обрабатывается с помощью Effect’ов, описание которых будет ниже.
Вся реактивная логика в Ngrx обрабатывается с помощью Effect’ов, описание которых будет ниже.
Обычно файл, в котором описывается редьюсер в Ngrx, содержит 3 вещи:
- Feature key — ключ, который будет создан в root state, значением которого будет state.
- State interface — интерфейс состояния разрабатываемого модуля.
- Reducer — функция, реализующая редьюсер для конкретных action’ов данного модуля.
В качестве примера, приведём state для пользователя.
Feature key будет соответствовать названию модуля (user):
export const USER_FEATURE_KEY = 'user';
State для User, может включать в себя следующие параметры:
- user — объект загруженного пользователя.
- userApiError — ошибка, которая произошла при загрузке пользователя
Константа userInitialState — представляет собой начальное состояние UserState.
Reducer для пользователя можно представить так:
Так как нас интересует изменение state, в примере приведён action — loadUserSuccess, который просто получает User:
UserReducer при возникновении action’а — loadUserSuccess, записывает в UserState в свойство user, объект User’а, который был передан в loadUserSuccess.
При возникновении loadUserFailure, UserReducer запишет в UserState в userLoadError ошибку (error), которая была передана в loadUserFailure.
Так как из примера не понятно, как происходят вызов событий reducer’а, то это можно продемонстрировать следующим образом.
Допустим есть компонент, в котором мы хотим отобразить пользователя. Для этого мы вызываем событие загрузки пользователя:
В данном случае, в конструкторе компонента генерируется новое событие — загрузка пользователя. Соответственно store, находит необходимый reducer, находит нужную мутацию (метод on
c типом равным [User] Load User
).
Так как в примере ожидается загрузка пользователя с помощью API, то это является уже реактивной логикой, которая будет обработана в эффекте (Effect).
Как упоминалось ранее, в ngrx есть несколько способов описания reducer’ов. Одним из способов, является использование функции createReducer, которая сейчас является предпочтительной.
Пример, приведённый выше, как раз использует createReducer
.
Вторым способом является подход на использовании switch-case вместо функции createReducer
и on
.
Если сравнить реализации старого и нового подходов, то можно вновь отметить громоздкость описания в старом подходе.
Можно комбинировать новый формат action’ов со старым описанием reducer’ов. Для этого необходимо все типы action’ов вынести в
enum
.
Создание селекторов (Selectors)
Основное назначение селекторов — предоставить доступ к свойствам store. Селектор представляет pure функцию, которая выбирает из store нужные данны.
Так как store является реактивным и использовать селекторы можно только вместе с реактивностью. Простой пример использования селектора:
this.store.pipe(select(UserSelectors.getUser))
Селекторы это единственная часть Ngrx, которая за долгое существование не потерпело существенных изменений.
Для создания селекторов необходимо использовать функцию — createSelector
.
Отметим, что хотя и в официальной документации, связанной с selector’ами приведён пример из создания и использования, то может сложиться неверное предположение об использовании.
Это все справедливо, если вы не используете feature
, и не разбиваете state, на множество маленьких обособленных state’ов.
Модель с использованием features
работает следующим образом:
Сначала создаётся селектор, который формально выбирает из root state нужный state, соответствующий модулю:
export const getUserState = createFeatureSelector<UserState>(USER_FEATURE_KEY);
Затем, уже из выбранного state, выбирается нужные данные:
export const getUser= createSelector(getUserState, state => state.user);
Для примера с UserState, файл селекторов будет таким:
Создание эффектов (Effects)
Эффекты в Ngrx применяются для следующих целей:
- Работа с асинхронными данными и функциями (например, загрузка данных по http, или создание websocket’ов)
- Наблюдение за происходящими action’ами и порождение новых action’ов.
Эффекты не в ходят в ngrx/store модуль, являются отдельным пакетом, что подчёркивает отступление от стандартной модели Redux.
Создание эффекта представлено в двух вариантах:
- использование функции createEffect
- использование декоратора
@Effect()
Простой эффект можно представить так:
Как можно увидеть из реализации, по типу action’а фильтруется событие и вызывается загрузка пользователя.
loadUser$ = createEffect(() => this.actions$.pipe(
ofType(UserActions.loadUser),
mergeMap(action => this.userHttp.load(action.userId)
.pipe(
map(user => UserActions.loadUserSuccess({ user })),
catchError(error => UserActions.loadUserFailure({ error }))
))
)
);
В случае успешной загрузки, создаётся новый action с состоянием успеха, иначе генерируется action с ошибкой загрузки пользователя.
Для наблюдения над событиями, можно добавить effect, который отслеживает успех загрузки пользователя:
loadUserSuccess$ = createEffect(() => this.actions$.pipe(
ofType(UserActions.loadUserSuccess),
tap(() => {
console.log('User loaded!');
})
)
);
Стуруктура state глазами Nx
Ознакомившись с основами Ngrx можно поговорить о структуре state при разработке enterparise приложений с помощью монорепозитория Nx.
Если сгенерировать новый state с помощью Nx:
ng g @nrwl/angular:ngrx user --module=libs/users/src/lib/users-core/users-core.module.ts
то получим следующую структуру:
Схема nrwl/angular:ngrx
генерирует следующие файлы для user state:
- user.actions.ts — файл, в котором описаны все action’ы;
- user.effects.ts — файл, который содержит все эффекты;
- user.facade.ts — файл, реализующий facade над feature state;
- user.model.ts — файл, который содержит абстракцию сущности, с которой поразумивается сохранение в state.
- user.reducer.ts — файл, содержащий реализацию редьюсера и соответствующих мутаций;
- user.selectors.ts — файл, включающий в себя все селекторы user state.
Рассмотрим более детально каждый сгенерированный файл.
Наименование Action’ов
Файл user.actions.ts:
Как можно увидеть, в файле создано 3 action’а:
- loadUser
- loadUserSuccess
- loadUserFailure
Внимательный читатель заметит, что выше приведённые примеры, которые приводились для объяснения ngrx, построены по тому же принципу.
Важным нюансом тут является наименование action’ов, где используется следующая парадигма:
Действие — Сущность — Состояние/Статус
Данная модель наименования объясняется следующим образом.
Допустим есть множество действий, которые необходимо совершить с сущностью: create, change, update, load, merge, delete, migrate, reset, confirm, …
И если не придерживаться какой-то логики, с помощью которой можно идентифицировать цепочку событий, то поддержка подобного рода state’а превращается в очень сложную задачу.
Возьмём для примера 3 действия для user’а: create, delete, change.
Хоть случай и надуманный, но уже вызывает некоторое отвращение.
Такая же проблема была и раньше в Nx и Ngrx. Если посмотреть примеры action’ов в Ngrx, то можно было увидеть нечто подобное:
Даже если абстрагироваться от стиля написания и переписать в упрощённом виде:
Данное зрелище не вызывает восторга или успокоения. В данном примере, всего 3 действия. Но если их будет больше 10 — будет полный мрак, и без рефакторинга уже точно не обойтись.
Для избежания описанных выше проблем, предлагается использовать модель действие-сущность-состояние.
Обычно в проекте присутствуют группы action’ов, из которых выстраивается цепочка событий. Например, загрузка авторизированного пользователя.
Опишем всю цепочку:
- Инициировать событие, которые вызовет загрузку пользователя — loadUser
- После успешной загрузки пользователя, вызвать событие сохранения загруженного пользователя в state — loadUserSuccess
- Если при загрузке пользователя произошла ошибка, вызвать событие ошибки загрузки (для того, чтобы показать ошибку клиенту) — loadUserFailure.
Обычно, данной цепочки хватает, для реализации. Но чем сложнее логика, тем больше может становиться цепочка. Например, если вы используете SSR, и не хотите загружать предварительные данные, а оставить это на уровне browser.app. Тогда нужна ещё одна стадия — проверка возможности загрузки:
- Вызов события, которое проверит возможность загрузки пользователя и вызовет событие загрузки — loadUser
- Инициировать событие, которые вызовет загрузку пользователя — loadUserRun
- После успешной загрузки пользователя, вызвать событие сохранения загруженного пользователя в state — loadUserSuccess
- Если при загрузке пользователя произошла ошибка, вызвать событие ошибки загрузки (для того, чтобы показать ошибку клиенту) — loadUserFailure.
Также этот пример, может обрабатывать случай, когда вызвана повторная загрузка пользователя, хотя первая попытка ещё не завершилась.
Для того, чтобы отлавливать подобные случаи, можно добавить состояние cancel:
- Вызов события, которое проверит возможность загрузки пользователя и вызовет событие загрузки — loadUser, если загрузка не возможно вызвать событие loadUserCancel
- Инициировать событие, которые вызовет загрузку пользователя — loadUserRun
- После успешной загрузки пользователя, вызвать событие сохранения загруженного пользователя в state — loadUserSuccess
- Если при загрузке пользователя произошла ошибка, вызвать событие ошибки загрузки (для того, чтобы показать ошибку клиенту) — loadUserFailure.
Приведём реализацию:
Как можно заметить, мы однозначно можем выстроить цепочку, так как все action’ы начинаются одинаково с loadUser, а все состояния различны. Это очень удобно при использовании IDE, где при авто подсказках, все ваши action’ы выстроены друг под другом.
Вынесение данных из action’а в payload
Последним, что можно сказать об организации action’ов, это правила передачи параметров в action.
Так как createAction, формально создаёт новый объект со свойством type и всеми остальными свойствами, переданными в props, то получается непреднамеренное объединение двух разных составляющих Redux.
Рассмотрим проблему на примере.
Допустим необходима загрузка пользователя по id. Для этого в action добавим параметр.
Однако, результатом работы будет action вида:
И в этом нет ничего плохого. Но если вы решите передавать payload между action’ами, то тогда, будет необходимо дублировать и указывать все переданные параметры. В нашем случае это один параметр:
Однако, этого можно избежать, если использовать подход вынесения данных в payload. Вместо того, чтобы передавать набор свойств в action, передавать туда объект, который внутри себя будет хранить все параметры:
Это позволяет в выше описанном случае, просто передать весь payload целиком, без ненужного присваивания каждого из свойств payload’а.
Тогда, согласно описанному подходу, action’ы для пользователя будут выглядеть так:
где ActionPropsPayload простая абстракция для создания объекта со свойством payload.
Организация Reducer’а
Шаблон редьюсера:
Из примера видно, что Nx запихнули в schematic’у использование EntityState.
Ngrx/Entity — это отдельная песня, про улучшение и автоматизацию работы с сущностями.
Как показывает практика, если вы пишите проект с нуля, где в backend есть порядок и согласованность, а все методы действительно максимально идеализированы и соответствуют RestAPI, то тогда использование ngrx entity оправдано. Но очень часто это не так ..
Вы можете ознакомиться с принципами Entity на сайте ngrx и попробовать использовать данные подходы, но в данной статье мы ограничимся стандартным ядром ngrx.
Поэтому удалим все использования entity, а также немного переименуем названия, где State -> UserState.
Наименования свойств в State также важно, как задание нужных имён для action’ов.
Для задания имён свойствам предлагается давать максимально приближенные к названиям action’ов. Например для загрузки пользователя это будет:
Единственным отступлением, является свойство user. Мы не стали добавлять постфикс load, так как это чрезмерно и часто не нужно, для контроля.
Также хорошей практикой считается переименование intialState в именованную переменную, связанную со state, где в нашем случае это userInitialState.
Это необходимо тогда, когда в библиотеке монорепозитория Nx, будет более одного state, который будет экспортировать значения. Если вы не планируете экспортировать state, то можете игнорировать это правило.
И соответственно сами мутации, которые происходят при вызове того или иного action’а:
Выше, мы описывали принцип работы экшенов, в виде цепочки событий. В данном примере приведена её реализация.
- UserLoad вызывает события проверки возможности загрузки. Данный action не изменяет состояние state.
- Если загрузка пользователя возможна, вызывается событие UserLoadRun, которое записывает в state, состояние, что происходит асинхронная загрузка пользователя, а также сбрасывается предыдущая ошибка загрузки, если таковая была.
- При успешной загрузке вызывается событие UserLoadSuccess, где в state записывается загруженный User и флаг асинхронной загрузки ставится false.
- При неуспешной загрузке вызывается событие UserLoadFailure, где в state записывается ошибка загрузки и флаг асинхронной загрузкт ставится false.
В итоге, полный файл user.reducer.ts получит вид:
Реализация селекторов
Так как селекторы являются простой реализацией геттеров, то существенных изменений со стандартным использованием нет.
Приведём конкретный пример селекторов для User State:
Реализация Effect’ов и использование DataPersistence
Эффекты являются самой нагруженной частью state. Наблюдение, запуск и контроль асинхронных событий требует особого внимания.
Посмотрим, что предоставляет нам Nx, для упрощения разработки эффектов.
Сгенерированный файл эффектов содержит в себе:
Основной особенностью является оператор fetch. Как видно из примера, fetch порождает 2 action’а: loadUserSuccess и loadUserFailure при успехе или неуспехе соответственно.
Добавим абстрактный класс для загрузки пользователя и подключив его в эффекты:
Так как статья является частью серии статей про Angular, то в статье используются ранее реализованное GraphQL API. Примеры конкретной реализации, можете посмотреть в репозитории.
Добавив загрузку, получим следующие 2 эффекта.
- loadUser$ — Эффект который наблюдает за процессом начала загрузки пользователя. Если происходит событие loadUser, эффект проверят возможность загрузки и запускает загрузку пользователя, генерируя action loadUserRun.
- loadUserRun$ — эффект, который непосредственно запускает реактивную загрузку пользователя. И при окончании загрзуки испускает action либо успешной загрузки, либо неуспешной.
force в экшене — это признак принудительной загрузки.
Как можно увидеть в первом эффекте:
Для того, чтобы получить root state (store), приходиться использовать оператор withLatestFrom. В Nx посчитали, что так писать достаточно громоздко, и реализовали сервис — DataPersistence.
DataPersistence — реализует 3 популярные стратегии:
- Fetch — обычный запрос, аналогичный выше описанному
- Optimistic Updates — запрос, который формально не дожидается ответа от сервера и считает, что запрос прошёл успешно.
- Pessimistic Updates — запрос, который сначала обновляет данные на сервере, а уже после обновляет состояние клиента.
Более подробно о DataPersistence, ознакомитесь в документации.
Пример с использованием DataPersistence:
Утилиты для работы с Effect’ами
Так как большинство работы в effect’ах однообразно, то можно сгруппировать основные методы и вынести в абстрактный класс:
Расширяя UserEffects, получим:
- getState — утилита, которая из root state по feature key возвращает partial state
- isBrowser — обертка над isPlatformBrowser(this.platformId)
- errorHandler — утилита, которая позволяет генерировать новый action с ошибкой, а также выводить ошибку в консоль
Создание Facade
Заключающим моментом является создание facade. Формльно фасад представляет обёртку над store и скрывает всю реализацию (dispatch, select) store.
Подробнее с принипом фасадов можно ознакомиться в статье — NgRx + Facades: Better State Management.
Это удобно, так как не нужно получать сервис store и подписываться на определённые свойства, тем самым дублируя код.
В нашем случае фасад для User State будет следующим:
То есть для каждого значения из state, мы использовали соответствующий selector.
user$ = this.store.pipe(select(UserSelectors.getUser));
userLoadError$ = this.store.pipe(select(UserSelectors.getUserLoadError));
userLoadRun$ = this.store.pipe(select(UserSelectors.getUserLoadRun));
Также, для всех цепочек событий, мы написали инициаторы данных событий.
loadUser(force?: boolean): void {
this.store.dispatch(UserActions.loadUser({ payload: { force } }));
}
Так как весь Angular построен на абстракциях, а Facade это сервис, создадим абстракцию и для фасада:
И добавим его в реализацию фасада:
Тестирование
Запустим в нашем демо проекте:
yarn run backend:db:start // запуск сервера баз данных
yarn run serve:backend:api // запуск бекенд приложения с grpahql
yarn run redux:serve // запуск angular приложения
Так как авторизация реализована с помощью авторизационного токена, то сначала нужно авторизироваться, а затем запросить информацию о текущем пользователе.
Откроем приложение в браузере:
Попытаемся загрузить пользователя, без авторизации:
Как видно на скриншоте, произошло 3 action’а:
- LoadUser — нажатие на кнопку загрузить текущего пользователя
- LoadUserRun — загрузка пользователя с помощью API
- LoadUserFailure — ошибка загрузки пользователя
Если открыть ответ, то можно увидеть 401 статус:
Нажмём на кнопку авторизации, где произойдёт установка логина и пароля:
Извините формочку ввода не делал, так как это не часть данной статьи.
Видим, что произошло ещё 4 action’а:
- SignInSet — установка логина и пароля для запроса
- SignIn — запуск авторизации
- SignInRun — запрос на авторизацию по API
- SignInSuccess — успешная авторизация
Внимательные пользователи, могут увидеть используемые логин и пароль, а невнимательные пользователи могут посмотреть компонент, который реализует авторизацию.
Если открыть вкладку network, то можно увидеть ответ сервера:
Авторизация вернула JWT access token. Теперь можем запросить текущего пользователя:
Видим снова 3 action’а, но только теперь загрузка прошла успешно.
Как можно убедиться, GraphQL API вернула информацию по текущему, авторизированному пользователю.
И чтобы развеять сомнения, что я отлично пользуюсь Photoshop (или конечно gimp, так как на unix есть проблемы с запуском photoshop), небольшая демонстрация работы целиком:
На скриншотах выше было проделано тоже самое.
Резюме
В данной статье рассмотрели два основных блока:
- Принципы создания state с помощью Ngrx
- Принципы организации state’ов в Ngrx и Nx.
В первом блоке детально разобрали создание следующих сущностей:
- Рассмотрели создание action’ов, поговорили о старом и новом подходах, а также обсудили важность наименования action’ов, привели примеры создания экшенов;
- Рассмотрели создание редьюсера, поговорили о основных компонентах редьюсера, привели примеры создания редьюсера старым и новым подходами;
- Рассмотрели создание селекторов, привели примеры создания селекторов
- Рассмотрели создание эффектов, поговорили об основных назначениях эффектов, привели примеры создания.
Во-втором блоке поговорили о общих подходах создания Ngrx State.
По частям разобрали и прокомментировали создания сущностей:
- Рассмотрели примеры наименования экшенов, а также привели рекомендации по созданию action’ов;
- Рассмотрели примеры организации State’а, а также предоставили комментарии по созданию мутаций;
- Рассмотрели примеры организации эффектов в Ngrx. Поговорили о DataPersistence и привели несколько примеров для упрощения создания и управления событиями;
- Рассмотрели примеры создания facade. Поговорили о важности применения фасадов и привели примеры использования.
В третьей части статьи, запустили часть приведённых примеров, для демонстрации работоспособности выше описанных подходов.
Исходники
Все исходники находятся на github, в репозитории:
Для того, чтобы посмотреть состояние проекта на момент написания статьи, нужно выбрать соответствующий тег — nx-state.
git checkout nx-state
Код можно посмотреть в разделе https://github.com/Fafnur/medium-stories/tree/master/libs/users
Для демо проекта, использовалось приложение из предыдущей статьи — redux — https://github.com/Fafnur/medium-stories/blob/master/apps/frontend/redux
Ссылки
Подписывайтесь на блог, чтобы не пропустить новые статьи про Angular и новости из мира фронтенд разработки.
Medium: https://medium.com/fafnur
Добавляйтесь в группу ВК: https://vk.com/fafnur
Добавляйтесь в группу в Fb: https://www.facebook.com/groups/fafnur/
Телеграм канал: https://t.me/f_a_f_n_u_r
Twitter: https://twitter.com/Fafnur1
Instagram: https://www.instagram.com/fafnur
Предыдущие статьи:
- Angular 9, Universal и Nx. Новые правила сборки SSR приложения.
- Кроссплатформенные web storage в Angular 9. Реализация LocalStorage, SessionStorage и Cookies в Angular Universal.
- Мультиязычность ngx-translate в Angular 9 c монорепозиторием Nx.
- Разбиение локализации ngx-translate на несколько файлов в Nx
- Бесконечный скролл в Angular 9 с помощью Intersection Observer API
- Redux в Angular. Управление состояниями в Angular с помощью Ngrx и Nx.