NgRX с нуля: Полное руководство по управлению состоянием в Angular

Сегодня мы рассмотрим одну из ключевых тем в разработке приложений на Angular — использование NgRX для управления состоянием. Мы погрузимся в архитектуру Redux, узнаем, что такое state manager, и как NgRX помогает в создании централизованного хранилища данных.

Проблема

Пример источников состояний

Если мы изучим любое приложение, мы увидим множество источников данных: Local Storage, Session Storage, данные на сервере, а также данные, хранящиеся напрямую в HTML. Управление этими данными сложно. Если каждый будет сохранять данные по-своему, со временем станет сложно разбираться в этом многообразии состояний. Также возникают вопросы о том, какие данные актуальны, если одна и та же информация хранится в нескольких местах. Необходимо определить источник истины в таких случаях. Это и есть основная проблема Frontend-приложений, которую решают State Managers.

Что такое State Manager?

State Manager - это подход, позволяющий управлять состоянием приложения. В основе лежит объект состояния, называемый state, который находится в одном из многих сменяемых, но конечных состояний. Redux - это паттерн state manager, который используется для управления и обновления состояния приложения с помощью событий, называемых actions.

Основные идеи Redux:

  1. Единый источник данных - состояние приложения хранится в одном месте, что позволяет держать данные консистентными.

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

  3. Предсказуемые изменения состояния - изменения состояния происходят только через обработчики событий (reducers).

State Manager NgRX

NgRX - это библиотека для управления состоянием в Angular, построенная на базе Redux и использующая ReactiveX (RxJS). Она внедряется в Angular и предоставляет инструменты для создания и управления централизованным хранилищем данных.

Попробуем посмотреть сразу на схему работы с NgRX, и начнем с понятного Component и Service (они выделены другим цветом на схеме, так как являются сущностями Angular)

State Manager NgRX

Компонент может заинжектить сущность из NgRX и с помощью Selector получить данные из Store, а также вызвать Action, который может повлиять на Store.

То есть, по схеме ниже:

  • Раньше компонент брал данные из сервисов, теперь он берет их из селекторов.

  • Раньше компонент вызывал сервис, теперь сервис вызывается через эффекты.

По сути, с точки зрения Angular-приложения, ничего не изменилось. Компонент вызывает Action и получает данные из Selectors вместо работы с Services. Сервис по-прежнему обращается к Backend, но теперь взаимодействует с Effects вместо компонента.

Что представляет из себя Store?

Давайте теперь посмотрим на схему со стороны самого State Manager NgRX, что он делает и что из себя представляет.

State Manager - это единственный источник правды для всего приложения. Он предоставляет один большой объект, который хранит все данные для приложения, но при этом некоторые данные могут храниться и вне его. Стоит запомнить, если данные должны использоваться в нескольких компонентах или вообще используются по всему приложению, мы их кладем в Store, это просто удобно.

Например, у нас к Store через Selectors подключено 20 компонентов, как только данные в Store обновятся, то тут же 20 компонентов обновят эти данные. Все данные реактивны.

Резюмируем: это просто большой объект с данными, где каждый ключ представляет собой NgRX Feature.

Что такое NgRX Feature?

Это конечная смысловая единица функционала для пользователя, например: Корзина, Каталог товаров, Личный кабинет, Авторизация. В NgRX Store фича — это функционал, принято, чтобы глобальный объект Store содержал ключи в виде фич, например Articles.

1 проект - это 1 Store.

Как данные в Store меняются?

Данные в Store изменяются только иммутабельно. Это значит, что Store не может изменить какое-то поле в себе, он может только полностью перезаписаться. То есть, если у нас есть Store с 50 полями, и мы хотим изменить его, мы можем только полностью удалить объект и создать новый с нужным изменением.

Store не изменяется никогда. Он может только пересоздаться.

Каким образом можно изменить данные в Store? Action.

Изменение в Store начинается с Component, который вызовет специальную сущность NgRX Action. Action - это событие, например, загрузка товаров, загрузка корзины, успешная загрузка корзины, авторизация пользователя. Есть ли случаи, когда Action можно вызвать не из компонента? (По схеме видно, что Action могут возвращаться из Effects, но это уже другая ситуация).

Например:

  1. Вызываем Action Load Users из Component.

  2. Вся остальная логика происходит внутри NgRX.

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

Посмотрим пример файла с Actions.

import { createAction, props } from '@ngrx/store';
import { User } from '../models/user.model';

export const loadUsers = createAction(
  '[Users] Load Users'
);

export const loadUsersSuccess = createAction(
  '[Users] Load Users Success',
  props<{ users: User[] }>()
);

export const loadUsersFailure = createAction(
  '[Users] Load Users Failure',
  props<{ error: string }>()
);

export const addUser = createAction(
  '[Users] Add User',
  props<{ user: User }>()
);

export const deleteUser = createAction(
  '[Users] Delete User',
  props<{ userId: number }>()
);

export const updateUser = createAction(
  '[Users] Update User',
  props<{ userId: number, updatedUser: User }>()
);

Вот эти идентификаторы Action - ‘[Users] Update User’, ‘[Users] Load Users’ и т.д. должны быть уникальны. Если пересекутся, будет баг, и вместо одного Action может быть вызван другой. В идентификаторе пишется понятный текст, чтобы проще было дебажить с помощью расширения в Chrome Redux DevTools.

Action создаем с помощью createAction(), где первый аргумент - это идентификатор Action (ниже из чего он состоит):

  1. [Users] - домен, где произошло событие (Action).

  2. Update User - наименование Action.

Второй аргумент функции createAction() - это параметры, с которыми можно вызвать Action (что-то передать из компонента) props<{ userId: number, updatedUser: User }>(). Например, для Action удаления товара из корзины в качестве аргумента мы передаем id позиции в корзине.

Actions может быть много.

Action нужно использовать в коде один раз, это облегчает отладку проблем с помощью Redux DevTools.

Как вызывается Action в компоненте?

@Component({
  selector: 'app-user-list',
  templateUrl: './user-list.component.html',
  styleUrls: ['./user-list.component.css']
})
export class UserListComponent {
  constructor(private readonly store: Store) {}

  addUser(newUser: User) {
    this.store.dispatch(addUser({ user: newUser }));
  }
}

Reducer - что это такое? Как Reducer обновляет State?

Reducer срабатывает только тогда, когда вызывается Action. Reducer содержит код, который выполняется при вызове Action. Сам по себе Store не знает, как обновляться; это происходит в Reducer. Задача Reducer — изменять Store в ответ на Action.

Reducers может быть много. Количество Reducers соответствует количеству фич.

import { createReducer, on } from '@ngrx/store';
import { User } from '../models/user.model';
import * as UsersActions from './users.actions';

// Определение начального состояния
export interface UsersState {
  users: User[];
  loading: boolean;
  error: string | null;
}

export const initialState: UsersState = {
  users: [],
  loading: false,
  error: null,
};

// Создание Reducer с использованием createReducer
export const usersReducer = createReducer(
  initialState,
  
  // Обработка действия для загрузки пользователей
  on(UsersActions.loadUsers, state => ({
    ...state,
    loading: true,
    error: null,
  })),
  
  // Обработка действия при успешной загрузке пользователей
  on(UsersActions.loadUsersSuccess, (state, { users }) => ({
    ...state,
    users,
    loading: false,
    error: null,
  })),
  
  // Обработка действия при неудачной загрузке пользователей
  on(UsersActions.loadUsersFailure, (state, { error }) => ({
    ...state,
    loading: false,
    error,
  })),
  
  // Обработка действия для добавления пользователя
  on(UsersActions.addUser, (state, { user }) => ({
    ...state,
    users: [...state.users, user],
  })),
);

initialState - начальное значение, которое будет у фичи (это значение будет храниться в объекте State по ключу фичи).

on() функция - может быть сколько угодно, на каждый Action:

  1. параметр - какой action,

  2. функция, перезаписывающая State.

(state, { user }) => ({
    ...state,
    users: [...state.users, user],
  })

// 1 аргумент - текущий State
// 2 аргумент actionPayload объект с новым юзером, что передался нам из Action
// функция возвращает новый объект State, где поле users будет включать нового юзера
on(UsersActions.addUser, (state, { user }) => ({
    ...state,
    users: [...state.users, user],
  })),

Расшифруем on функцию редьюсера: на Action UsersActions.addUser оставь текущие значения в объекте state, только обнови поле users - добавь туда нового user (который был передан из action).

Как данные из Store берутся? Selectors.

Store может быть огромным объектом на тысячи полей, и как нам взять именно нужные поля? Для этого в NgRX специально придуманы и постоянно используются Selectors. Их можно создать сколько угодно.

import { createFeatureSelector, createSelector } from '@ngrx/store';
import { UsersState } from './users.reducer';
import { User } from '../models/user.model';

// Создаем селектор для feature state 'users'
export const selectUsersState = createFeatureSelector<UsersState>('users');

// Селектор для получения всех пользователей
export const selectAllUsers = createSelector(
  selectUsersState,
  (state: UsersState) => state.users
);

// Селектор для получения состояния загрузки
export const selectUsersLoading = createSelector(
  selectUsersState,
  (state: UsersState) => state.loading
);

// Селектор для получения состояния ошибки
export const selectUsersError = createSelector(
  selectUsersState,
  (state: UsersState) => state.error
);

// Селектор для получения пользователя по ID
export const selectUserById = (userId: number) => createSelector(
  selectAllUsers,
  (users: User[]) => users.find(user => user.id === userId)
);

Селектор - это результат выполнения функции createSelector, в который мы передаем какие-то данные, чтобы получить нужные данные в компоненте из State.

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

export const selectUsersState = createFeatureSelector<UsersState>('users');

Затем мы уже создаем более мелкие селекторы, которые достают конкретные поля из объекта State.

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

export const selectUsersLoading = createSelector(
  selectUsersState,
  selectUsersState2,
  selectUsersState3,
  (state: UsersState, state2: UsersState, state3: UsersState) => state.loading
);

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

Как использовать селекторы?

@Component({
  selector: 'app-user-list',
  templateUrl: './user-list.component.html',
  styleUrls: ['./user-list.component.css']
})
export class UserListComponent implements OnInit {
  users$: Observable<User[]> = this.store.select(selectAllUsers);
  user$: Observable<User | undefined> = this.store.select(selectUserById(1));

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

Если хотим не только данные поменять, а еще запрос на backend сделать? Effects.

Что делать, если нам нужно на вызов Action делать запрос на Backend? В NGRX эту проблему решают Effects. Так назвали потому-что в программировании есть чистые функции, те которые просто возвращают значения, не ходят на backend, не меняют значения переменных во вне, т.е. без side effect. Если функция делает запрос на Backend значит она с Side Effect, от сюда и сделали такое наименование. Effects - это с помощью чего мы делаем запросы на Backend. Например, мы вызвали Action, но мы хотим не просто Store обновить, но и сделать запрос на Backend. Effects - это результат вызова функции createEffect(). Effect вызывается на Action и возвращает другой Action.

Например:

1. Мы вызвали Action Load Users

2. Effect увидел что Action Load Users вызван

3. В ответ на это Effect делает запрос на backend, получает данные

4. Вызывает другой Action Load Users Success

import { UsersActions } from '@app/store/actions';
import { ofType, Actions, createEffect } from '@ngrx/effects';
import { ApiService } from '@app/services/api.service';
import { catchError, map, switchMap } from 'rxjs/operators';
import { of } from 'rxjs';

//... 
export const deleteUser: FunctionalEffect<function() => (Actions) => void> = createEffect(
  () => {
    const actions$: Actions<any> = inject(Actions);
    const apiService: ApiService = inject(ApiService);
    return actions$.pipe(
      ofType(UsersActions.deleteUser),
      switchMap(
        (project: { id: number }) => apiService.delete<void>(`/users/${project.id}`).pipe(
          map((props: { id: number }) => UsersActions.deleteUserSuccess({ props: { id: project.id } })),
          catchError((error: any) => {
            console.error('Error', error);
            return of(UsersActions.deleteUserFailed({ props: { error } }));
          })
        )
      )
    )
  },
  { functional: true }
);

Разберем простой Effects выше, он описан функцией, о чем мы сообщаем в 2 параметре { functional: true }. 1 параметр это callback, в котором мы подписались на поток Actions, отфильтровали с помощью ofType нужный нам Action и далее выполнили запрос на backend, если запрос успешен возвращаем в поток Action deleteUserSuccess, иначе Action deleteUserFailed.

Резюмируем

Весь flow работы NgRX: Эффекты реагируют на вызов Action, затем делают запрос к сервисам, сервисы идут на backend, данные с бэка приходят в сервис, сервис отдает данные в Effect, эффект вызывает Action, потом этот Action попадает в Reducer и Reducer обновляет Store, затем из Store с помощью селектора данные приходят в компонент.

Есть глобальное состояние, которым мы пользуемся, оно единственный источник правды для приложения. Мы можем его считывать, изменять и иногда нам требуется сделать поход на backend. NgRX предоставляет все инструменты для этого:

Selectors - чтобы данные считывать. Actions - чтобы данные изменять. Effects - чтобы делать запросы на backend.