Reduxから見えてきた希望と課題

こんにちは!pairsの開発を担当している太田です。
2016年3月現在、もっともアツいFlux実装といえばReduxですね!
2015年5月にスタートしてからグングン人気が出てGitHubのStarは本家FacebookのFluxを超えています。

facebook_flux

reactjs_redux

プロダクトにReduxを採用したレポートをよくみかけますし、Angular2とReduxを組み合わせたり、iOSやAndroidでReduxにインスパイアされたフレームワークが登場していたりして、SPA界隈だけでなくモダンなクライアントアプリムーブメントの中心になってきている感があります。

Reduxのドキュメントは大変よく整備されていて、サンプルも多数あり、さらには充実したポッドキャストもあります。

サンプルでReduxのHelloWorld的なポジションのCounterはシンプルでReduxをキャッチアップしやすい内容になっています。
が、CounterサンプルにはReal WorldでSPAを作るときに必須な非同期なWeb APIコールや画面遷移などは含まれていません。

Real Worldで必要なそういった機能をReduxではどう書けばいいのかを知りたかったので、AngularJSのPhonecatチュートリアルをReduxで実装して理解を試みました。そこで感じたReduxに対する希望と課題についてレポートしたいと思います。
実装したものはこちらです。

sohta3/redux_phonecat
redux_phonecat

Reduxが解決しようとしていること

SPA(Single Page Application)では

  • 非同期処理
  • 状態管理
  • レンダリング

をうまくコントロールすることが重要です。
より高度なUXを実現しようとすればするほどアプリの複雑性は高まっていきます。
Fluxはこのような複雑化するアプリの破綻を避け、スケール可能を実現するためのアーキテクチャです。
ReduxはこのようなFluxアーキテクチャの実装の1つであり、Reduxの目的もまたFluxと同じです。

Reduxの構成

redux_archtecture

Fluxアーキテクチャではアプリの状態変更のトリガーは必ずActionになります。もちろんReduxもこの点は変わりません。
View上のユーザー操作などにより状態変更を方向づけるActionをAction Createrを通じて生成します。
Actionに対して横断的処理を行なうMiddlewareを通じてActionはReducerへ届きます。
Reducerは現在のStateとActionをもとに新たなStateを生成して返します。
Reducerが返したStateはStoreの中に入ります。ViewはStoreを介してStateを取得してレンダリングで使います。

ReduxのThree Principles

Reduxが人気を集めているのは、Reduxが表明しているポリシーが簡潔で明確な面が大きいと思います。
Reduxには3つの原則があります。

Single source of truth
アプリの状態をもつStoreはただ1つ。

state is read-only
状態を変更するには必ずActionを発行しなければならない。

Changes are made with pure functions
Actionにより状態をどう変更させるかはReducerが行なう。


FacebookのFlux実装は複数Storeの設計や構造をどうすべきかよくわからなかったり、モジュール間の連携が冗長な印象があったりしたのですが、Reduxはそのあたりをうまく解消していると思いました。
例えば、Facebook Fluxでは状態変更がStoreで実装されていましたが、Reduxでは状態変更できるのはReducerだけ、という制約があります。
さらにReducerは (state, action) => state を満たす状態を持たないふつうの関数(純粋関数)でなければなりません。
これらの強い制約がモジュールの役割がクリアにし、迷いなく分割統治を進めやすくなっていると思いました。

Reduxアプリの起動

Reduxアプリの起動をみていきます。
アプリで唯一のStoreを生成し、ルートコンポーネントをレンダリングします。

import React from 'react'
import ReactDOM from 'react-dom'
import { createStore, applyMiddleware, combineReducers } from 'Redux'
import Root from './containers/Root'
import {phones, phone} from './Reducers'
import thunkMiddleware from 'Redux-thunk'
import createLogger from 'Redux-logger'
import { Provider } from 'react-Redux'

// Storeの生成
const store = createStore(combineReducers({phones, phone}), {phones: {processedPhones: []}}, applyMiddleware(thunkMiddleware, createLogger()))

const rootEl = document.getElementById('root')

function render() {
  ReactDOM.render(
  <Provider  store={store}>
    <Root/>
  </Provider>,
    rootEl
  )
}

render()
store.subscribe(render)

Storeの生成についてみていきます。

createStore

createStoreはStoreを生成するヘルパーメソッドです。
createStoreは引数にReducer、初期state、Enhancerを取ります。
Enhancerは横断的な機能拡張をするMiddlewareのことです。

combineReducers

Reducerをアプリ内で1つしか使わない場合はReducerをcreateStoreにそのまま渡せばよいのですが、普通に複数のReducerの必要性が出てくるので、combineReducersというヘルパーメソッドで複数ReducerをまとめてcreateStoreに渡します。

combineReducersでまとめられたReducerから出力されるStateにはReducer名のプロパティが設定されるようになります。StateのプロパティとReducerがマッピングされ、Reducerの担当範囲が限定されます。

例えばこのようなReducerたちがある場合


function phones(state, action) {
	// ...
	
	return state;
}

function phone(state, action) {
	// ...
	
	return state;
}

これらをcombineReducersすると


combineReducers({phones, phone})

StateにはReducerの名前ごとのプロパティが生成されます。

{
    phones: {},
    phone: {}
}

また、このphones Reducerに渡されてくるStateはState全体ではなく、Stateのphonesプロパティの値だけが渡されます。


function phones(state, Action) {
	// このReducerに渡されるstateは全体stateのうちのphonesだけ!
	// ...
	
	return state;
}

このようにStateはアプリで1つだけですが、Reducerごとに分割されたStateに対して処理を書けるので、非常に扱いやすくなっています。

applyMiddleware

Middlewareの設定をするヘルパーメソッドです。
MiddlewareはすべてのActionに対して横断的な処理を適用するものです。
例えばこのようなMiddlewareがあります。

Redux-thunk: 非同期Actionをサポートする
Redux-logger: ブラウザコンソールにいい感じでAction/Stateをロギングしてくれる

Provider

renderメソッドの中をみてみると、StateをプロパティとしてもつProviderタグがアプリのRootElementを囲んでいます。
Providerはreact-reduxというライブラリが提供するコンポーネントです。

react-reduxはReduxとReactを連携する中間層(Container)の役割をしています。ReactはReduxのStateを直接受け取るのではなく、react-reduxが提供するContainerを挟むことでReduxへの依存を回避しています。

reactjs/react-redux

非同期処理(Web APIコール)をするには

ReduxでWeb APIコールなどの非同期処理を行なう場合、redux-thunkのような非同期Actionをサポートするミドルウェアの使用が必要になります。

なぜかというと、ReduxではReducerへの呼び出し以降が同期処理でしか動作しないためです。
このため、非同期処理はReducerへいく前に済ませておく必要があります。
つまりAction Creater〜Middlewareで非同期処理を完了させる必要があります。
これをサポートしてくれるMiddlewareがredux-thunkです。

gaearon/redux-thunk

redux-thunkを使う場合の非同期Actionの書き方

下記のAction CreaterであるfetchPhonesはActionオブジェクトではなく関数を返します。
この関数をredux-thunkが受け取って評価します。
この関数はrequestPhonesが生成するActionをまずdispatchして、その後Web APIを非同期実行して得られたレスポンスデータをreceivePhonesへ渡し、Actionを生成してdispatchします。
requestPhonesはWeb APIリクエストを開始する状態変更を促します。
receivePhonesはWeb APIリクエストが完了してレスポンスを取得した状態変更を促します。


function fetchPhones(order) {
    return dispatch => {
    
        // リクエスト状態へ変更する
        dispatch(requestPhones(order))
    
        return fetch(`http://localhost:3000/phones?order=${order}`)
            .then(response => response.json())
            .then(json => dispatch(receivePhones(order, json))) // リクエスト完了状態へ変更する
    }
}

function requestPhones(order) {
    return {
        type: 'REQUEST_PHONES',
        order
    }
}

function receivePhones(order, json) {
    return {
        type: 'RECEIVE_PHONES',
        order: order,
        phones: json
    }
}

ルーティング

react-routerを使えばReactでルーティング処理ができるようになります。
Reduxを導入した場合でも、問題なくreact-routerを利用できます。

reactjs/react-router

テスト

Action Createrのテスト

Action Createrのテストは基本的に関数を呼び出して返されたActionオブジェクトの検証になります。


  it('filterPhone should create FILTER_PHONE Action', () => {
    expect(Actions.filterPhone('nexus')).toEqual({
      type: 'FILTER_PHONE',
      query: 'nexus'
    })
  })

しかし、非同期Actionのテストはこのように単純に書くことができません。
そこで、redux-mock-storeという非同期Actionのテストを書くためのライブラリを使って検証します。
このライブラリはActionがdispatchされたことを検証する目的でStoreのモックを提供します。
このモックStoreを利用して、Action Createrから非同期で生成されるActionがStoreへdispatchされたことを検証します。

arnaudbenard/redux-mock-store


import expect from 'expect'
import * as Actions from '../../Actions'
import configureMockStore from 'Redux-mock-store'
import thunk from 'Redux-thunk'
import nock from 'nock'

const Middlewares = [ thunk ]
const mockStore = configureMockStore(Middlewares)

describe('Actions', () => {

  // Async Action
  it('fetchPhonesIfNeeded should create REQUEST_PHONES/RECEIVE_PHONES Action', (done) => {
    const phones = [{ "age": 0,
        "id": "motorola-xoom-with-wi-fi",
        "imageUrl": "img/phones/motorola-xoom-with-wi-fi.0.jpg",
        "name": "Motorola XOOM\u2122 with Wi-Fi",
        "snippet": "The Next, Next Generation\r\n\r\nExperience the future with Motorola XOOM with Wi-Fi, the world's first tablet powered by Android 3.0 (Honeycomb)."}]
    const order = 'name';

    nock('http://localhost:3000/')
      .get('/phones?order=name')
      .reply(200, phones)

    // 非同期でStoreにdispatchされるAction群
    const expectedActions = [
      { type: 'REQUEST_PHONES', order: order },
      { type: 'RECEIVE_PHONES', order: order, phones: phones }
    ]
    const store = mockStore({ phones: [] }, expectedActions, done)
    store.dispatch(Actions.fetchPhonesIfNeeded(order))
  })

Reducerのテスト

Reducerは(state, action) => state の純粋関数なのでテストは単純です。
テスタビリティは最高です。

	it('should handle REQUEST_PHONES', () => {
		expect(
			phones({}, {
				type: 'REQUEST_PHONES'
			})
		).toEqual({
			isFetching: true,
			didInvalidate: false,
			phones: [],
			processedPhones: []
		})
	})

	it('should handle RECEIVE_PHONES', () => {
		expect(
			phones({
				isFetching: true,
				didInvalidate: false,
				phones: [],
				processedPhones: []
			}, {
				type: 'RECEIVE_PHONES',
				phones: phonesstate
			})
		).toEqual({
			isFetching: false,
			didInvalidate: false,
			phones: phonesstate,
			processedPhones: phonesstate
		})
	})

まとめ

Reduxのいいところ

  • 非同期処理/状態管理/レンダリングを分割統治できる
    • 設計しやすい
    • テストが書きやすい
    • スケールしやすい
  • 1つのStoreと複数のReducerのまとめ方がうまい
    • Facebook FluxだとStoreの設計やStoreの持つ状態変更の処理設計が難しい感じだった
    • この部分に関して迷わなくてよいのはうれしい

Reduxのアレなところ

  • Action Creater肥大化の恐れ
    • Reducerが非同期処理できない
    • Reducer以降が同期処理に限定されるので、Action CreaterとMiddlewareで非同期処理をコントロールしなければならない
    • Middlewareは横断的処理を行なうべきものなので、ドメインの知識はAction Createrに集中する傾向では
    • Action Creater大丈夫か
  • ちょっとしたことでもAction/Reducer/stateをそれぞれ書かなくてはならない面倒さ
    • そもそもちょっとしたことをするアプリにReduxを適用してもペイしない
  • react-routerとreact-reduxを組み合わせてきれいにContainerとComponentをつくっていくWayがよくわかってない
    • これは個人的問題
    • 練習不足

Real WorldでSPAを作るにあたり、Reducerが非同期処理を扱えない問題はやはり大きなdisadvantageになっているようです。
これを突破しようとしている先人たちがいます。

Reduxはこのポリシーを変えそうにないので、このあたりをうまくカバーした別のものが人気を集めるのかもしれないと思った2016年の春先でした。

  • このエントリーをはてなブックマークに追加

エウレカでは、一緒に働いていただける方を絶賛募集中です。募集中の職種はこちらからご確認ください!皆様のエントリーをお待ちしております!

Recommend

Go言語のベンチマークでパフォーマンス測定

突撃!隣のGo言語テストコード(エウレカのGo言語におけるテストコードの工夫)