angular
RxJS

UXの高い「いいね」ボタンをRxJSで実装する

ちきさんです。

榊原氏からヒントをもらってSNSアプリとかでよくある「いいね」ボタンをRxJS(Angular)で実現するコードを書いたので共有します。

概要

ユーザーの操作に素早く応答する画面を作りつつ、サーバーとクライアント間で状態の整合性を保つ。

特徴

  • サーバーから初期状態を受け取るまでは「いいね」ボタンを押しても何もリクエストを飛ばさない。
  • 初期状態を受け取った後はクライアントで状態を管理し、ユーザーの操作に応じてすぐに画面を更新する。
  • 画面の更新をしてからバックグラウンドでサーバーの状態を更新する。(Lazy Sync)

つまりサーバーから初期状態を取得したらあとはクライアントが状態管理の主導権を握るということ。

デモ → https://stackblitz.com/edit/angular-qwfwkm


コード解説

サービス

サーバー側の状態管理をまとめています。本質ではない部分は省略しました。

FakeStateService.ts
export interface FakeState {
  iine: boolean
}

@Injectable()
export class FakeStateService {
  private state: FakeState
  private requester$ = new Subject<boolean>()
  private dispatcher$ = new Subject<void>()
  private provider$ = new ReplaySubject<FakeState>(1)

  constructor() {
    this.state = {
      iine: Math.random() > 0.5
    }

    // サーバーにPOSTリクエストを送る。
    this.requester$
      .concatMap(iine => this.httpPostRequest(iine))
      .subscribe(() => {
        this.dispatcher$.next()
      })

    // dispatcherがnextされる度にサーバーから状態を取得し直す。
    Observable.merge(this.dispatcher$)
      .switchMap(() => this.httpGetRequest())
      .subscribe(state => {
        this.provider$.next(state)
      })

    this.dispatcher$.next()
  }

  private httpGetRequest(): Promise<FakeState> { /* 省略 */ }

  private httpPostRequest(iine: boolean): Promise<void> { /* 省略 */ }

  getState$(): Observable<FakeState> {
    return this.provider$;
  }

  updateIine(iine: boolean): void {
    this.requester$.next(iine)
  }
}

「いいね」の状態を更新する流れは下記のようになります。

コンポーネントからupdateIine()を呼び出されます。

updateIine(iine: boolean): void {
  this.requester$.next(iine)
}

reqester$のストリームがhttpPostRequest()を呼び出すことでサーバーの状態を更新します。

this.requester$
  .concatMap(iine => this.httpPostRequest(iine))
  .subscribe(() => {
    this.dispatcher$.next()
  })

concatMapオペレーターによりアクションの順番が担保されているので「いいね」ボタンを連打されても状態の矛盾が生じないようになっています。(参照: RxJSのconcatMap, mergeMap, switchMapの違いを理解する)

dispatcher$のストリームを合成したストリームがhttpGetRequest()を呼び出すことでサーバーの状態を取得します。

Observable.merge(this.dispatcher$)
  .switchMap(() => this.httpGetRequest())
  .subscribe(state => {
    this.provider$.next(state)
  })

わざわざmergeオペレーターを使っているのは、今後dispatcher$が複数になってもコードを書き換えなくていいようにするためです。
switchMapオペレーターによりストリームは適切にキャンセルされます。(参照: RxJSのconcatMap, mergeMap, switchMapの違いを理解する)

provider$のストリームはgetState$()を通じてコンポーネントが取得できるようにします。

getState$(): Observable<FakeState> {
  return this.provider$;
}

コンポーネント

クライアント側の状態管理をまとめています。本質ではない部分は省略しました。

AppComponent.ts
@Component({ /* 省略 */ })
export class AppComponent {
  private isLoaded = false
  state: FakeState | null = null

  constructor(private fakeStateService: FakeStateService) {
    // サーバーから状態を取得する。これ以後はクライアントの状態を優先してViewに反映させる。
    this.fakeStateService.getState$()
      .take(1)
      .subscribe(state => {
        this.state = state
        this.isLoaded = true
      })
  }

  get serverState$(): Observable<FakeState> {
    return this.fakeStateService.getState$()
  }

  updateIine(): void {
    Observable.of(this.state)
      .takeWhile(() => this.isLoaded)
      .map(state => !state.iine)
      .subscribe(iine => {
        this.updateComponentState({ iine })
        this.fakeStateService.updateIine(iine) // サーバーの状態変更は裏で処理される。
      })
  }

  private updateComponentState(state: Partial<FakeState>): void {
    this.state = { ...this.state, ...state }
  }
}

コンポーネント初期化時に、一度だけサーバーの状態を取得します。
このときisLoadedフラグをtrueにしているのがコツです。

    this.fakeStateService.getState$()
      .take(1)
      .subscribe(state => {
        this.state = state
        this.isLoaded = true
      })

「いいね」ボタンを押されたら、updateIine()を呼び出すことでクライアントの状態を更新してすぐに画面に反映させます。そのあと遅れてサーバーの状態を更新します。
なのでサーバーの状態が正しく更新されないときがあるかもしれませんが、そこを厳密にやるべきかどうかは要件次第でしょう。

  updateIine(): void {
    Observable.of(this.state)
      .takeWhile(() => this.isLoaded)
      .map(state => !state.iine)
      .subscribe(iine => {
        this.updateComponentState({ iine })
        this.fakeStateService.updateIine(iine)
      })
  }

takeWhileオペレーターによってisLoadedフラグがtrueのときだけストリームが流れるようになっています。
サーバーから「いいね」の状態を取得していないのに「いいね」ボタンを押されて更新リクエストを飛ばしてしまうよりよっぽどいいですね。

その後はserverState$()プロパティを通じて更新後のサーバーの状態を取得しているので、クライアントとサーバー間で状態の矛盾が生じないことをデモで確認していだけると思います。

  get serverState$(): Observable<FakeState> {
    return this.fakeStateService.getState$()
  }

RxJSをちょっと使うだけでユーザーにとって直感的なUIを簡単に構築できますね。ではまた。

P.S.
似たようなことを以前書いたことがあるなと思ったらこれでした。 → Firebase-as-a-Store ~RxJSで作るFirebaseバックエンドのRedux~