これはReact Advent Calendar 2016の第1日目の記事です。
夏頃からRxJS
で状態を管理してReact
で描画するようなアプリケーションを作っていました。
RxJS
に限らず、(F)RP系ライブラリの導入に際して、導入のメリットが分かりづらいという話があります。
難しい(そうに見える)新しいことを覚えてまで導入するほどのメリットがあるようには思えない、みたいな意見です。
特にライブラリのレポジトリに上がっているExampleアプリのような(例えばTODOアプリとか)ものだと、単に複雑性が増しているように見えるということもあります。
これについて、Functional Reactive Programmingという本に、(F)RPは小さく単純なアプリケーションでサンプルを作ってもメリットが見えてこない、機能同士に複雑な依存関係があるような複雑なアプリケーションになって、はじめてその真価がわかってくる、というようなことが書いてありました。
(この本ではガソリン給油機アプリを想定して、ハイオク・レギュラーを給油しつつ、価格の最大値まで給油するみたいな仕組みを実装していました)
この意見は、実際にアプリケーションを作った上での実感にも近いように思います。
そこでこの記事ではredux
のレポジトリにあるサンプルアプリケーションを元にちょっと複雑な仕様を足してみて、RxJS
の導入メリットが分かりやすくなることを目指したいと思います。
こんな手順で進めます。
-
redux
のチュートリアルからサンプルアプリケーションを見繕ってコードと構造をザッと把握する -
1
で選んだサンプルアプリケーションの非同期処理部分をRxJS
ベースのredux
ミドルウェアであるredux-observableで差し替える - 現実にあってもおかしくなさそうな仕様を足してみて、
RxJS
が複雑さを吸収できていそうか見てみる
reduxのチュートリアルにあるサンプルアプリケーションをクローンしてきてざっと見る
reduxレポジトリのexamplesディレクトリにいくつかサンプルアプリケーションがあります。
この中で、非同期な通信の処理が入って現実のアプリケーションを想像しやすく、適度にシンプルな「ショッピングカート」アプリケーションを元にして進めてみたいと思います。
図のように、Products
を在庫の許す限りCart
に投入できて、Cart
の内容によって支払い総額が変わる、といったアプリケーションです。
非同期アクションをredux-thunk
でaction 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
の世界にRxJS
のObservable
を組み込むためのもの、みたいに解釈しています。
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>;
とあります。
action
のObservable
を受け取って、変性・フィルタリングなどを加えたaction
のObservable
を返す関数であるEpic
をMiddleware
として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-observable
とRxJS
をredux
の世界で用いるメリットということになるでしょうか。
ここでは触れられませんでしたが、Epic
はObservable
を受け取ってObservable
を返す関数に過ぎないので、RxJSの提供するテスト手法に乗っかって簡単にテストできるのもうれしいところです。
まとめ
以上、redux-thunk
で非同期処理を取り扱っているredux
の公式サンプルをredux-observable
に置き換えて、少し仕様を複雑にするところまでを実装してみました。
何かの参考になればうれしいです。
明日のReact Advent Calendar 2016もお楽しみに!(地図ライブラリの話めっちゃ気になります)