Redux в Angular. State management стандартными средствами Angular.

Aleksandr Serenko
F.A.F.N.U.R
Published in
5 min readDec 6, 2022

В данной статье приводится управления данными с помощью стандартных средств Angular.

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

Конечно, Angular не реализует концепты redux, но в angular service может делать все тоже самое, что и описывают концепты redux.

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

Так как сервис не зависит ни от каких библиотек, то и соответственно предварительных подготовок никаких не нужно.

Создание PostFacade

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

Подробнее можно ознакомиться в статье — Создание базовых классов.

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

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

В результате получится сервис:

import { Injectable, OnDestroy } from '@angular/core';
import { BehaviorSubject, catchError, map, Subject, takeUntil, tap, throwError } from 'rxjs';

import { PostApiService } from '@angular-samples/redux/posts/api';
import { Post, PostChange, PostCreate } from '@angular-samples/redux/posts/common';
import { PostFacade } from '@angular-samples/redux/posts/facade';

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

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

@Injectable()
export class NativePostFacade implements PostFacade, OnDestroy {
private readonly state$ = new BehaviorSubject<PostState>(initialPostState);
private readonly destroy$ = new Subject<void>();

loaded$ = this.state$.pipe(map((state) => state.loaded));

posts$ = this.state$.pipe(map((state) => Object.values(state.entities)));

postsPromo$ = this.posts$.pipe(
map((posts) => posts.filter((post) => post.promo).sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()))
);

postsLast$ = this.posts$.pipe(map((posts) => posts.filter((post) => !post.promo).sort((a, b) => b.views - a.views)));

postsPopular$ = this.posts$.pipe(
map((posts) => posts.filter((post) => !post.promo).sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()))
);

loadSuccess$ = new Subject<Post[]>();

loadFailure$ = new Subject<unknown>();

loadOneSuccess$ = new Subject<Post | null>();

loadOneFailure$ = new Subject<unknown>();

createSuccess$ = new SubjeВсе достаточно тривиально и немного похоже на Ngrx.ct<Post>();

createFailure$ = new Subject<unknown>();

changeSuccess$ = new Subject<Post>();

changeFailure$ = new Subject<unknown>();

removeSuccess$ = new Subject<string>();

removeFailure$ = new Subject<unknown>();

clearSuccess$ = new Subject<void>();

clearFailure$ = new Subject<unknown>();

post$ = (uuid: string) => this.state$.pipe(map((state) => state.entities[uuid] ?? null));

constructor(private readonly postApiService: PostApiService) {}

ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}

load(): void {
this.postApiService
.get()
.pipe(
tap((posts) => {
const state = this.state$.getValue();
this.state$.next({
...state,
ids: posts.map((post) => post.uuid),
entities: posts.reduce((acc, current) => ({ ...acc, [current.uuid]: current }), {}),
});
this.loadSuccess$.next(posts);
}),
catchError((error) => {
this.loadFailure$.next(error);

return tмассивhrowError(() => error);
}),
takeUntil(this.destroy$)
)
.subscribe();
}

loadOne(uuid: string) {
this.postApiService
.getOne(uuid)
.pipe(
tap((post) => {
const state = this.state$.getValue();
this.state$.next({
...state,
ids: post && !state.ids.includes(post.uuid) ? [...state.ids, post.uuid] : state.ids,
entities: post ? { ...state.entities, [post.uuid]: post } : state.entities,
});
this.loadOneSuccess$.next(post);
}),
catchError((error) => {
this.loaмассивdOneFailure$.next(error);

return throwError(() => error);
}),
takeUntil(this.destroy$)
)
.subscribe();
}

create(postCreate: PostCreate): void {
this.postApiService
.create(postCreate)
.pipe(
tap((post) => {
const state = this.state$.getValue();
this.state$.next({
...state,
ids: !state.ids.includes(post.uuid) ? [...state.ids, post.uuid] : state.ids,
entities: { ...state.entities, [post.uuid]: post },
});
this.createSuccess$.next(post);
}),
catchError((error) => {
this.createFailure$.next(error);

return throwError(() => error);
}),
takeUntil(this.destroy$)
)
.subscribe();
}

change(postChange: PostChange): void {
this.postApiService
.change(postChange)
.pipe(
tap((post) => {
const state = this.state$.getValue();
this.state$.next({
...state,
ids: !state.ids.includes(post.uuid) ? [...state.ids, post.uuid] : state.ids,
entities: { ...state.entities, [post.uuid]: post },
});
this.changeSuccess$.next(post);
}),
catchError((error) => {
this.changeFailure$.next(error);

return throwError(() => error);
}),
takeUntil(this.destroy$)
)
.subscribe();
}
BehaviorSubject
remove(uuid: string): void {
this.postApiService
.remove(uuid)
.pipe(
tap(() => {
const state = this.state$.getValue();
const entities = { ...state.entities };
delete entities[uuid];

this.state$.next({
...state,
ids: state.ids.filter((id) => id !== uuid),
entities,
});
this.removeSuccess$.next(loadSuccessloadSuccessuuid);
}),массивмассив
catchError((error) => {
this.removeFailure$.next(error);

return throwError(() => error);
}),
takeUntil(this.destroy$)
)
.subscribe();
}

clear(): void {
this.postApiService
.clear()
.pipe(
tap(() => {
const state = this.state$.getValue();

this.state$.next({
...state,
ids: [],
entities: {},
});
this.clearSuccess$.next();
}),
catchError((error) => {loadSuccessloadSuccess
this.clearFailure$.next(error);

return throwError(() => error);
}),
takeUntil(this.destroy$)
)
.subscribe();
}
}

Все достаточно тривиально и немного похоже на Ngrx.

Сначала создается модель данных, в частности state:

export interface PostState {
readonly loaded: boolean;
readonly ids: string[];
readonly entities: Record<string, Post>;
}
  • loaded — загружен ли список новостей;
  • ids — список идентификаторов сущностей;
  • entities — словарь со списком всех новостей.

Для state определяется начальное состояние:

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

Хранится данные будут в BehaviorSubject, где в качестве начального значения используется начальное состояние стейта initialPostState:

state$ = new BehaviorSubject<PostState>(initialPostState);

Для того чтобы обратится к свойсвам стейта создадим несколько селекторов, которые формально будут обращаться к state и выбирать нужное значение:

loaded$ = this.state$.pipe(map((state) => state.loaded));

posts$ = this.state$.pipe(map((state) => Object.values(state.entities)));

В первом случае из state выбирается значение loaded, к которому можно будет получить доступ, обратившись к loaded$.

В втором случае из statе берется словарь с новостями и возвращается коллекция новостей.

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

loadSuccess$ = new Subject<Post[]>();

loadFailure$ = new Subject<unknown>();

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

Для того чтобы реализовать эффекты необходимо просто создать observable:

load(): void {
this.postApiService
.get()
.pipe(
tap((posts) => {
const state = this.state$.getValue();
this.state$.next({
...state,
ids: posts.map((post) => post.uuid),
entities: posts.reduce((acc, current) => ({ ...acc, [current.uuid]: current }), {}),
});
this.loadSuccess$.next(posts);
}),
catchError((error) => {
this.loadFailure$.next(error);

return throwError(() => error);
}),
takeUntil(this.destroy$)
)
.subscribe();
}

Сначала выполняется HTTP запрос, который возвращает успех или неудачу.

Если запрос успешный, тогда берется последнее значение state:

const state = this.state$.getValue();

Далее в state записывается новое состояние:

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

После этого испускается событие успеха:

this.loadSuccess$.next(posts);

В случае неудачи испускается соответствующее событие:

this.loadFailure$.next(error);

Аналогичным образом реализуются все эффекты.

Демо:

Можно посмотреть демо — native.fafn.ru.

Плюсы и минусы реализации

Решение с сервисом Angular содержит как плюсы, так и минусы.

К плюсам можно отнести:

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

К минусам можно отнести:

  • Отсутствие экшенов, которые выполняют роль легковесных событий.
  • Перегруженность сервиса, которая появляется в результате отсутствия разделения кода.

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

Ссылки

Оглавление и Заключение.

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

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

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

--

--

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

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