LoginSignup
24

More than 5 years have passed since last update.

素朴な Angular 向けの type safe で immutable な Flux Store

Last updated at Posted at 2017-05-19

TL;DR

タイトルまんまですが、TypeScript と RxJS だけで、type safe で immutable な Flux パターンが実現できるよ、って話です。Flux Store の実装はコメント入れても 40 行未満です。

PoC 的に作ってみましたが、Angular が適合するユースケースでは、あまり必要な場面はありません。

  • 2018-10-06 この古い記事に「いいね」もらったので、現時点のオススメフレームワークを追記
  • 2017-06-15 末尾にクライアント状態管理が必要になる場面についての考察を追記

動機

Akita (2018-10-06 追記)

本記事と問題意識を同じくする Akita という、高機能かつ使いやすい API を持つ状態管理フレームワークをオススメします。

簡単に特徴を紹介すると:

Akita の考え方や様々な利用例は blog でわかりやすく紹介されているので、リファレンスを主とする公式ドキュメント よりも blog から読む方が理解しやすいと思います。

アイデアの元

Flux Store の実装

これだけです。

  • BehaviorSubject で state を管理しています。
  • 公開する Observable からは Readonly<T> が流れてくるので immutable が保証されます
  • Action は 現 state (Readonly<T>) から次の state (Partial<T>) を返す関数です。返り値は Partial なので、変更対象のプロパティだけを返せばいいようにしています。
  • dispatch メソッドが Action を適用して Subject に新しい state を流します。dispatch 内では、updatespread を利用して、現 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 のおかげで Observableasync パイプだけで、テンプレートを簡潔に書けるようになりました。なので、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 で共有された常に更新され続けている最新のデータが見れなければ、何の役にも立ちません。

言い替えれば、クライアントサイドでの状態管理は不要、ということになります。

実装自体は以下のような流れになります。

  1. Router の resolve 等で画面遷移時に最新データをサーバーから取得
  2. 入力情報は Form が管理
  3. 編集後に「保存」等の明確なアクションでサーバーに反映

クライアントサイドで管理すべきデータは Angular の Form がよしなにやってくれるので、データを取得して送信する薄い Service があれば十分です。

画面遷移しないアプリでは重要

逆の観点から言えば、上記の前提があてはまらない場合に、クライアントサイドでの状態管理が必要になります。

具体的には Facebook や Slack のように画面遷移がないアプリで、GUI MVC (!= Web MVC2) が想定していた Model が様々な View に反映されるようなユースケースです。Facebook を例にすれば、ヘッダ未読数やウォール、チャットウィンドウなど、様々な箇所で今の「状態」を適切に管理しないといけないわけです。

その必要性から Flux アーキテクチャが Facebook から生まれたわけですね。

結論

ということで、結論としては、ユースケース依存という当たり障りのない回答になってしまいます。

しかし、Angular の提供する Form や Router の強みが発揮される画面遷移型アプリでは、http 通信を wrap する薄い Service があれば十分で、状態管理は不要と言っていいと思います。

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
24