47
31

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

ReactAdvent Calendar 2016

Day 1

Reactとredux-Observableでショッピングカートアプリを作ってみる話

Last updated at Posted at 2016-11-30

これはReact Advent Calendar 2016の第1日目の記事です。

夏頃からRxJSで状態を管理してReactで描画するようなアプリケーションを作っていました。
RxJSに限らず、(F)RP系ライブラリの導入に際して、導入のメリットが分かりづらいという話があります。
難しい(そうに見える)新しいことを覚えてまで導入するほどのメリットがあるようには思えない、みたいな意見です。
特にライブラリのレポジトリに上がっているExampleアプリのような(例えばTODOアプリとか)ものだと、単に複雑性が増しているように見えるということもあります。

これについて、Functional Reactive Programmingという本に、(F)RPは小さく単純なアプリケーションでサンプルを作ってもメリットが見えてこない、機能同士に複雑な依存関係があるような複雑なアプリケーションになって、はじめてその真価がわかってくる、というようなことが書いてありました。
(この本ではガソリン給油機アプリを想定して、ハイオク・レギュラーを給油しつつ、価格の最大値まで給油するみたいな仕組みを実装していました)

この意見は、実際にアプリケーションを作った上での実感にも近いように思います。
そこでこの記事ではreduxのレポジトリにあるサンプルアプリケーションを元にちょっと複雑な仕様を足してみて、RxJSの導入メリットが分かりやすくなることを目指したいと思います。

こんな手順で進めます。

  1. reduxのチュートリアルからサンプルアプリケーションを見繕ってコードと構造をザッと把握する
  2. 1で選んだサンプルアプリケーションの非同期処理部分をRxJSベースのreduxミドルウェアであるredux-observableで差し替える
  3. 現実にあってもおかしくなさそうな仕様を足してみて、RxJSが複雑さを吸収できていそうか見てみる

reduxのチュートリアルにあるサンプルアプリケーションをクローンしてきてざっと見る

reduxレポジトリのexamplesディレクトリにいくつかサンプルアプリケーションがあります。
この中で、非同期な通信の処理が入って現実のアプリケーションを想像しやすく、適度にシンプルな「ショッピングカート」アプリケーションを元にして進めてみたいと思います。

最初はこんな状態です

sample.png

図のように、Productsを在庫の許す限りCartに投入できて、Cartの内容によって支払い総額が変わる、といったアプリケーションです。
非同期アクションをredux-thunkaction creatorの中にdispatcherを流し込んで実現しているという、シンプルなアプリケーションとしてはわりとよくある感じの実装になっているようです。
※サーバ側の実装は煩雑さを避けるために、単なるjsonファイルとsetTimeoutで、非同期にレスポンスが返ってくることだけを表現しています。

サンプルアプリを、redux-observableで再実装してみる

では次に、このアプリケーションのredux-thunkを用いている部分をredux-observableに置き換えてみます。
まだこの時点では、RxJSを用いるメリットよりもデメリットの方が多いはずです。

次はこういう状態にします

// actions/index.js
import * as types from '../constants/ActionTypes'

export const fetchAllProducts = () => ({
  type: types.FETCH_PRODUCTS
})

export const receiveAllProducts = products => ({
  type: types.RECEIVE_PRODUCTS,
  products
})

export const addToCart = productId => ({
  type: types.ADD_TO_CART_UNSAFE,
  productId
})

export const receiveInCart = productId => ({
  type: types.ADD_TO_CART,
  productId
})

export const checkout = products => ({
  type: types.CHECKOUT_REQUEST,
  products,
})

export const checkoutSuccess = cart => ({
  type: types.CHECKOUT_SUCCESS,
  cart,
})
// epics/index.js
import { Observable } from "rxjs";
import shop from '../api/shop'
import * as types from '../constants/ActionTypes'
import {receiveAllProducts, receiveInCart, checkoutSuccess} from '../actions'

export const allProductsEpic = (action$) => action$
  .ofType(types.FETCH_PRODUCTS)
  .mergeMap(_ => Observable.bindCallback(shop.getProducts)())
  .map(receiveAllProducts)

export const addToCartEpic = (action$, store) => action$
  .ofType(types.ADD_TO_CART_UNSAFE)
  .map(({productId}) => productId)
  .filter(productId => store.getState().products.byId[productId].inventory > 0)
  .map(receiveInCart)

export const checkoutEpic = (action$, store) => action$
  .ofType(types.CHECKOUT_REQUEST)
  .map(({products}) => products)
  .mergeMap(products => Observable.bindCallback(shop.buyProducts)(products))
  .map(_ => store.getState().cart)
  .map(checkoutSuccess)

大きく変わっているのは、redux-thunkを用いてaction creatorの中で表現していた非同期処理を、Epicと呼ばれる別の構造の中で宣言することで、action creatorが純粋で同期的な関数に戻っていることです。
Epicとはredux-observableが提供する構造で、私はreduxの世界にRxJSObservableを組み込むためのもの、みたいに解釈しています。
redux-observableのドキュメントには

It is a function which takes a stream of actions and returns a stream of actions. Actions in, actions out.
You can think of it of having roughly this type signature:
function (action$: Observable<Action>, store: Store): Observable<Action>;

とあります。
actionObservableを受け取って、変性・フィルタリングなどを加えたactionObservableを返す関数であるEpicMiddlewareとしてreduxの世界の中に組み込んでいる感じのようです。

仕様をいくつか足して、アプリ仕様を複雑にしてみて、Rxが複雑性を吸収する様を見る

さて、ここまでだとコードが減っているわけではないですし、あまりメリットが見えて来ないかも知れません。(actionが純粋で同期的な関数からのみ生成されるようにはなりましたが。。。)
そこで(F)RP的なメリットが見えやすいように、機能同士に複雑な依存関係が必要になるような仕様を追加してみたいと思います。

例えば今の仕様だと「カートの中身」は「商品のIDを投げるaction」のみに依存しています。
これを別のactionにも同時に依存しなければならないようにしてみたいと思います。

こんな仕様です。

  • 3個の商品を購入すると支払い総額から1割値引きされる
  • 購入しようとしているユーザには通常会員・プレミアム会員の2種があり、属性によって割引率が変わる
  • 購入した商品の総額によって、割引率が増える

こうなると、「カートの中身」とは「商品のIDを投げるAction」に加えて「ユーザの属性を投げるaction」に依存したものになります。

実際にコードに起こしたものはこういう感じです。
少しだけ複雑なアプリケーション

まず「カートの中身」をEpicにします。
cart reducerに定義されていた「カートの中身とは、ADD_TO_CARTアクションで渡ってくる商品IDをとりまとめたもの」という定義をEpicとして再定義する感じです。
ですので、Epicの中に定義された関数は、reducers/index.jsに定義されていたユーティリティ関数を少し改修したものが多いです。

export const cartEpic = (action$, store) => {
  const productId$ = Observable.merge(
    action$.ofType(ADD_TO_CART).map(({ productId }) => productId),
    action$.ofType(CHECKOUT_REQUEST).mapTo(null)
  )

  const addedIds$ = productId$
    .scan((addedIds, id) => {
        if (id === null) {
          return []
        }
        return addedIds.indexOf(id) !== -1 ? addedIds : [...addedIds, id]
      }, [])

  const quantityById$ = productId$
    .scan((quantityById, id) => {
      if (id === null) {
        return {}
      }
      return { ...quantityById, [id]: (quantityById[id] || 0) + 1 }
    }, {})

  return Observable
    .combineLatest(addedIds$, quantityById$, (addedIds, quantityById) => ({addedIds, quantityById}))
    .map(({addedIds, quantityById}) => {
      const {byId} = store.getState().products

      const total = addedIds
          .reduce((acc, id) => acc + byId[id].price * (quantityById[id] || 0), 0)
          .toFixed(2)

      const products = addedIds
          .map(id => ({
            ...byId[id],
            quantity: quantityById[id] || 0,
          }))

      return updateCart({
        total,
        products
      })
    })
}

次にアプリケーションにおける「ユーザー」を定義します。
まず既存の実装をベースに、偽のAPIレスポンス関数を用意します。

// こんなレスポンスを返すAPIを想定しています
{
  "userType": 1, // 通常会員には0、プレミアム会員には1を返します
  "amount": 499 // そのユーザーの、今までの購買額を返します
}
export const getUser = cb => setTimeout(() => cb(_user), TIMEOUT)

更にユーザー情報をEpicとして定義して、ユーザー情報の取得(必要なら更新も)をアプリケーションに組み込みます。

const userEpic = action$ => action$
  .ofType(ON_LOAD)
  .mergeMap(_ => Observable.bindCallback(getUser)())
  .map(recieveUser)

最後に、割引率の算出ロジックを実装します。
まずuserEpicがユーザー属性と購買総額に応じた割引率を取得できるように変更します。

export const userEpic = action$ => {
  const user$ = action$
    .ofType(ON_LOAD)
    .mergeMap(_ => Observable.bindCallback(getUser)())

  const total$ = action$
    .ofType(CHECKOUT_REQUEST)
    .map(({total}) => ({ amount: Number(total) }))

  return user$
    .merge(total$)
    .scan((acc, next) => ({
      amount: acc.amount + next.amount,
      userType: acc.userType,
    }))
    .map(user => ({
      ...user,
      // プレミアムユーザー・支払い総額が1000ドルを超えるユーザーはそれぞれ割引率が追加される
      discountRate: (user.userType === UserTypes.PREMIUM ? 0.1 : 0) + (user.amount > 1000 ? 0.1 : 0)
    }))
    .map(recieveUser)
}

次にuserEpicが生成する「割引率」を「カートの中身」に混ぜ込みます。
これで最終的な割引率を加味した購入額をカートのチェックアウトの際に取得できるようになります。

export const cartEpic = (action$, store) => {
  // ...略
  const discountRate$ = action$.ofType(RECEIVE_USER).pluck("discountRate")

  return Observable
    .combineLatest(addedIds$, quantityById$, discountRate$, (addedIds, quantityById, userDiscountRate) => ({addedIds, quantityById, userDiscountRate}))
    .map(({addedIds, quantityById, userDiscountRate}) => {
      // ...略
      const totalQuantity = addedIds.reduce((acc, id) => acc + (quantityById[id] || 0), 0)
      const discountRate = (totalQuantity >= 3 ? 0.3 : 0) + userDiscountRate

      return updateCart({
        total,
        products,
        discountRate,
      })
    })
}

という感じで、難しい書き方をせずに「複数のactionを起点に別のactionを発行する」ような機能が実装できるのが、redux-observableRxJSreduxの世界で用いるメリットということになるでしょうか。
ここでは触れられませんでしたが、EpicObservableを受け取ってObservableを返す関数に過ぎないので、RxJSの提供するテスト手法に乗っかって簡単にテストできるのもうれしいところです。

まとめ

以上、redux-thunkで非同期処理を取り扱っているreduxの公式サンプルをredux-observableに置き換えて、少し仕様を複雑にするところまでを実装してみました。
何かの参考になればうれしいです。
明日のReact Advent Calendar 2016もお楽しみに!(地図ライブラリの話めっちゃ気になります)

参考

47
31
0

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
47
31

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?