はじめてのNgRx v8を使ったAngularアプリケーション

どうも!大阪オフィスの西村祐二です。

Angularでアプリケーションを作っていると、ログイン時のトークンやいろんなコンポーネントでよく使う情報をどうやってアプリケーション内で管理していくか悩むことがあります。

Angularには、Reduxベースで状態を管理できる準公式ライブラリのNgRxがあります。

v8になってボイラーテンプレートなどの記述量が減って使いやすくなったので、今回はNgRxをつかってTODOアプリケーションを作ってみたいと思います。

NgRxとは

NgRxはAngularアプリケーション用の状態管理ライブラリです。

NgRxを利用するとReduxのような状態管理ができます。

また、NgRxはRxJSベースで作られており、状態をストリームとして扱うことができます。

なんでNgRxを使う?

簡単に説明すると状態管理にルールを付けて、秩序をもたらすためです。RxJSでも状態管理ができますが、メンバーが増えたり、規模が大きくなると各々の思想で実装され管理が煩雑になり、メンテナンスしにくくなってきます。

NgRxの概念図

下記の図のように情報の流れにルールを持たせます。

さっそく作っていく

ゴール

下記のGifのように、NgRxで状態管理したアプリケーションを作成し、ツールで状態がどのように変化していったか確認できるようにする。

環境

Angular CLI: 8.3.17
Node: 10.15.1
OS: darwin x64
Angular: 8.2.13
ngrx: 8.4.0

流れ

  1. NgRxを使わずに普通にTODOアプリケーションを作成
  2. NgRxを導入し、Reduxのような状態管理をするアプリケーションに修正していく

Angularアプリを生成

▼CLIをインストールします。

$ npm i -g @angular/cli

▼アプリケーションを作成します。

$ ng new ngrx8-tutorial --routing --style scss
$ cd ngrx8-tutorial

▼TODO関連資産をまとめるモジュールを作成します。

$ ng g module todo --module=app.module.ts --routing

▼routingの設定を行います。

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

const routes: Routes = [
  {
    path: '',
    loadChildren: () => import('./todo/todo.module').then(m => m.TodoModule)
  }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

▼TODO処理用のコンポーネントを作成します。

上記で作成したモジュールにコンポーネントを登録するため--moduleオプションを指定します。

最終的にアプリ全体のモジュールにコンポーネントを登録するため--exportオプションを指定します。

パフォーマンスを考慮してchangeDetectionをOnPushで設定しておきます。

$ ng g component todo/containers/todo --module=todo/todo.module.ts --export --changeDetection=OnPush

▼TODO用のroutingを編集します。

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

import { TodoComponent } from './containers/todo/todo.component';

const routes: Routes = [
  {
    path: '',
    component: TodoComponent
  }
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule]
})
export class TodoRoutingModule { }

▼app.component.html修正し、作成したTODO処理用のコンポーネントを呼び出すようにします。

<header>
  <h2>ngrx-v8-tutorial</h2>
</header>
<main>
  <router-outlet></router-outlet>
</main>
<footer></footer>

▼一旦ここまででWebアプリを立ち上げてみます。

$ ng serve -o

ブラウザが起動し、下記画面が表示されれば成功です。

TODOアプリケーションを実装していく

構成する要素の雛形ファイルを作成

  • コンポーネントの雛形を作成
$ ng g component todo/components/todo-list --module=todo/todo.module.ts --changeDetection=OnPush
$ ng g component todo/components/todo-list-item --module=todo/todo.module.ts --changeDetection=OnPush
$ ng g component todo/components/todo-form --module=todo/todo.module.ts --changeDetection=OnPush
  • TODOのモデル定義用のファイルを作成
$ ng g interface todo/models/todo model

TODOの構造は下記としています。

export interface Todo {
  id: string;
  text: string;
  checked: boolean;
  createdAt: number;
  updatedAt: number;
}
  • TODOの取得、登録、更新、削除を行うサービスファイルを作成
$ ng g service todo/services/todo

TODOのAPIサーバー

APIサーバはライブラリを使ってシミュレートします。

In-memory Web APIモジュールを利用します。

導入方法はAngularのチュートリアルを参考にします

上記を参考にapp.module.tsにモジュールをインストールし、HTTPリクエストをインターセプトするようにします。im-memory-data.service.tsは下記のようにします。

import { InMemoryDbService } from 'angular-in-memory-web-api';
import { Todo } from './todo/models/todo.model';
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class InMemoryDataService implements InMemoryDbService {
  createDb() {
    const todos: Todo[] = [
      { id: 11, text: 'test1', checked: false, createdAt: 1573279856, updatedAt: 1573279856 },
      { id: 12, text: 'test2', checked: false, createdAt: 1573279856, updatedAt: 1573279856 },
      { id: 13, text: 'test3', checked: false, createdAt: 1573279856, updatedAt: 1573279856 },
      { id: 14, text: 'test4', checked: false, createdAt: 1573279856, updatedAt: 1573279856 },
      { id: 15, text: 'test5', checked: false, createdAt: 1573279856, updatedAt: 1573279856 },
      { id: 16, text: 'test6', checked: false, createdAt: 1573279856, updatedAt: 1573279856 },
    ];
    return { todos };
  }

  // Overrides the genId method to ensure that a hero always has an id.
  // If the heroes array is empty,
  // the method below returns the initial number (11).
  // if the heroes array is not empty, the method below returns the highest
  // hero id + 1.
  genId(todos: Todo[]): number {
    return todos.length > 0 ? Math.max(...todos.map(todo => todo.id)) + 1 : 11;
  }
}

TODOサービスの実装

APIサーバと通信して(今回はローカルのシミュレーター)、TODOの操作を行う処理を実装していきます。

import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';

import { Todo } from '../models/todo.model';
import { tap } from 'rxjs/operators';

@Injectable({
  providedIn: 'root',
})
export class TodoService {
  constructor(private http: HttpClient) {}
  private todosUrl = 'api/todos'; // Web APIのURL

  httpOptions = {
    headers: new HttpHeaders({ 'Content-Type': 'application/json' }),
  };

  loadAll() {
    return this.http
      .get<Todo[]>(this.todosUrl, this.httpOptions)
      .pipe(tap(todos => console.log('load all todo', todos)));
  }

  load(id: number) {
    const url = `${this.todosUrl}/${id}`;
    return this.http.get<Todo>(url, this.httpOptions);
  }

  create(todo: Partial<Todo>) {
    return this.http.post<Todo>(this.todosUrl, todo, this.httpOptions);
  }

  update(todo: Todo) {
    return this.http.put(this.todosUrl, todo, this.httpOptions);
  }

  remove(id: number) {
    const url = `${this.todosUrl}/${id}`;
    return this.http.delete<Todo>(url, this.httpOptions).pipe(tap(_ => console.log(`deleted todo id=${id}`)));
  }
}

コンポーネントの実装

TODOサービスをDIして、コンポーネントから関数が呼び出されたらその処理を実行し、完了したらTODOを再読み込むという流れで実装しています。

  ....
  ngOnInit() {
    this.todos$ = this.todoService.loadAll();
  }
  create(todo: Partial<Todo>) {
    const date = new Date();
    todo.checked = false;
    todo.createdAt = Math.floor(date.getTime() / 1000);
    todo.updatedAt = Math.floor(date.getTime() / 1000);
    this.todoService.create(todo).subscribe(_ => {
      this.todos$ = this.todoService.loadAll();
    });
  }
  update(todo: Todo) {
    this.todoService.update(todo).subscribe(_ => {
      this.todos$ = this.todoService.loadAll();
    });
  }
  remove(id: number) {
    this.todoService.remove(id).subscribe(_ => {
      this.todos$ = this.todoService.loadAll();
    });
  }
  ...
<app-todo-form
  (create)="create($event)"
></app-todo-form>
<app-todo-list>
  <app-todo-list-item
    *ngFor="let todo of todos$ | async"
    [todo]="todo"
    (update)="update($event)"
    (remove)="remove($event)"
  ></app-todo-list-item>
</app-todo-list>

すべてのコンポーネントの処理を記載するとものすごく長くなるので、詳細は、リポジトリを確認してください。

できたら、ローカル実行してみましょう。TODOの登録、更新、削除ができるアプリケーションが動きます。

$ ng serve

サンプルであればこれでも問題ないですが、規模が大きくなることを考えて、続いてはNgRxを使って状態管理していきます。

ngrxインストール、初期設定

@ngrx/storeをアプリに導入し、初期設定を行います。

▼下記ライブラリをインストールします。

  • @ngrx/schematics
    • Angualr CLIでngrxの雛形を作るためのライブラリ
  • @ngrx/store
    • ngrxでStore,Reducer,Actionを使うためのライブラリ
  • @ngrx/store-devtools
    • 強力なデバッカを使えるようにするためのライブラリ
$ npm i -D @ngrx/schematics
$ npm i -s @ngrx/store
$ npm i -s @ngrx/effects
$ npm i -s @ngrx/store-devtools

▼@ngrx/schematicsをデフォルトのSchematicsに追加します。

$ ng config cli.defaultCollection @ngrx/schematics

▼ルートのStoreを作成します。

作成したStoreはアプリケーション全体を通して利用されます。

$ ng g module app-store --module=app.module.ts
$ ng g store state --statePath app-store --root --module app-store/app-store.module.ts

▼environmentのimport文のパスでエラーが出ている場合は修正してください。

...
import { environment } from '../../environments/environment';
...

▼ログ出力の設定とRedux DevTools Extensionを使えるようにします。

import { ActionReducer, ActionReducerMap, createFeatureSelector, createSelector, MetaReducer } from "@ngrx/store";
import { environment } from '../../environments/environment';

export interface State {}

export const reducers: ActionReducerMap<State> = {};

export function logger(reducer: ActionReducer<State>): ActionReducer<State> {
  return (state, action) => {
    const result = reducer(state, action);
    console.groupCollapsed(action.type);
    console.log('action', action);
    console.log('state', result);
    console.groupEnd();
    return result;
  };
}

export const metaReducers: MetaReducer<State>[] = [logger];

TODO処理の値をStoreで管理

ここで作成する資産はTODO処理に閉じたものなので、src/app/todo/store配下に作成します。

▼TODO用のStoreを作成します。

$ ng g module todo/store/todo-store --module=todo/todo.module.ts --flat
$ ng g store todo/todo --statePath store --module store/todo-store.module.ts

▼作成したStoreにReducerを登録します。

$ ng g reducer todo/store/todo --reducers index.ts --creators

▼作成したStoreにActionを登録します。

$ ng g action todo/store/todo --flat --creators

▼Effectを登録します。

$ ng g effect todo/store/todo --module todo/store/todo-store.module.ts --flat --creators

app-module.module.tsEffectsModuleを追加しておきます。

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { StoreModule } from '@ngrx/store';
import { reducers, metaReducers } from './';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { environment } from '../../environments/environment';
import { EffectsModule } from '@ngrx/effects';

@NgModule({
  declarations: [],
  imports: [
    CommonModule,
    StoreModule.forRoot(reducers, {
      metaReducers,
      runtimeChecks: {
        strictStateImmutability: true,
        strictActionImmutability: true,
      }
    }),
    EffectsModule.forRoot([]),
    !environment.production ? StoreDevtoolsModule.instrument() : []
  ]
})
export class AppStoreModule { }

各処理を実装

State

まずは状態と初期状態を定義していきます。

CLIで作成した雛形では、todo.reducer.tsに集約し、store/index.tsにimportされる形になっているので、todo.reducer.tsに状態と初期状態を定義していきます。

TODOの情報に加えて、loadingの状態なども管理していきます。

import { Action, createReducer, on } from '@ngrx/store';
import { Todo } from '../models/todo.model';

export const todoFeatureKey = 'todo';

export interface State {
  loading: boolean;
  todos: Todo[];
  error?: any;
}

export const initialState: State = {
  loading: false,
  todos: []
};

const todoReducer = createReducer(
  initialState,

);

export function reducer(state: State | undefined, action: Action) {
  return todoReducer(state, action);
}

Selectorを実装

状態の一部を切り出すSelectorを実装していきます。今の所、 @ngrx/schematicsにはないので、手動で作成していきます。

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

import { State, todoFeatureKey } from './todo.reducer';

/**
 * Selectors
 */
const getState = createFeatureSelector<State>(todoFeatureKey);

export const getLoading = createSelector(
  getState,
  state => state.loading
);

export const getTodos = createSelector(
  getState,
  state => state.todos
);

@ngrx/storecreateFeatureSelectorを使ってFeature stateを取得した後、createSelectorを使ってSelectorを作成します。

TODOとloadingの状態をストリームで取得できるようになります。

Actionの実装

createActionを使って定義していきます。第1引数にはActionの名前(名前の付け方はGood Action Hygieneがとても参考になります)

第2引数にはActionのペイロードを指定します。@ngrx/storeが提供するpropsを使って記載していきます。

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

export const loadAll = createAction('[Todo Page] Load All');

export const loadAllSuccess = createAction('[Todo API] Load All Success', props<{ todos: Todo[] }>());

export const loadAllFailure = createAction('[Todo API] Load All Failure', props<{ error: any }>());

export const load = createAction('[Todo Page] Load', props<{ id: number }>());

export const loadSuccess = createAction('[Todo API] Load Success', props<{ todo: Todo }>());

export const loadFailure = createAction('[Todo API] Load Failure', props<{ error: any }>());

export const create = createAction('[Todo Page] Create', props<{ todo: Partial<Todo> }>());

export const createSuccess = createAction('[Todo API] Create Success', props<{ todo: Todo }>());

export const createFailure = createAction('[Todo API] Create Failure', props<{ error: any }>());

export const update = createAction('[Todo Page] Update', props<{ todo: Todo }>());

export const updateSuccess = createAction('[Todo API] Update Success');

export const updateFailure = createAction('[Todo API] Update Failure', props<{ error: any }>());

export const remove = createAction('[Todo Page] Remove', props<{ id: number }>());

export const removeSuccess = createAction('[Todo API] Remove Success', props<{ id: number }>());

export const removeFailure = createAction('[Todo API] Remove Failure', props<{ error: any }>());

どういう状態があるか定義することによって、devtoolでのデバッグがやりやすくなります。

Effectsを実装

Actionを受け取り、TODOサービスを実行したり副作用を処理するEffectsを実装していきます。

...
export class TodoEffects {
  constructor(private actions$: Actions, private todoService: TodoService) {}

  loadAll$ = createEffect(() =>
    this.actions$.pipe(
      ofType(TodoActions.loadAll),
      switchMap(() =>
        this.todoService.loadAll().pipe(
          map(result => TodoActions.loadAllSuccess({ todos: result })),
          catchError(error => of(TodoActions.loadAllFailure({ error })))
        )
      )
    )
  );

  load$ = createEffect(() =>
    this.actions$.pipe(
      ofType(TodoActions.load),
      concatMap(({ id }) =>
        this.todoService.load(id).pipe(
          map(result => TodoActions.loadSuccess({ todo: result })),
          catchError(error => of(TodoActions.loadFailure({ error })))
        )
      )
    )
  );

  create$ = createEffect(() =>
    this.actions$.pipe(
      ofType(TodoActions.create),
      concatMap(({ todo }) =>
        this.todoService.create(todo).pipe(
          map(result => TodoActions.createSuccess({ todo: result })),
          catchError(error => of(TodoActions.createFailure({ error })))
        )
      )
    )
  );

  update$ = createEffect(() =>
    this.actions$.pipe(
      ofType(TodoActions.update),
      concatMap(({ todo }) =>
        this.todoService.update(todo).pipe(
          map(_ => TodoActions.updateSuccess()),
          catchError(error => of(TodoActions.updateFailure({ error })))
        )
      )
    )
  );

  remove$ = createEffect(() =>
    this.actions$.pipe(
      ofType(TodoActions.remove),
      concatMap(({ id }) =>
        this.todoService.remove(id).pipe(
          map(() => TodoActions.removeSuccess({ id })),
          catchError(error => of(TodoActions.removeFailure({ error })))
        )
      )
    )
  );
}

...

concatMapはストリームに入った順番を守ってくれます。前のアクションが終わるまでは次のアクションを待って欲しいときに利用します。

switchMapは前の非同期処理が解決する前に、次の処理が流れてくると前のものはキャンセルされます。TODOリストを読み込み中に追加、削除、更新などがあったら、読み込みキャンセルして一覧表示を最適化します。

参考サイト

Reducerを実装

@ngrx/storeのcreateReducerを使ってReducerを作成します。

Reducerには各Actionに対して状態がどのように遷移するかをonの中に記載していきます。

...
const todoReducer = createReducer(
  initialState,
  on(TodoActions.loadAll, state => ({ ...state, loading: true })),
  on(TodoActions.loadAllSuccess, (state, { todos }) => ({
    ...state,
    loading: false,
    todos: [...state.todos, ...todos],
  })),
  on(TodoActions.loadAllFailure, (state, { error }) => ({ ...state, loading: false, error })),
  on(TodoActions.load, (state, { id }) => ({ ...state, loading: true, selectedId: id })),
  on(TodoActions.loadSuccess, (state, { todo }) => {
    const todos = state.todos.some(t => t.id === todo.id)
      ? state.todos.map(t => (t.id === todo.id ? todo : t))
      : [...state.todos, todo];
    return { ...state, loading: false, todos };
  }),
  on(TodoActions.loadFailure, (state, { error }) => ({ ...state, loading: false, error })),
  on(TodoActions.create, state => ({ ...state, loading: true })),
  on(TodoActions.createSuccess, (state, { todo }) => {
    const todos = [...state.todos, todo];
    return { ...state, loading: false, todos };
  }),
  on(TodoActions.createFailure, (state, { error }) => ({ ...state, loading: false, error })),
  on(TodoActions.update, state => ({ ...state, loading: true })),
  on(TodoActions.updateSuccess, state => ({ ...state, loading: false })),
  on(TodoActions.updateFailure, (state, { error }) => ({ ...state, loading: false, error })),
  on(TodoActions.remove, state => ({ ...state, loading: true })),
  on(TodoActions.removeSuccess, (state, { id }) => {
    const todos = state.todos.filter(todo => todo.id !== id);
    return { ...state, loading: false, todos };
  }),
  on(TodoActions.removeFailure, (state, { error }) => ({ ...state, loading: false, error }))
);

export function reducer(state: State | undefined, action: Action) {
  return todoReducer(state, action);
}
...

todo-store.module.tsの設定を修正しておきます。

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { StoreModule } from '@ngrx/store';
import * as fromTodo from './';
import { EffectsModule } from '@ngrx/effects';
import { TodoEffects } from './todo.effects';
import { reducer } from './todo.reducer';

@NgModule({
  declarations: [],
  imports: [
    CommonModule,
    StoreModule.forFeature(fromTodo.todoFeatureKey, reducer),
    EffectsModule.forFeature([TodoEffects])
  ]
})
export class TodoStoreModule { }

Facadeを実装

コンポーネントからstoreを呼び出すときに抽象化層(Facade)を挟むことによって依存度をさげます。

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

import { Todo } from '../models/todo.model';
import { State } from './todo.reducer';
import * as TodoSelectors from './todo.selector';
import * as TodoActions from './todo.actions';
import { TodoStoreModule } from './todo-store.module';

@Injectable({
  providedIn: TodoStoreModule, // 'root' でもOK
})
export class TodoFacade {
  loading$ = this.store.pipe(select(TodoSelectors.getLoading));
  todos$ = this.store.pipe(select(TodoSelectors.getTodos));

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

  loadAll() {
    this.store.dispatch(TodoActions.loadAll());
  }

  load(id: number) {
    this.store.dispatch(TodoActions.load({ id }));
  }

  create(todo: Partial<Todo>) {
    this.store.dispatch(TodoActions.create({ todo }));
  }

  update(todo: Todo) {
    this.store.dispatch(TodoActions.update({ todo }));
  }

  remove(id: number) {
    this.store.dispatch(TodoActions.remove({ id }));
  }
}

これでStore周りの実装は終わりです。お疲れ様でした。

コンポーネントからStoreを使う

作成したTodoFacadeをDIし、StoreからTODOの情報とloadingの状態をsubscribeしましょう。

import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core';

import { Todo } from '../../models/todo.model';
import { TodoFacade } from '../../store/todo.facade';
@Component({
  selector: 'app-todo',
  templateUrl: './todo.component.html',
  styleUrls: ['./todo.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TodoComponent implements OnInit {
  loading$ = this.todoService.loading$;
  todos$ = this.todoService.todos$;

  constructor(private todoService: TodoFacade) {}
  ngOnInit() {
    this.todoService.loadAll();
  }
  create(todo: Partial<Todo>) {
    const date = new Date();
    todo.checked = false;
    todo.createdAt = Math.floor(date.getTime() / 1000);
    todo.updatedAt = Math.floor(date.getTime() / 1000);
    this.todoService.create(todo);
  }
  update(todo: Todo) {
    this.todoService.update(todo);
  }
  remove(id: number) {
    this.todoService.remove(id);
  }
}
<app-todo-form [loading]="loading$ | async" (create)="create($event)"></app-todo-form>
<app-todo-list>
  <app-todo-list-item
    *ngFor="let todo of todos$ | async"
    [todo]="todo"
    [loading]="loading$ | async"
    (update)="update($event)"
    (remove)="remove($event)"
  ></app-todo-list-item>
</app-todo-list>

Redux DevTools Extensionを使って状態の変化を確認

Reduxベースで状態を管理しているので、Redux DevTools Extensionを使うことができます。

TODOを操作した状態を裏で記録してくれているので、下記のGifのように、状態がどのように変化していったか確認することができます。

さいごに

NgRx v8を使ったアプリケーションを作ってみました。

学習コストが多少はかかりますが、規模が大きくなりそうアプリケーションに導入しておくと後々、大きな恩恵があると思います。

ただRxJSでも状態管理できるので、必要に応じて利用していくと良さそうです。

武器は色々持っておくことに損はないので、NgRx是非試してみてください。

また、今回作ったソースはこちらにおいてます。

誰かの参考になれば幸いです。

参考サイト

https://qiita.com/kouMatsumoto/items/c8297466c1824953632f

https://qiita.com/puku0x/items/0a8e7224761dc549bd06

https://takumon.github.io/gatsby-starter-qiita/41febdc6-bf5d-50b8-a695-3c017b8f766a/

https://qiita.com/Yamamoto0525/items/6c31cd6b45d23e54637b

http://tercel-s.hatenablog.jp/entry/2018/08/10/214951