Redux в Angular. Использование Ngxs.

Aleksandr Serenko
F.A.F.N.U.R
Published in
7 min readNov 24, 2022

В данной статье приводится реализация redux с помощью Ngxs.

Ngxs задумывался как redux более приближенной к фреймворку, в отличии от ngrx.

Реализация redux в ngxs выглядит следующим образом:

Компонент диспатчит экшен. Экшен вызывает соответствующие апи запросы и изменяет store. После того как store был изменен, обновляются селекторы.

Установка Ngrx

Для того чтобы использовать ngxs необходимо установить зависимости, запустив команду:

yarn add @ngxs/store @ngxs/devtools-plugin

После выполнения команды необходимо добавить модуль в AppModule:

@NgModule({
imports: [
...,
NgxsModule.forRoot([])
],
})
export class AppModule {}

Root Store

Для использования devtools и логирования экшенов нужно использовать NgxsReduxDevtoolsPluginModule.

Для оптимизации были созданы два модуля для prod и development версий:

import { NgModule } from '@angular/core';
import { NgxsReduxDevtoolsPluginModule } from '@ngxs/devtools-plugin';
import { NgxsModule } from '@ngxs/store';

@NgModule({
imports: [NgxsModule.forRoot([])],
})
export class RootStoreModule {}

@NgModule({
imports: [
NgxsModule.forRoot([], {
developmentMode: true,
}),
NgxsReduxDevtoolsPluginModule.forRoot(),
],
})
export class RootStoreDevelopmentModule {}

Тогда подключение root store выглядит следующим образом:

@NgModule({
imports: [
...
environment.production
? RootStoreModule,
: RootStoreDevelopmentModule
],
...
})
export class AppModule {}

Создание Feature Store

Для генерации новой фичи используется следующая команда:

ngxs --name post

Для работы команды необходимо установить ngxs cli.

Стоит отметить, что в момент написания статьи ngxs cli не работали наmacbook. Поэтому в статье все файлы созданы руками, а не генераторами.

После выполнения команды будут сгенерированы следующие файлы:

feature
├── state
│ ├── feature.actions.ts
│ ├── feature.facade.ts
│ └── feature.state.ts
├── feature.module.ts
└── ...

Каждый файл содержит конкретные абстракции redux:

  • feature.actions.ts — файл, который содержит все экшены;
  • feature.state.ts — файл, который содержит все селекторы, мутации и асинхронные экшены;
  • feature.facade.ts — файл, который скрывает прямое обращение к конкретной реализации redux.

Файл facade не генерируется в ngxs, но так как статья является часть цикла статей, добавляется данный фасад.

Для сайта новостей понадобятся следующие экшены:

  • Загрузка списка новостей;
  • Загрузка новости по id;
  • Создание новой новости;
  • Изменение новости;
  • Удаление новости;
  • Удаление всех новостей;
  • Восстановление новостей (сброс новостей до дефолтного списка новостей.

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

  • Load — экшен, который вызывает загрузку списка новостей путем выполнения запроса к серверу;
  • LoadSuccess — экшен, который испускается только в том случае, если список новостей был успешно загружен;
  • loadFailure — экшен, который испускается в случае если получили ошибку от сервера при попытке загрузить список новостей.

Аналогично создаются другие цепочки событий: Change, ChangeSuccess, ChangeFailure, Remove, RemoveSuccess, RemoveFailure и так далее.

Для новостного сайта список экшенов в ngxs выглядит следующим образом:

В примере выше видно, что все экшены представлены цепочками экшенов.

В Ngxs в качестве reducer выступает сервис, который называют state, а сам стейт model:

Для списка новостей модель будет следующей:

export interface PostStateModel {
readonly loaded: boolean;
readonly ids: string[];
readonly entities: Record<string, Post>;
}

export const initialPostState: PostStateModel = {
ids: [],
entities: {},
loaded: false,
};

В данном случае имеем классическую реализацию коллекций:

  • ids — список идентификаторов сущностей;
  • entities — словарь с новостями, ключом которого является id новости;
  • loaded — признак загрузки списка новостей.

Начальное значение модели вынесено в отельную константу — initialPostState.

Реализация редьюсера представлена сервисом:

@State<PostStateModel>({
name: 'posts',
defaults: initialPostState,
})
@Injectable()
export class PostState implements NgxsOnInit {
...
}
  • @State — декоратор сервиса, который задает имя фичи (name) и начальное значение (defaults);
  • NgxsOnInit — интерфейс для подписки на инициализацию эффектов для feature store.

В данном случае сервис совмещает в себе следующий функционал:

  • reducer — изменение состояние state;
  • selectors — доступ к свойствам state;
  • effects — выполнение асинхронных экшенов.

Пример реализации селектора:

@Injectable()
export class PostState {
// ...

@Selector()
static loaded(state: PostStateModel): boolean {
return state.loaded;
}
}

Пример реализации селектора с параметром:

@Injectable()
export class PostState {
// ...

static post(uuid: string): (state: PostStateModel) => Post | null {
return createSelector([PostState], (state: PostStateModel) => {
return state.entities[uuid] ?? null;
});
}
}

Пример создания асинхронного экшена/цепочки экшенов/эффекта:

@Injectable()
export class PostState {
// ...

@Action(PostActions.Load)
load(ctx: StateContext<PostStateModel>) {
return this.postApiService.get().pipe(
map((posts) => {
const state = ctx.getState();

ctx.setState({
...state,
ids: posts.map((post) => post.uuid),
entities: posts.reduce((acc, current) => ({ ...acc, [current.uuid]: current }), {}),
});

return ctx.dispatch(new PostActions.LoadSuccess(posts));
}),
catchError((error: unknown) => ctx.dispatch(new PostActions.LoadFailure(error)))
);
}
}

Если создавать цепочки подобным образом, то результат будет следующим:

Из-за того, что экшен в Ngxs имеет свой жизненый путь, то и экшены будут выполнятся соответвенно жизненому циклу. В данном случае Load это асинхронный экшен, а LoadSuccess синхронный. Из-за того, что LoadSuccess вызывается в момент исполнения Load, то сначала залогируется LoadSuccess, а только потом Load.

Жизненный цикл экшена:

Подробнее о экшенах в документации — Actions Life Cycle.

Выше приведенной реализации достаточно, чтобы реализовать фасад:

Для обращения к селекторам используется декоратор select:

@Select(PostState.loaded)
loaded$!: Observable<boolean>;

Для подписки на экшены используется сервис Actions:

loadSuccess$ = this.actions.pipe(
ofActionDispatched(PostActions.LoadSuccess),
map(({ posts }) => posts)
);

Для диспатча событий используется глобальный сервис store:

load() {
this.store.dispatch(new PostActions.Load());
}

Ngxs labs

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

@ngxs-labs/data — он же @angular-ru/ngxs расширение для ngxs, которое пытается уменьшить и упростить количество кода.

Реализация redux в ngxs с использованием решения от angular-ry/ngxs:

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

Код state до использования плагина counter.state.ts:

import { State, Action, StateContext } from '@ngxs/store';

export class Increment {
static readonly type = '[Counter] Increment';
}

export class Decrement {
static readonly type = '[Counter] Decrement';
}

@State<number>({
name: 'counter',
defaults: 0
})
export class CounterState {
@Action(Increment)
increment(ctx: StateContext<number>) {
ctx.setState(ctx.getState() + 1);
}

@Action(Decrement)
decrement(ctx: StateContext<number>) {
ctx.setState(ctx.getState() - 1);
}
}

и файл компонента app.component.ts:

import { Component } from '@angular/core';
import { Select, Store } from '@ngxs/store';

import { CounterState, Increment, Decrement } from './counter.state';

@Component({
selector: 'app-root',
template: `
<ng-container *ngIf="counter$ | async as counter">
<h1>{{ counter }}</h1>
</ng-container>

<button (click)="increment()">Increment</button>
<button (click)="decrement()">Decrement</button>
`
})
export class AppComponent {
@Select(CounterState) counter$: Observable<number>;
constructor(private store: Store) {}

increment() {
this.store.dispatch(new Increment());
}

decrement() {
this.store.dispatch(new Decrement());
}
}

После применения плагина, файл со стейтом:

import { State } from '@ngxs/store';
import { DataAction, StateRepository } from '@angular-ru/ngxs/decorators';
import { NgxsDataRepository } from '@angular-ru/ngxs/repositories';

@StateRepository()
@State<number>({
name: 'counter',
defaults: 0
})
@Injectable()
export class CounterState extends NgxsDataRepository<number> {
@DataAction() increment() {
this.ctx.setState((state) => ++state);
}

@DataAction() decrement() {
this.ctx.setState((state) => --state);
}
}

и файл с компонентом соответственно:

import { Component } from '@angular/core';

import { CounterState } from './counter.state';

@Component({
selector: 'app-root',
template: `
<h1>{{ counter.snapshot }}</h1>
<button (click)="counter.increment()">Increment</button>
<button (click)="counter.decrement()">Decrement</button>
`
})
export class AppComponent {
constructor(counter: CounterState) {}
}

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

У меня было желание сделать еще один проект с другой реализацией ngxs, но я понял что решение от angular-ru предоставляет свой state management концепт, отличный от redux. И поэтому я пытался воссоздать максимально близкое решение к классическому redux, в ущерб оптимизации и количеству кода.

Последним примером будет реализация коллекций с помощью плагина:

import { Injectable } from '@angular/core';
import { createEntityCollections, EntityCollections } from '@angular-ru/cdk/entity';
import { DataAction, Payload, StateRepository } from '@angular-ru/ngxs/decorators';
import { NgxsDataEntityCollectionsRepository } from '@angular-ru/ngxs/repositories';
import { NgxsOnInit, State } from '@ngxs/store';

import { PostApiService } from '@angular-samples/redux/posts/api';
import { Post } from '@angular-samples/redux/posts/common';

interface PostStateOptions {
loaded: boolean;
}

export type PostStateModel = EntityCollections<Post, string, PostStateOptions>;

export const initialPostState: PostStateModel = {
...createEntityCollections(),
loaded: false,
};

@StateRepository()
@State<PostState>({
name: 'posts',
defaults: initialPostState,
})
@Injectable()
export class PostStateRepository extends NgxsDataEntityCollectionsRepository<Post, string, PostStateOptions> implements NgxsOnInit {
constructor(private readonly postApiService: PostApiService) {
super();
}

override selectId(entity: Post): string {
return entity.uuid;
}

@DataAction()
setLoaded(@Payload('loaded') loaded: boolean): void {
const state = this.getState();
this.setEntitiesState({
...state,
loaded,
});
}

override ngxsOnInit(ctx: StateContext<PostStateModel>): void {
// TODO: Need to call ctx.dispatch(new PostActions.Load());
}
}

И тут начинаются некоторые проблемы для интеграции с текущим циклом статей:

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

Из-за этого реализацию пришлось отложить, и продолжить уже с другим решением — Akita.

Ссылки

Оглавление

Предыдущая статья — Ngrx в действии: создание и тестирование.

Следующая статья — Использование Akita.

Все исходники находятся на github, в репозитории:

Для того, чтобы посмотреть состояние проекта на момент написания статьи, нужно выбрать соответствующий тег — redux.

Подписывайтесь на блог, чтобы не пропустить новые статьи про Angular, и веб-разработку. Medium | Telegram| VK |Tw| Ln

--

--

Aleksandr Serenko
F.A.F.N.U.R

Senior Front-end Developer, Angular evangelist, Nx apologist, NodeJS warlock