171
118

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

@ngrx/storeと@ngrx/effectsの使い方

Last updated at Posted at 2017-09-13

:tada: NgRx v9に対応しました :tada:

NgRxAngularアプリケーション用の状態管理ライブラリです。
image.png
本記事では、簡単なサンプルを通して@ngrx/store@ngrx/effectsの使い方を紹介します。
※ サンプルはNgRx v9系で書かれています

この記事の対象者

  • Angularを利用している方
  • Angular + RxJSで状態管理をしたい方
  • NgRxを触ってみたい方

TL;DR

NgRxはいいぞ。
https://stackblitz.com/edit/ngrx-todo-v9

NgRxの概要

NgRxはAngular用の状態管理ライブラリです。NgRxを利用するとAngularを利用したアプリケーションでReduxのような状態管理ができます。

NgRxは下図のような構成になっており、Reduxと非常によく似ています。

https://ngrx.io/guide/store より

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

@ngrx/store

Angular用の状態管理ライブラリです。
ReduxのようにAction、Reducer、Storeの概念があります。

@ngrx/storeのコンセプトは以下のように紹介されています。

  • Actionはコンポーネントやサービスからディスパッチされる一意のイベントを表す
  • 状態はReducer(現在の状態と最新のActionから新しい状態を返す純粋関数)によって更新される
  • Selectorは状態の一部を取り出したり組み合わせたりする純粋関数である
  • 状態はStateとActionのObservableであるStoreからアクセスされる

https://ngrx.io/guide/store より翻訳

@ngrx/effects

受け取ったActionを元にHTTP通信や別のActionを実行するなどの「副作用」を処理するライブラリ(redux-sagaやredux-thunkのようなもの)です。

@ngrx/effectsのコンセプトは以下のように紹介されています。

  • Effectsはコンポーネントから副作用を分離し、コンポーネントをより綺麗にする
  • EffectsはStoreにディスパッチされる全てのActionを監視し長期的に実行されるサービスである
  • EffectsはofTypeオペレータによって関心のあるActionを絞り込む
  • Effectsは新しいActionを返す同期または非同期なタスクを処理する

https://ngrx.io/guide/effects より翻訳

NgRxを使ってみよう

さっそくNgRxを使ってみましょう。
今回は簡単なTodoアプリに組み込むことを想定します。

インストール

npmからインストールしてください。

$ npm install @ngrx/store
$ npm install @ngrx/effects

ディレクトリ構成

NgRxを用いる場合は、下図のようにフィーチャ毎にstoreディレクトリを用意し必要なファイルをまとめていくと良いと思います。
image.png

State

まずは状態と初期状態を定義しましょう。

NgRxでは、

  • Root state
    ルートの状態、アプリケーション全体を通して利用される

  • Feature state
    各機能単位の状態、フィーチャモジュール内で利用される

と分けて扱うことが多いかと思います。

/src/app/store/state/app.state.ts
export interface State {
  // ルートの状態
}
/src/app/todo/store/state/todo.state.ts
export const featureName = 'todo';

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

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

Todoモデルは以下のようなものを想定しています。

/src/app/models/todo.model.ts
export interface Todo {
  id: string;
  text: string;
}

今回の例では、アプリケーション全体の状態は以下のようになります。

アプリケーション全体の状態
{
  todo: {
    loading: false,
    todos: [
      { id: '1', text: 'Learn Angular' },
      { id: '2', text: 'Learn RxJS' },
      { id: '3', text: 'Learn NgRx' },
    ],
    error: {...}
  }
}

Selector

Selectorは状態の一部を切り出すものです。

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

/src/app/todo/store/selectors/todo.selector.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';

import { State, featureName } from '../state';

const getState = createFeatureSelector<State>(featureName);
export const getLoading = createSelector(getState, state => state.loading);
export const getTodos = createSelector(getState, state => state.todos);

createSelectorで作成したSelectorは自動的にメモ化されるため高速にアクセスできます。

Action

@ngrx/storecreateActionを使ってActionを作成します。

第1引数にはActionの名前を指定します。Actionの名前はGood Action Hygieneを参考に[Source] Eventの形式にすると良いと思います。

第2引数にはActionのペイロードを指定します。@ngrx/storeが提供するpropsを使うと簡潔に書けます。

/src/app/todo/store/actions/todo.action.ts
import { createAction, props } from '@ngrx/store';

export const loadAll = createAction(
  '[Todo Page] Load All',
  props<{ offset?: number; limit?: number }>()
);

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

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

ちなみに↓のように書くと、

/src/app/todo/store/actions/todo.action.ts
export const createSuccess = createAction(
  '[Todo API] Load All Success',
  (payload: { todos: Todo[] }) => ({ payload })
);

action.payloadの形でペイロードを取り出すことができます。

Effects

Actionを受け取り、副作用を処理する部分です。

/src/app/todo/store/effects/todo.effect.ts
import { Injectable } from '@angular/core';
import { Actions, ofType, createEffect, act } from '@ngrx/effects';
import { of } from 'rxjs';
import { map, tap, concatMap, switchMap, catchError } from 'rxjs/operators';

import { TodoService } from '../../services';
import * as TodoActions from '../actions';

@Injectable()
export class TodoEffects {
  constructor(
    private actions$: Actions<TodoActions>,
    private todoService: TodoService
  ) {}

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

マッピングオペレータについては、一覧読み込みなどの場合はswitchMap、それ以外はconcatMapを使うのをお勧めします(参考URL)。

TodoServiceは以下のようなものを想定しています。

/src/app/todo/services/todo.service.ts(一部抜粋)
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

import { Todo } from '../../models';

@Injectable({
  providedIn: 'root'
})
export class TodoService {
  constructor(private http: HttpClient) {}

  findAll(offset?: number, limit?: number) {
    const url = 'APIのアクセス先URL';
    let params = new HttpParams();
    params = offset ? params.set('offset', `${offset}`) : params;
    params = limit ? params.set('limit', `${limit}`) : params;
    return this.http.get<Todo[]>(url, { params });
  }
}

新しいアクションを呼ばない場合は{ dispatch: false }を渡しましょう。

/src/app/todo/store/effects/todo.effect.ts
@Injectable()
export class TodoEffects {
  createSuccess$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(TodoActions.loadAllSuccess),
        tap(action => {
          console.log('Success!');
        })
      ),
    { dispatch: false }
  );
}

Reducer

@ngrx/storecreateReducerを使ってReducerを作成します。

Reducerでは各Actionに対して状態がどのように遷移するかをonを使って書いていきます。

/src/app/todo/store/reducers/todo.reducer.ts
import { Action, createReducer, on } from '@ngrx/store';

import { State, initialState } from '../state';
import * as TodoActions from '../actions';

export const reducer = createReducer(
  initialState,
  on(TodoActions.loadAll, state => {
    return { ...state, loading: true };
  }),
  on(TodoActions.loadAllSuccess, (state, { todos }) => {
    return { ...state, loading: false, todos: [...state.todos, ...todos] });
  }),
  on(TodoActions.loadAllFailure, (state, { error }) => {
    return { ...state, loading: false, error };
  }),
);

本記事では解説しませんが、このようなエンティティのCRUD操作を簡略化する@ngrx/entityというパッケージもあります。

Facade

FacadeはNgRxの処理を抽象化します。作成は任意ですが、Facadeのような中間層を設けることでコンポーネントがNgRxに強く依存することを防ぎ、将来的なライブラリの変更にも柔軟に対応できるようになると思います(参考URL)。

/src/app/todo/store/todo.facade.ts
import { Injectable } from '@angular/core';
import { Store, select } from '@ngrx/store';

import { Todo } from '../../models';
import * as TodoSelectors from './selectors';
import * as TodoActions from './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) {}

  loadAll(offset?: number, limit?: number) {
    this.store.dispatch(TodoActions.loadAll({ offset, limit }));
  }
}

モジュールの作成

NgRxではStoreModule.forFeature()を用いてRoot stateにFeature stateを追加できます。

StoreModuleの初期化はAppModule内、またはストア用モジュールで行うと良いでしょう。

/src/app/store/app-store.module.ts
import { NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';

import { reducers, metaReducers } from './reducers';

@NgModule({
  imports: [
    StoreModule.forRoot(reducers, { metaReducers }),
    EffectsModule.forRoot([]),
  ]
})
export class AppStoreModule {}

metaReducersにはログ等の前処理を差し込むことができます。追加は任意です。

デフォルトでは、開発時に状態やActionのイミュータブルチェックが行われますが、runtimeChecksを指定して設定を上書きすることもできます。この他にもActionがシリアライズ可能かどうかのチェックもあるので必要に応じて切り替えると良いでしょう。

StoreModule.forRoot(reducers, {
  metaReducers,
  runtimeChecks: {
    strictStateSerializability: false,  // デフォルト: false
    strictActionSerializability: false, // デフォルト: false
    strictActionImmutability: false,    // デフォルト: true
    strictStateImmutability: false,     // デフォルト: true
    strictActionWithinNgZone: false     // デフォルト: false
  }
})

続いてFeature state用のモジュールを作成し、ReducerとEffectsを登録します。

/src/app/todo/store/todo-store.module.ts
import { NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';

import { featureName } from './state';
import { reducer } from './reducers';
import { TodoEffects } from './effects';

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

作成したTodoStoreModuleTodoModuleにインポートしましょう。

/src/app/todo/todo.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';

import { TodoStoreModule } from './store';
import { TodoComponent } from './todo.component';

@NgModule({
  imports: [
    CommonModule,
    TodoStoreModule
  ],
  exports: [TodoComponent],
  declarations: [TodoComponent]
})
export class TodoModule {}

「状態をNgModuleとして扱える」と理解することがNgRxで状態を設計するコツです👍

AppModuleに登録

最後に、作成したモジュールをAppModuleにインポートしましょう。

app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';

import { AppStoreModule } from './app-store.module';
import { TodoModule } from './todo';
import { AppComponent } from './app.component';

@NgModule({
  imports: [
    BrowserModule,
    HttpClientModule,
    AppStoreModule,  // 必須
    TodoModule,      // ←Lazy Loadingしても良い
  ],
  declarations: [AppComponent],
  bootstrap: [AppComponent],
  providers: []
})
export class AppModule {}

これで状態管理の仕組みが整いました。早速コンポーネント内で使ってみましょう。

コンポーネント内で使う

StoreをコンポーネントにDIしましょう。

/src/app/app.component.ts
import { Component } from '@angular/core';
import { Store } from '@ngrx/store';

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

状態の取得やActionのディスパッチを行います。

/src/app/app.component.ts
import { Component, OnInit } from '@angular/core';
import { Store, select } from '@ngrx/store';

import * as TodoSelectors from './store/todo/selectors';
import * as TodoActions from './store/todo/actions';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: [ './app.component.css' ]
})
export class AppComponent implements OnInit {
  this.todos$ = this.store.pipe(select(TodoSelectors.getTodos));

  constructor(private todoService: TodoFacade) {}

  ngOnInit() {
    const offset = 0;
    const limit = 10;
    this.store.dispatch(TodoActions.loadAll({ offset, limit }));
  }
}

テンプレート内でasyncパイプを使うと状態を簡単に取り出せます。

/src/app/app.component.html
<ul>
  <li *ngFor="let todo of todos$ | async">
    {todo.text}
  </li>
</ul>

Facadeを使う場合

作成したTodoFacadeをDIしましょう。

/src/app/app.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: [ './app.component.css' ]
})
export class AppComponent {
  constructor(private todoService: TodoFacade) {}
}

状態の取得やActionのディスパッチはFacade経由で行います。

/src/app/app.component.ts
import { Component, OnInit } from '@angular/core';

import { TodoFacade } from './store';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: [ './app.component.css' ]
})
export class AppComponent implements OnInit {
  this.todos$ = this.todoService.todos$;

  constructor(private todoService: TodoFacade) {}

  ngOnInit() {
    const offset = 0;
    const limit = 10;
    this.todoService.loadAll(offset, limit);
  }
}

終わりに

NgRxを利用することで、AngularアプリケーションにReduxのような状態管理を組み込むことができます。Angularとの親和性が高いだけでなく、優秀な型推論や豊富な機能、テストユーティリティ、ドキュメントも充実しているので安心して使用できると思います。

「アプリケーションの規模が大きくなって状態管理つらい...:sweat:」となったときにNgRxはきっと役に立つでしょう。

本記事を見て興味を持ったという方はぜひデモで動作確認してみてください。
https://stackblitz.com/edit/ngrx-todo-v9

テストコードを含む今回のサンプルのソースは以下からダウンロードできます。
https://github.com/puku0x/ngrx-todo

NgRxはまだ日本語の情報が少ないので「もっと良い使い方があるぞ!」という場合はコメントしていただければ幸いです。

参考

NgRx document
NgRx example application
Comprehensive Introduction to @ngrx/store
NgRx + Facades: Better State Management

171
118
9

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
171
118

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?