Организация Stat’ов в Angular c Ngrx и Nx

Aleksandr Serenko
F.A.F.N.U.R
Published in
13 min readAug 8, 2019
Angulat ngrx & nx

Update 29 февраля 2020: Методы и подходы в данной статье устарели и сейчас не являются хорошей практикой. Изменения подходов Ngrx, а также изменение в Nx позволяют создавать более оптимальные state’ы. Подробнее в новой статье — Структура и подходы к организации экшенов, селекторов, редьюсеров и эффектов в Ngrx и Nx.

Создадим translation state, в которой будет храниться информация о доступных языках, выбранном языке и языке по-умолчанию. Также в state будет информация о том, был ли инициализирован язык, и устанавливается/меняется локаль на сайте. Отметим, что язык и локаль являются эквивалентными понятиями в данной статье.

Расширим библиотеку translation и добавим еще один angular module:

ng g module translation-common --project=translation

Сгенерируем translation state:

ng g @nrwl/angular:ngrx translation --module=libs/translation/src/lib/translation-common.module.ts

После генерации stat’a, появиться папка +state, в которой будут все необходимые файлы для данного state. Подробнее можно ознакомиться здесь.

Отметим, что имеется ряд подходов, в котором state’ы группируются по типам файлов:

--actions
----post.actions.ts
----product.actions.ts
--reducers
----post.reducer.ts
----product.reducer.ts
...

Однако, чисто субъективно, данный подход не подходит для больших, масштабируемых приложений, т.к. если вы создадите более 40 state’ов, то с большой вероятностью все эти state’ы будут зависеть друг от друга, хотя в основе лежит идея — иметь локальные, независимые state’ы.

Translation Actions

Если вы впервые читаете о Ngrx Action’ах, то сначала ознакомитесь с официальной документацией Ngrx в разделе Architecture — actions.

Если в двух словах, то actions — это уникальные события, возникновение которых, может изменить хранимые значения в state, где правила изменения стейта описаны в reducer’е индивидуально для каждого action.

Прежде чем добавлять новые action’ы, скажем пару слов о типах событий, с которыми сталкивается разработчик.

Простое UI событие состоит из одного состояния (действия), например:

  • focus
  • blur
  • keydown

Обычно, простые события не могут привести к ошибке, но если событие порождает ошибку, то это уже обычное UI событие.

Обычное UI событие состоит как минимум из 3 состояний:

  1. Запуск события
  2. Успешное выполнение события
  3. Ошибка выполнения события

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

  1. Запрос на запуск события
  2. Запуск события
  3. Успешное выполнение события
  4. Ошибка выполнения события
  5. Отмена запуска события

Теперь рассмотрим на примере задания языка в проекте. В проекте используется ngx-translate и соответственно при смене языка идет обращение к серверу за новой загрузкой файла локали. Если пользователь выбирает новую локаль для сайта, то проходит следующее:

  1. Пользователь выбирает новую локаль.
  2. Запускается событие, которое загружает с сервера новый файл локали по http.
  3. TranslateService устанавливает локаль и обновляет переводы в приложении.

Однако, если пользователь начнет 100 подряд нажимать на кнопку смены языка, то формально, отправиться 100 запросов к серверу, а приложение изменит язык 100 раз (если пользователь будет кликать, сначала на один язык, потом на другой и снова на прежний).

Сформулируем наши action’ы для события — установка нового языка:

  1. Запрос на установку языка
  2. Установка языка
  3. Отмена установки языка
  4. Язык установлен
  5. Ошибка установки языка

Первый action “Запрос на установку языка” проверяет, загружается ли новая локаль и устанавливается ли она.
Если локаль не загружается и не устанавливается, то вызываем второй action — “Установка языка”, если же загружается локаль, то вызывается третий action — “Отмена установки языка”.

Второй action загружает локаль с сервера, и устанавливает ее. Если все прошло успешно, то вызывается четвертый action — “Язык установлен” иначе вызывается пятый action — “Ошибка установки языка”.

Третий action“Отмена установки языка” формально логирует, что произошла отмена установки языка.

Четвертый action“Язык установлен” формально логирует, что произошла успешная установка локали.

Пятый action “Ошибка установки языка” формально логирует, что произошла при установке локали произошла ошибка, и происходит сохранение ошибки в state, если это необходимо.

На основе данной идеологии будут построены почти все события в дальнейшем проекте.

Используя подход описанный выше, для инициализации и установки локали получим следующие action’ы:

import { Action } from '@ngrx/store';

import { TranslationConfig } from '../interfaces/translation-config.interface';

export enum TranslationActionTypes {
InitTranslation = '[Translation] Init translation',
TranslationInitCanceled = '[Translation] Translation init canceled',
InitiatingTranslation = '[Translation] Initiating translation',
TranslationInitialized = '[Translation] Translation initialized',
TranslationInitError = '[Translation] Translation init error',

SetLanguage = '[Translation] Set language',
LanguageSetCanceled = '[Translation] Language set canceled',
SettingLanguage = '[Translation] Setting language',
LanguageSet = '[Translation] Language set',
LanguageSetError = '[Translation] Language set error'
}

export class InitTranslation implements Action {
readonly type = TranslationActionTypes.InitTranslation;

constructor(public payload?: boolean) {}
}

export class TranslationInitCanceled implements Action {
readonly type = TranslationActionTypes.TranslationInitCanceled;
}

export class InitiatingTranslation implements Action {
readonly type = TranslationActionTypes.InitiatingTranslation;
}

export class TranslationInitialized implements Action {
readonly type = TranslationActionTypes.TranslationInitialized;
constructor(public payload: TranslationConfig) {}
}

export class TranslationInitError implements Action {
readonly type = TranslationActionTypes.TranslationInitError;
constructor(public payload: string) {}
}

export class SetLanguage implements Action {
readonly type = TranslationActionTypes.SetLanguage;
constructor(public payload: string, public force?: boolean) {}
}

export class LanguageSetCanceled implements Action {
readonly type = TranslationActionTypes.LanguageSetCanceled;
}

export class SettingLanguage implements Action {
readonly type = TranslationActionTypes.SettingLanguage;
constructor(public payload: string) {}
}

export class LanguageSet implements Action {
readonly type = TranslationActionTypes.LanguageSet;
}

export class LanguageSetError implements Action {
readonly type = TranslationActionTypes.LanguageSetError;
constructor(public payload: string) {}
}

export type TranslationAction =
| InitTranslation
| TranslationInitCanceled
| InitiatingTranslation
| TranslationInitialized
| TranslationInitError
| SetLanguage
| LanguageSetCanceled
| SettingLanguage
| LanguageSet
| LanguageSetError;

export const fromTranslationActions = {
InitTranslation,
TranslationInitCanceled,
InitiatingTranslation,
TranslationInitialized,
TranslationInitError,
SetLanguage,
LanguageSetCanceled,
SettingLanguage,
LanguageSet,
LanguageSetError
};

Translation State

Если вы впервые читаете о Ngrx State, то сначала ознакомитесь с официальной документацией Ngrx в разделе Architecture — reducers.

В двух словах, State это просто имутабельный объект, при изменении которого создается новый объект. Reducer — это функция, которая в соответствии с переданным в нее action, создает новый экземпляр state, в котором могут быть property state.

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

Для каждого события будут храниться два обязательных значения:

  • running — булево состояние, что событие запущено, по-умолчанию false
  • error — ошибка, которая произошла при выполнении события, по-умолчанию null

Также возможно несколько опциональных значений.

  • done — булевое состояние, в которое говорит, что событие было успешно выполнено, по-умолчанию false. Обычно нужно для определения, было ли ранее запущено событие, например, нужно единожды инициализировать что-то.
  • value — значение полученное в результате выполнения события, по-умолчанию null. Обычно хранит загруженные/полученные данные откуда либо, например список последних новостей.

Конечно, помимо данных переменных, можно хранить что-угодно, но чаше всего, встречаются именно описанные значения.

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

Согласно данной идеологии, событие для инициализации языка будет сохранять в State:

  • runninginitiating
  • errorinitError
  • doneinitialized
  • value —{ languages, language, defaultLanguage }

для установки языка, будут сохранены в Store:

  • runningselectingLang
  • errorselectErrorLang
  • doneselectedLang
  • valuecurrentLang

Теперь реализуем интерфейс Translation State:

export interface TranslationState {
/**
* Current language
*/
currentLanguage: string;

/**
* Translation is initialized
*/
initialized: boolean;

/**
* Translation init error
*/
initError?: string;

/**
* Is initiating
*/
initiating: boolean;

/**
* Default language
*/
language: string;

/**
* Languages
*/
languages: string[];

/**
* Set language error
*/
setError?: string;

/**
* Is setting
*/
setting: boolean;
}
export const initialState: TranslationState = {
currentLanguage: null,
initialized: false,
initError: null,
initiating: false,
language: null,
languages: null,
setError: null,
setting: false
};

Были опущены постфиксы для ряда полей, т.к. в идеале, если можно сохраняемому свойству задать имя из одного слова, то это считается оптимальным. В данном случае пользуемся правилом, пишем на столько коротко, чтобы было понятно. Если например, было бы еще одно событие, например установка системного языка, то тогда мы уже не смогли бы назвать свойства как setting, т.к. заранее не известно, к какому из двух событий принадлежит это поле, и поэтому названия были бы settingLang и settingSystemLang.

Также, небольшим отступлением от описанного подхода являются 3 поля:

  • currentLanguage
  • language
  • languages

Которые по сути представляют собой результат выполнения события инициализации локали, представленного интерфейсом TranslationConfig

/**
* Translation config
*/
export interface TranslationConfig<T = string> {
/**
* Selected language
*/
currentLanguage?: T;

/**
* Default language
*/
language: T;

/**
* Available language
*/
languages: T[];
}

Но так как, в приложении language и languages не будут меняться, то данный интерфейс был просто вынесен на уровень state.

Translation Reducer

Reducer — это функция, которая в соответствии с переданным в нее action, создает новый экземпляр state, в котором могут быть property state.

Так как Translation события состоят из 5 action’ов, в reducer обычно будет попадать только 3 из них:

  1. Запуск события
  2. Успешное выполнение события
  3. Ошибка выполнения события

При запуске события, ставиться состояние running = true, а также иногда сбрасывается ошибка.

При успешном выполнении события, ставиться состояние running = false, а также сохраняется данные, полученные при выполнении события.

При ошибке выполнения события, снова ставиться состояние running = false, а также сохраняется ошибка если это необходимо.

Следуя данному алгоритму, добавим обработку событий в translation reducer

export function translationReducer(state: TranslationState = initialState, action: TranslationAction): TranslationState {
switch (action.type) {
case TranslationActionTypes.InitiatingTranslation: {
state = {
...state,
initError: null,
initiating: true
};
break;
}
case TranslationActionTypes.TranslationInitialized: {
state = {
...state,
...action.payload,
initialized: true,
initiating: false
};
break;
}
case TranslationActionTypes.TranslationInitError: {
state = {
...state,
initError: action.payload,
initialized: true,
initiating: false
};
break;
}

case TranslationActionTypes.SettingLanguage: {
state = {
...state,
currentLanguage: action.payload,
setError: null,
setting: true
};
break;
}
case TranslationActionTypes.LanguageSet: {
state = {
...state,
setting: false
};
break;
}
case TranslationActionTypes.LanguageSetError: {
state = {
...state,
setError: action.payload,
setting: false
};
break;
}
}

return state;
}

Единственное, что может ввести в заблуждение, это свойство initialized.

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

Translation Selectors

Если вы впервые читаете о Ngrx Selectors, то сначала ознакомитесь с официальной документацией Ngrx в разделе Architecture — selectors.

В двух словах, selectors — это observable геттеры для хранимых свойств в state.

Так как selectors, являются геттерами, то это самая простая часть State.

Для каждого свойства Translation State создадим соответствующий selector:

import { createFeatureSelector, createSelector } from '@ngrx/store';

import { TRANSLATION_FEATURE_KEY, TranslationState } from './translation.reducer';

const getTranslationState = createFeatureSelector<TranslationState>(TRANSLATION_FEATURE_KEY);

const getCurrentLanguage = createSelector(
getTranslationState,
(state: TranslationState) => state.currentLanguage
);

const getInitialized = createSelector(
getTranslationState,
(state: TranslationState) => state.initialized
);

const getInitError = createSelector(
getTranslationState,
(state: TranslationState) => state.initError
);

const getInitiating = createSelector(
getTranslationState,
(state: TranslationState) => state.initiating
);

const getLanguage = createSelector(
getTranslationState,
(state: TranslationState) => state.language
);

const getLanguages = createSelector(
getTranslationState,
(state: TranslationState) => state.languages
);

const getSetError = createSelector(
getTranslationState,
(state: TranslationState) => state.setError
);

const getSetting = createSelector(
getTranslationState,
(state: TranslationState) => state.setting
);

export const translationQuery = {
getCurrentLanguage,
getInitialized,
getInitError,
getInitiating,
getLanguage,
getLanguages,
getSetError,
getSetting
};

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

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

const getIsRussian = createSelector(
getTranslationState,
(state: TranslationState) => state.currentLanguage === 'ru'
);

Или например можно выбрать объект по Id из массива:

export const getTicketById = (ticketId: number) =>
createSelector(
getTicketState,
state => state.tickets.find(ticket => ticket.id === ticketId) || null
);

Также можно вкладывать select’ы во внутрь других и много чего еще.

Translation Effects

Если вы впервые читаете о Ngrx Effects, то сначала ознакомитесь с официальной документацией Ngrx в разделе Effect — overview.

В двух словах, Effects позволяют перехватывать выбранные Actions, и испускать новые Actions асинхронно.

К effect’ам тоже нет жестких требований, поэтому будем использовать авторский подход к организации эффектов.

Так как у нас для каждого события 5 состояний, то для них у нас будет минимум 2 эффекта.

  • run — проверка возможности запуска события, и если событие может быть запущено, запустить событие, если же событие уже запущено, отмерить повторный запуск события.
  • running — запуск события, которое может быть как синхронным, так и асинхронным, по завершении события вызвать успешное/не успешное завершение событие
  • done — опциональный эффект, если нужно породить ряд дополнительных эффектов.

Реализация Translation Effects

В данной статье, мы используем AbstractEffects, которые являются вспомогательным классом, для effects, но который не является обязательным. Более подробнее об этом классе, будет рассказано в следующей статье, которая будет посвящена тестированию Ngrx State’а.

import { DEFAULT_GENERIC_RETRY_STRATEGY, GenericRetryStrategyOptions } from '../utils/generic-retry-strategy.util';
import { md5 } from '../utils/md5.util';

/**
* Abstract Effects
*/
export abstract class AbstractEffects<T = any> {
/**
* Generic retry strategy options
*/
protected genericRetryStrategyOptions = DEFAULT_GENERIC_RETRY_STRATEGY;

protected constructor(protected readonly key: string) {}

/**
* Return effect id by payload
*
@param payload payload
*/
getIdByPayload(payload: any): string {
return md5(JSON.stringify(payload));
}

/**
* Return state from "partial" store
*
@param state State
*/
getState<S = any>(state: S): T {
return state[this.key];
}

/**
* Set generic retry strategy options
* Notice: Required only for testing
*
*
@param options options
*/
setRetryStrategyOptions(options: Partial<GenericRetryStrategyOptions>): void {
this.genericRetryStrategyOptions = { ...this.genericRetryStrategyOptions, ...options };
}
}

где genericRetryStrategy кастомный rxjs оператор:

import { Observable, SchedulerLike, throwError, timer } from 'rxjs';
import { mergeMap } from 'rxjs/operators';

export interface GenericRetryStrategyOptions {
maxRetryAttempts?: number;
scalingDuration?: number;
excludedStatusCodes?: number[];
scheduler?: SchedulerLike;
}

export const DEFAULT_GENERIC_RETRY_STRATEGY: GenericRetryStrategyOptions = {
maxRetryAttempts: 3,
scalingDuration: 1000,
excludedStatusCodes: [403],
scheduler: null
};

export const genericRetryStrategy = ({
maxRetryAttempts = 3,
scalingDuration = 1000,
excludedStatusCodes = [403],
scheduler = null
}: GenericRetryStrategyOptions = {}) => (attempts: Observable<any>) => {
return attempts.pipe(
mergeMap((error, i) => {
const retryAttempt = i + 1;
if (retryAttempt > maxRetryAttempts || excludedStatusCodes.find(e => e === error.status)) {
return throwError(error);
}

return timer(retryAttempt * scalingDuration, scheduler);
})
);
};

Также отличии от классических эффектов в Ngrx, используется DataPersistence предоставляемый Nx, который в разы упрощает и уменьшает количество кода. Подробнее можно ознакомиться здесь.

Translation Effects согласно предложной методологии, примут вид:

import { Injectable } from '@angular/core';
import { Effect, Actions } from '@ngrx/effects';
import { DataPersistence } from '@nrwl/angular';
import { map } from 'rxjs/operators';

import { AbstractEffects } from '@medium-stories/store';

import { TranslationService } from '../interfaces/translation-service.interface';
import {
InitiatingTranslation,
InitTranslation,
LanguageSet,
LanguageSetCanceled,
LanguageSetError,
SetLanguage,
SettingLanguage,
TranslationActionTypes,
TranslationInitCanceled,
TranslationInitError,
TranslationInitialized
} from './translation.actions';
import { TRANSLATION_FEATURE_KEY, TranslationPartialState, TranslationState } from './translation.reducer';

@Injectable()
export class TranslationEffects extends AbstractEffects<TranslationState> {
@Effect() init$ = this.dataPersistence.fetch(TranslationActionTypes.InitTranslation, {
run: (action: InitTranslation, store: TranslationPartialState) => {
const state = this.getState(store);

return !state.initiating ? new InitiatingTranslation() : new TranslationInitCanceled();
},
onError: (action: InitTranslation, error) => console.error(error.toString())
});

@Effect() initiating$ = this.dataPersistence.fetch(TranslationActionTypes.InitiatingTranslation, {
run: (action: InitiatingTranslation, store: TranslationPartialState) => {
const config = this.translationService.getConfig();
return this.translationService
.init(this.translationService.getConfig())
.pipe(map<any, TranslationInitialized>(data => new TranslationInitialized(config)));
},
onError: (action: InitiatingTranslation, error) => new TranslationInitError(error.toString())
});

@Effect() set$ = this.dataPersistence.fetch(TranslationActionTypes.SetLanguage, {
run: (action: SetLanguage, store: TranslationPartialState) => {
const state = this.getState(store);

return !state.setting || action.force
? state.languages.includes(action.payload)
? new SettingLanguage(action.payload)
: new LanguageSetError(`Language isn't supported`)
: new LanguageSetCanceled();
},
onError: (action: SetLanguage, error) => console.error(error.toString())
});

@Effect() setting$ = this.dataPersistence.fetch(TranslationActionTypes.SettingLanguage, {
run: (action: SettingLanguage, store: TranslationPartialState) => {
return this.translationService.setLanguage(action.payload).pipe(map<any, LanguageSet>(() => new LanguageSet()));
},
onError: (action: SettingLanguage, error) => new LanguageSetError(error.toString())
});

constructor(
private actions$: Actions,
private dataPersistence: DataPersistence<TranslationPartialState>,
private translationService: TranslationService
) {
super(TRANSLATION_FEATURE_KEY);
}
}

Стоит отметить, то в данной реализации нет запрета на повторную инициализацию. Формально можно проверять, было ли раньше вызвана инициализация, но в данном случае это логика была опущена из тех соображений, что метод init будет запускаться один раз, в начале запуска Angular приложения. Если вам необходимо возможность переинициализации приложение, то тогда нужно внести изменения в эффект init$.

Translation Facade

Последней частью Translation State, является Translation Facade, который представляет собой паттерн программирования facade для доступа к State. Подробнее о данном подходе, можно почитать здесь.

В двух словах, facade предоставляет методы, которые диспачат(вызывают) новые action’ы без явной инициализации Store.

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

Для всех методов facade будем добавлять опциональный параметр force, который представляет собой, принудительное исполнение события, даже если подобное событие уже было запущено.

Translation Facade

import { Injectable } from '@angular/core';
import { select, Store } from '@ngrx/store';

import { fromTranslationActions } from './translation.actions';
import { TranslationPartialState } from './translation.reducer';
import { translationQuery } from './translation.selectors';

@Injectable({
providedIn: 'root'
})
export class TranslationFacade {
/**
* Observed current language
*/
currentLanguage$ = this.store.pipe(select(translationQuery.getCurrentLanguage));

/**
* Observed initialized
*/
initialized$ = this.store.pipe(select(translationQuery.getInitialized));

/**
* Observed init error
*/
initError$ = this.store.pipe(select(translationQuery.getInitError));

/**
* Observed initiating
*/
initiating$ = this.store.pipe(select(translationQuery.getInitiating));

/**
* Observed default language
*/
language$ = this.store.pipe(select(translationQuery.getLanguage));

/**
* Observed languages
*/
languages$ = this.store.pipe(select(translationQuery.getLanguages));

/**
* Observed set error
*/
setError$ = this.store.pipe(select(translationQuery.getSetError));

/**
* Observed setting
*/
setting$ = this.store.pipe(select(translationQuery.getSetting));

constructor(private store: Store<TranslationPartialState>) {}

/**
* Init translation
*
@param payload Force
*/
init(payload?: boolean): void {
this.store.dispatch(new fromTranslationActions.InitTranslation(payload));
}

/**
* Set language
*
@param payload Language
*
@param force Force
*/
setLanguage(payload?: string, force?: boolean): void {
this.store.dispatch(new fromTranslationActions.SetLanguage(payload, force));
}
}

Проверка работоспособности

Для демонстрации, сделаем клон frontend/store приложения, создание которого было описано здесь, и назовем его frontend/translation-state.

Добавим в AppComponent инициализацию локали:

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

import { TranslationFacade } from '@medium-stories/translation';

@Component({
selector: 'medium-stories-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {
constructor(private translationFacade: TranslationFacade) {
this.translationFacade.init();
}
}

В HomeComponent поставим задание локали с помощью TranslationFacade, который подключим в конструкторе:

constructor(public translationFacade: TranslationFacade, private cookieStorage: CookieStorage) {

Соответственно в файле шаблона вызовем методы задания локали:

<div class="home-langs">
<button type="button" (click)="translationFacade.setLanguage('ru')">RU</button> /
<button type="button" (click)="translationFacade.setLanguage('en')">En</button>
</div>

Теперь можно запустить проект и проверить. Devtools работает только в браузероной версии в dev режиме.

ng serve frontend-translation-state

После запуска проекта выберем другой язык и посмотрим историю.

Можно увидеть, что в момент инициализации локали, state ~ initialState

Translation state initial

На стадии [Translation] Initiating translation, значение initiating стало true:

Translation state initiating

На стадии [Translation] Translation initialized , в translation state можно увидеть сохраненные значения.

Translation state set initialized

Теперь при выборе другой локали, изменяется значение текущей локали и ставится setting = true

Translation state setting true

После успешной установки локали значение setting = false

Translation state setting false

Исходники

Все исходники находятся на github, в репозитории https://github.com/Fafnur/medium-stories

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

git checkout translation-state

Предыдущие статьи:

  1. Статья о настройке Prettier, tslint и eslint в Angular
  2. Статья про LocalStorage, SessionStorage в Angular Universal
  3. Статья про мультиязычность в Angular & Universal с помощью ngx-translate
  4. Статья про Redux в Angular с помощью Ngrx. Создание Store в Angular

Следующие статьи:

  1. Статья про Тестирование Ngrx State в Angular с помощью Jest

--

--

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

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