福山です。「ngrxでcounterアプリを作る」で ngrx に触れることができたと思います。今回はさらに踏み込んでTodoアプリを作ってみましょう。動作している様子は↓のような感じです。

todo

コード量が多くなってきたので今回からひとつずつ解説していくのはやめて、ポイントだけ抑えていこうかと思います。

構成

自前で作ったコンポーネントの構成としては以下の図のようになります。
structure

単方向のデータフロー、すっきりしてていいですよねー。ここでポイントとなるのは、色のついてるコンポーネント AddTodoComponent, VisibleTodoListComponent, FilterLinkComponent と、それ以外のコンポーネントです。色の付いているコンポーネントが、データを store から引っ張ってきたり、 storeaction を送りこんだりする 賢いコンテナコンポーネント(Smart Container Component) となります。それに対して、与えられたものだけ表示するし、何かしらイベントがおきたら「何か起きたよ!」ってだけ教えてくれるだけの おバカなコンポーネント(Dumb Component) がいます。この2種類のコンポーネントは重要なのでしっかり覚えておいてください。

新規Todo追加の流れ

新規Todo追加はシンプルな流れになります。ポイントというほどのものもありませんが、 Form としては ReactiveFormsModuleimport して使っています。今のコトロ ngrx との親和性が高い Form のモジュールです。ただ、イケてない部分も結構あるので、おそらく近々 @ngrx/form なるものも登場するかと思います。テンプレートに関しても、 Enter 押されたタイミングか、ボタンを押下したタイミングで onSubmit(todo) がよばれます。

<md-card>
  <form [formGroup]="form" (submit)="onSubmit(form.value)">
    <md-input-container>
      <input mdInput placeholder="Enter Todo" [formControl]="form.controls['text']">
    </md-input-container>
    <button md-raised-button type="submit">Add Todo</button>
  </form>
</md-card>

そして、 onSubmit のタイミングで store に対して AddTodoActiondispatch されます。

// src/app/containers/add-todo.component.ts
...
  onSubmit(todo: Todo) {
    this.store.dispatch(new AddTodoAction(todo));
    this.form.reset();
  }
...

AddTodoAction は↓のような class となっています。

...
export const ADD_TODO = 'ADD_TODO';
...
let nextTodoId = 0;
export class AddTodoAction implements Action {
    readonly type = ADD_TODO;
    constructor(public payload: Todo) {
        payload.id = nextTodoId++;
    }
}
...

実際のアプリで、自分で Todoid を設定することはないでしょうけど、このサンプルでは nextTodoId として保持しておいて、単純にインクリメント( nextTodoId++ )した値を入れています。

dispatch された actionreducerに渡ってきます。

...
const todoReducer = (state: Todo, action: todo.Actions) => {
    switch (action.type) {
        case todo.ADD_TODO:
            const { id, text } = action.payload;
            return {
                id: id,
                text: text,
                completed: false
            };
...
    }
};

export function reducer(state: Todo[] = [], action: todo.Actions) {
    switch (action.type) {
        case todo.ADD_TODO:
            return [
                ...state,
                todoReducer(undefined, action)
            ];
...
    }
}

ここで reducer関数 は親で、 todoReducer関数 は便利関数(細かく分解したもの)です。要は、新しく渡ってきた Todo を、既存の Todo群 に追加しているんです。そして store の中の todos は更新され、再びそれh view に渡っていきます。

フィルタ設定の流れ

フィルタが満たしていないといけない機能は

func

  • 今選択中のフィルタがわかること
  • 選択済みのフィルタは再度選択できないこと
  • クリックされたフィルタを visibilityFilter として store に保存しておくこと

になります。

フィルタはボタン All, Active, Completed ですが、機能としてはすべて同じです。違っているのは担当するフィルタ(All, Active, Completed)だけです。
ちょっとここで個人的に変わっていると思うのは一番てっぺんに おバカなコンポーネント である FooterComponent の中に、 賢いコンテナコンポーネント がいることです。個人的にはいつもコンテナコンポーネントをてっぺんにしているのですが、ここは redux 本家の example に沿うことにしました。

FooterComponent は↓のようになっています。

<md-card>
  <app-filter-link [filter]="showAll">All</app-filter-link>
  <app-filter-link [filter]="showActive">Active</app-filter-link>
  <app-filter-link [filter]="showCompleted">Completed</app-filter-link>
</md-card>

FilterLinkComponent を使いまわして [filter] に、担当させるフィルタを渡しています。ここには直接 filter="SHOW_ALL" と書いてしまってもいいのですが、 typo したくないのと TypeScript の恩恵を受けたいので↓のように変数につめこんだものをテンプレートに埋め込んでいます。

// src/app/component/footer.component.ts
...
import { SHOW_ALL, SHOW_ACTIVE, SHOW_COMPLETED } from 'app/shared/visibility-filter.const';
...
  showAll = SHOW_ALL;
  showActive = SHOW_ACTIVE;
  showCompleted = SHOW_COMPLETED;
...

では、 FilterLinkComponent を見ていきましょう。これは 賢いコンテナコンポーネント になります。 store の中にある、 現在のフィルタ を取得して、それが 自分のフィルタと一致するかどうか を知る必要があるので↓のように Observable を構築して自分がアクティブかどうかを active$ に宣言しています。

    this.active$ = this.store.select(fromRoot.getVisibilityFilter)
      .map(f => f === this.filter);

ここで新しく登場しているのは fromRoot.getVisibilityFilter の部分です。 store.select には (state: T) => R な関数を突っ込むことができます。これは何なのかというと、てっぺんの state から、自分の興味のある場所だけを切りとってくる selector関数 になります。 getVisibilityFilter の定義をみると↓のようになっています。

export interface State {
    todos: Todo[];
    visibilityFilter: VisibilityFilter;
}
...
export const getVisibilityFilter = (state: State) => state.visibilityFilter;

第一引数にてっぺんの state が渡ってくるので、その中の visibilityFilter を抽出しているのがわかると思います。なので、 store.select(fromRoot.getVisibilityFilter) は、現状の visibilityFilter が流れてくる Observable なのです。そして、これが自分の担当するフィルタ(All, Active, Completed)と一致しているかを監視したいので map(f => f === this.filter) となっています。

view としてはあとはこの active$ の内容を LinkComponent に渡すことと、 LinkComponent がクリックされたときにフィルタを更新することです。よって、↓のような定義をしておきます。

<app-link [active]="active$ | async" (linkClick)="onLinkClick()">

LinkComponent は単純です。

<button md-raised-button [disabled]="active" (click)="onClick($event)">
  <ng-content></ng-content>
</button>

親から渡ってきた activetrue かどうかで、 buttondisabled にするかどうかを判断しています。そして click された場合は onClick が発火して↓が実行されます。

// src/app/components/link
...
  @Output() linkClick = new EventEmitter();
...
  onClick(event: Event) {
    event.preventDefault();
    this.linkClick.emit();
  }
...

linkClick が発火することで、親である FilterLinkComponentonLinkClick() が発火します。

// src/app/containers/filter-link/filter-link.component.ts
...
  onLinkClick() {
    this.store.dispatch(new SetVisibilityFilterAction(this.filter));
  }
...

ここで SetVisibilityFilterActionstoredispatch されます。これを担当する reducer はいたってシンプルです。 SET_VISIBILITY_FILTER であれば、そのフィルタを新たなフィルタとして state に反映するだけです。

// src/app/reducers/visibility-filter.reducer.ts
export function reducer(state = SHOW_ALL, action: todo.Actions) {
    switch (action.type) {
        case todo.SET_VISIBILITY_FILTER:
            return action.payload;
        default:
            return state;
    }
}

これでフィルタ更新の流れは完了です。

Todo表示の流れ

それでは最後に Todo を表示する流れを見ていきましょう。まずは 賢いコンテナコンポーネントVisibleTodoListComponent から見ていきます。このコンポーネントは表示する Todo群store からとりたいので、↓でとってきています。

// src/app/containers/visible-todo-list/visible-todo-list.component.ts
...
  ngOnInit() {
    this.todos$ = this.store.select(fromRoot.getVisibleTodos);
  }
...

コンポーネント側はシンプルですね!結局は store から情報を切り取ってくるんです。ただし、 fromRoot.getVisibleTodos はちょっと工夫がされているのでその実装をみてみましょう。

// src/app/reducers/index.ts

...

/** 全Todoを取得 */
export const getTodos = (state: State) => state.todos;
/** 現在のフィルタを取得 */
export const getVisibilityFilter = (state: State) => state.visibilityFilter;

/** 表示可能な全Todoを取得 */
export const getVisibleTodos = createSelector(getTodos, getVisibilityFilter, (todos, filter) => {
    switch (filter) {
        case SHOW_ALL:
            // 全表示
            return todos;
        case SHOW_COMPLETED:
            // Completeだけ表示
            return todos.filter(t => t.completed);
        case SHOW_ACTIVE:
            // Activeだけ表示
            return todos.filter(t => !t.completed);
        default:
            throw new Error('Unknown filter: ' + filter);
    }
});

ここで新しく登場しているのが createSelector の部分です。これは reselect という selector 用のライブラリの関数です。 selector を組み合わせて使ったり、同じ計算を何度も行わない memoize な機能も備えているシンプルだけど優れもののライブラリです。

getVisibleTodos がやりたいことは、今表示してもOKな Todo を判断することなので、 todosvisibilityFilter を組み合わせればOKですよね。この場合の createSelector の型は↓のようになっています。

export function createSelector<S, R1, R2, T>(
  selector1: Selector<S, R1>,
  selector2: Selector<S, R2>,
  combiner: (res1: R1, res2: R2) => T,
)

selector1R1 を返す関数、 selector2 は R2 を返す関数、そして combinerR1, と R2 が渡ってきて、結果 T を返す関数となっています。これを getVisibleTodos にあてはめると

  • R1 -> todos
  • R2 -> visibilityFilter
  • T -> todosvisibilityFilter を組み合わせて算出された todos

となります。この関数を store.select(fromRoot.getVisibleTodos) として使うことで、 今表示してOKなtodos を取得できます。そしてこの取得してきた todosTodoListComponent に渡し、さらには個々の TodoComponent へと渡っていきます。

あとは、 Todo がクリックされたときに ToggleTodoActionstore に渡っていくように onTodoClick が実装されています。

// src/app/containers/visible-todo-list/visible-todo-list.component.ts

...

   * ToggleTodoAction発火(Todoの状態反転)
   * @param id 反転させるTodoのid
   */
  onTodoClick(id: number) {
    this.store.dispatch(new ToggleTodoAction(id));
  }
...

おわりに

以上でおおまかな流れと解説になりますソースは こちら に公開しています。

© 2018. SuperSoftware Co., Ltd. All Rights Reserved.