福山です。「ngrxでcounterアプリを作る」で ngrx
に触れることができたと思います。今回はさらに踏み込んでTodoアプリを作ってみましょう。動作している様子は↓のような感じです。
コード量が多くなってきたので今回からひとつずつ解説していくのはやめて、ポイントだけ抑えていこうかと思います。
構成
自前で作ったコンポーネントの構成としては以下の図のようになります。
単方向のデータフロー、すっきりしてていいですよねー。ここでポイントとなるのは、色のついてるコンポーネント AddTodoComponent
, VisibleTodoListComponent
, FilterLinkComponent
と、それ以外のコンポーネントです。色の付いているコンポーネントが、データを store
から引っ張ってきたり、 store
に action
を送りこんだりする 賢いコンテナコンポーネント(Smart Container Component) となります。それに対して、与えられたものだけ表示するし、何かしらイベントがおきたら「何か起きたよ!」ってだけ教えてくれるだけの おバカなコンポーネント(Dumb Component) がいます。この2種類のコンポーネントは重要なのでしっかり覚えておいてください。
新規Todo追加の流れ
新規Todo追加はシンプルな流れになります。ポイントというほどのものもありませんが、 Form
としては ReactiveFormsModule
を import
して使っています。今のコトロ 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
に対して AddTodoAction
が dispatch
されます。
// 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++;
}
}
...
実際のアプリで、自分で Todo
の id
を設定することはないでしょうけど、このサンプルでは nextTodoId
として保持しておいて、単純にインクリメント( nextTodoId++
)した値を入れています。
dispatch
された action
は reducer
に渡ってきます。
...
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
に渡っていきます。
フィルタ設定の流れ
フィルタが満たしていないといけない機能は
- 今選択中のフィルタがわかること
- 選択済みのフィルタは再度選択できないこと
- クリックされたフィルタを
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>
親から渡ってきた active
が true
かどうかで、 button
を disabled
にするかどうかを判断しています。そして click
された場合は onClick
が発火して↓が実行されます。
// src/app/components/link
...
@Output() linkClick = new EventEmitter();
...
onClick(event: Event) {
event.preventDefault();
this.linkClick.emit();
}
...
linkClick
が発火することで、親である FilterLinkComponent
の onLinkClick()
が発火します。
// src/app/containers/filter-link/filter-link.component.ts
...
onLinkClick() {
this.store.dispatch(new SetVisibilityFilterAction(this.filter));
}
...
ここで SetVisibilityFilterAction
が store
に dispatch
されます。これを担当する 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
を判断することなので、 todos
と visibilityFilter
を組み合わせればOKですよね。この場合の createSelector
の型は↓のようになっています。
export function createSelector<S, R1, R2, T>(
selector1: Selector<S, R1>,
selector2: Selector<S, R2>,
combiner: (res1: R1, res2: R2) => T,
)
selector1
は R1
を返す関数、 selector2
は R2
を返す関数、そして combiner
は R1
, と R2
が渡ってきて、結果 T
を返す関数となっています。これを getVisibleTodos
にあてはめると
R1
->todos
R2
->visibilityFilter
T
->todos
とvisibilityFilter
を組み合わせて算出されたtodos
となります。この関数を store.select(fromRoot.getVisibleTodos)
として使うことで、 今表示してOKなtodos を取得できます。そしてこの取得してきた todos
を TodoListComponent
に渡し、さらには個々の TodoComponent
へと渡っていきます。
あとは、 Todo
がクリックされたときに ToggleTodoAction
が store
に渡っていくように 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));
}
...
おわりに
以上でおおまかな流れと解説になりますソースは こちら に公開しています。