TL;DR
タイトルまんまですが、TypeScript と RxJS だけで、type safe で immutable な Flux パターンが実現できるよ、って話です。Flux Store の実装はコメント入れても 40 行未満です。
PoC 的に作ってみましたが、Angular が適合するユースケースでは、あまり必要な場面はありません。
- 2018-10-06 この古い記事に「いいね」もらったので、現時点のオススメフレームワークを追記
- 2017-06-15 末尾にクライアント状態管理が必要になる場面についての考察を追記
動機
- flux の考え方はいいけど、type safe で immutable, rxjs ベースの素朴な Store が欲しかった
- redux の switch 文は何が嬉しいの?
- immutable.js も無駄に大きいし...
Akita (2018-10-06 追記)
本記事と問題意識を同じくする Akita という、高機能かつ使いやすい API を持つ状態管理フレームワークをオススメします。
簡単に特徴を紹介すると:
- Store, Query, Service の役割が明確に分離されている
- State を表現する Model は Form と相互変換可能
- Redux Devtools 対応
- CLI が用意されている
- plugable な State の永続化層
Akita の考え方や様々な利用例は blog でわかりやすく紹介されているので、リファレンスを主とする公式ドキュメント よりも blog から読む方が理解しやすいと思います。
アイデアの元
- ANGULAR MODEL PATTERN が考え方のベースにあります。
- Action 自体を関数にしちゃうのは Walts を参考にさせてもらいました。
- immutable にする方法は Immutable.js Records in TypeScript のコメント「spread でいいやん」がきっかけで、TypeScript 2.1.4 以降の spread と Readonly, Partial をそのまま使うだけでいいと気付きました。
Flux Store の実装
これだけです。
-
BehaviorSubject
で state を管理しています。 - 公開する
Observable
からはReadonly<T>
が流れてくるので immutable が保証されます -
Action
は 現 state (Readonly<T>
) から次の state (Partial<T>
) を返す関数です。返り値はPartial
なので、変更対象のプロパティだけを返せばいいようにしています。 -
dispatch
メソッドがAction
を適用してSubject
に新しい state を流します。dispatch
内では、update
がspread
を利用して、現 state とAction
の結果のPartial
から新しい state を合成しています。
import {BehaviorSubject} from 'rxjs/BehaviorSubject';
import {Observable} from 'rxjs/Observable';
export type Action<T> = (current: Readonly<T>) => Partial<T>;
export class Store<T> {
private _data: BehaviorSubject<Readonly<T>>;
public data$: Observable<Readonly<T>>;
// サブクラスでは injectable にするため
// override して引数なしのコンストラクタにしましょう
constructor(initialData: T) {
this._data = new BehaviorSubject(initialData as Readonly<T>);
this.data$ = this._data.asObservable();
}
protected current(): Readonly<T> {
return this._data.getValue();
}
protected next(data: Readonly<T>): void {
this._data.next(data);
}
// state を更新する
//
// current をそのまま渡すと怒られる
// "spread types may only be created from object types"
// TypeScript 2.3 の不具合っぽい
// https://github.com/Microsoft/TypeScript/pull/13288
protected update(current: Readonly<T>, diff: Partial<T>): Readonly<T> {
return {...current as object, ...diff as object} as Readonly<T>;
}
public dispatch(action: Action<T>): void {
const current = this.current();
const diff = action(current);
const result = this.update(current, diff);
this.next(result);
}
}
Store の実装例
- テストしやすいよう Action は pure function として分離しました。
- ただ、Action ごとに dispatch 呼びだす component 公開用のメソッドを作るのがちょっと手間です。コンポーネントで毎回
this.store.dispatch(this.store.action(params...)
とするよりもマシですが… - 非同期処理は
Observable
でもPromise
でも最終的にdispatch
を呼べばいいだけなので、特に仕組みは用意していません。同じようなコードが増えそうなら、dispatchAsync
みたいなのをあらためて考えます。
import {Injectable} from '@angular/core';
import {Store} from '../../lib/store';
export interface Counter {
name: string;
value: number;
}
@Injectable()
export class CounterStore extends Store<Counter> {
constructor() {
super({name: 'hello counter', value: 0});
}
// pure function for test
_increment(current: Readonly<Counter>): Partial<Counter> {
return {value: current.value + 1};
}
// pure function for test
_decrement(current: Readonly<Counter>): Partial<Counter> {
return {value: current.value - 1};
}
// public interface for component with side effect
increment(): void {
this.dispatch(this._increment);
}
// public interface for component with side effect
decrement(): void {
this.dispatch(this._decrement);
}
}
コンポーネントの実装例
Angular 4.0 で便利になった ngIf のおかげで Observable
も async
パイプだけで、テンプレートを簡潔に書けるようになりました。なので、store の Observable
をそのままテンプレートに渡しています。
コンポーネントは状態を持たないので、ChangeDetectionStorategy
には OnPush
を安心して利用できます。わざわざ、ChangeDetectorRef#markForCheck
で変更を通知しなくていいわけです。
また、状態を持たず、Observable
をそのままテンプレートに渡すのは、コンポーネント内で subscribe
しない、ということでもあります。結果 Subscription
の管理も不要になります。ライフサイクルコールバックで頑張って unsubscribe
する 必要もありません。
app.component.ts
import {ChangeDetectionStrategy, Component} from '@angular/core';
import {Observable} from "rxjs/Observable";
import {Counter, CounterStore} from "./services/CounterStore";
@Component({
selector: '[app-root]',
templateUrl: './app.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {
constructor(private store: CounterStore) {}
get counter$(): Observable<Readonly<Counter>> {
return this.store.data$;
}
increment(): void {
this.store.increment();
}
decrement(): void {
this.store.decrement();
}
}
app.component.html
<h3>store example</h3>
<div *ngIf="counter$ | async as counter">
name: "{{ counter.name}}", value: {{counter.value}}
<br>
<button (click)="increment()">increment</button>
<button (click)="decrement()">decrement</button>
</div>
そもそもクライアントサイドの状態管理って必要? (2017-06-15 追記)
ng-kyoto #6 で、この記事について飛び込み発表させていただいたのですが、そこでもらった質問です。
画面遷移型アプリでは不要
Angular の強みの一つに Form があり、他のフレームワークと比較して業務アプリに向いている、と言えるでしょう。SPA ながら、旧来の Struts や Rails アプリのような画面遷移を行うようなウェブアプリです。
URL で明確に分割された画面があり、かつ、扱うデータが Todo サンプルのような個人的でシンプルなものでなく多数の項目から構成されるデータで、データベースで最新状況が共有されるようなユースケースです。まさに一般的な業務アプリが適合すると思いませんか?
このようなユースケースでは、画面ごとに必要な情報は閉じていて、画面をまたがったグローバルな情報はほぼ存在しないし、DB で共有された常に更新され続けている最新のデータが見れなければ、何の役にも立ちません。
言い替えれば、クライアントサイドでの状態管理は不要、ということになります。
実装自体は以下のような流れになります。
- Router の resolve 等で画面遷移時に最新データをサーバーから取得
- 入力情報は Form が管理
- 編集後に「保存」等の明確なアクションでサーバーに反映
クライアントサイドで管理すべきデータは Angular の Form がよしなにやってくれるので、データを取得して送信する薄い Service があれば十分です。
画面遷移しないアプリでは重要
逆の観点から言えば、上記の前提があてはまらない場合に、クライアントサイドでの状態管理が必要になります。
具体的には Facebook や Slack のように画面遷移がないアプリで、GUI MVC (!= Web MVC2) が想定していた Model が様々な View に反映されるようなユースケースです。Facebook を例にすれば、ヘッダ未読数やウォール、チャットウィンドウなど、様々な箇所で今の「状態」を適切に管理しないといけないわけです。
その必要性から Flux アーキテクチャが Facebook から生まれたわけですね。
結論
ということで、結論としては、ユースケース依存という当たり障りのない回答になってしまいます。
しかし、Angular の提供する Form や Router の強みが発揮される画面遷移型アプリでは、http 通信を wrap する薄い Service があれば十分で、状態管理は不要と言っていいと思います。