Reduxのパターンとアンチパターン

Reduxは、Fluxのようなアーキテクチャを使用してアプリケーションの状態を管理できる非常にシンプルなライブラリです。私たちAffirmでは今、Reduxのタイムトラベル機能に注目しています。Affirmの主要事業は、透明性の高い消費者ローンを提供することなので、ローン申し込み時の全過程をユーザ視点で再現できると非常に有用なのです。

Reduxはフレームワークというよりも、パターンの適用に役立つ関数セットです。よって、適切なパターンを慎重に適用しないと、Reduxを使ったことを後悔する結果になりかねません。この記事では、Affirmで確立したReduxのベストプラクティスや、ミスを犯しやすいポイントについて説明します。

ImmutableJS

ImmutableJSは、不変の永続データ構造を扱うためのライブラリです。私たちがこのライブラリを好んで使う理由は2つあります。

1つ目は、不変データの使用に伴うメリットが幾つかあるということです。この件に関しては、非常に多く記事で説明されていますが、要点をまとめると参照透過性という概念に集約されます。オブジェクトをインプレースで(参照ポインタを変更せずに)変更できる場合、プログラムは論理的に理解しづらくなります。

よりハイレベルな表現をすると、可変データを使うと、プログラム解析に数学を利用しづらくなるということです。例えば「xは必ずしもxとは限らない」という数学の定理があったらどうでしょう? 実際、不変データの場合は、オブジェクトの変更を確認する際に詳細な比較はせず、厳密等価演算子(===)で済ませてしまうことがよくあります。この方法は、Reactコンポーネントを再描画するケースなどでは非常に便利です。

ImmutableJSを使う2つ目の理由はパフォーマンスです。不変データを利用して、変更を行うたびに基になるデータ構造を複製し続けると、ガベージスラッシングの原因になります。この問題に対処するため、ImmutableJSでは永続データ構造を採用しています。これによって効率よくイミュータブルな更新を行い、基になるデータを複製することなく新しい参照を返すことができます。

では、連結リストの仕組みについて考えてみましょう。先頭にアイテムを追加して新しい連結リストを作成するには、単純に古い先頭を指す新しいノードを作成して、その新しいノードへの参照を返すようにします。以前のリストは保持され、基になるデータは新しいリストと共有されます。インプレースの変更がない限りは、この方法で不変データを効率よく扱うことができます。

ただし、ImmutableJSは正しく使わなければ効果がありません。私がよく目にする間違いは2つあります。1つ目は、複数の変更を行う方法です。例として、Map内で複数の値を設定する状況を考えてみましょう。以下ではloadingをfalseに指定して、stateのuserプロパティを更新しています。

state.set('loading', false).set('user', user)

Mapに値をセットする際に、ImmutableJSは内部で(Hash Mapped Array Trieを使って)中間状態が維持されるように、ありとあらゆる再編成を行います。これは不要な作業です。というのも、必要なのは最終的な結果だけで、中間段階は必要ないからです。その代わりに、これらの更新処理をバッチで行う際にwithMutations関数を使って、再編成を一度だけ行えばよいのです。

state.withMutations(s => s.set('loading', false).set('user', user))

その他のアンチパターンとして挙げられるのは、データを処理したい時には常に.toJS()を使って、生のJavaScriptオブジェクトへ標準的な変換をしている点です。Affirmで見たことのある、典型的な例があります。

const mapStateToProps = (state) => ({
  loans: state.get('loans').toJS(),
})

こうすると、ImmutableJSを利用することによるパフォーマンス上の利点が全く無くなってしまいます。というのも、実際、これを実行するたびにオブジェクトを複製しているからです。その代わりに、データをImmutableJSのオブジェクトのままにしておいて、.get.getInなどのImmutableJSのメソッドを使うべきです。

const mapStateToProps = (state) => ({
  loans: state.get('loans'),
})

ReduxのAction

以下に、ローンの支払いをするReduxのアクションの例を挙げます。

 export function makePayment(loanId, amount, paymentMethodId) { 
   return dispatch => { 
     dispatch(makePaymentSent()) 
     fetch(`/api/loans/${loanId}/payments`, { 
       headers: new Headers({ 
         'Content-Type': 'application/json', 
       }), 
       credentials: 'same-origin', 
       method: 'POST', 
       body: JSON.stringify({ 
         'amount': amount, 
         'paymentMethodId': paymentMethodId, 
       }) 
     }) 
     .then(response => { 
       dispatch(closeModal()) 
       if (response.status !== 200) { 
         throw new Error(response.statusText) 
       } else { 
         return response.json() 
       } 
     }) 
     .then(result => { 
       dispatch(makePaymentSuccess(result.data)) 
     }) 
     .catch(error => { 
       dispatch(makePaymentFailed(error.message)) 
     }) 
   } 
}

このコードは問題なく動作します。Reduxの初心者であっても、アクションを作成すると、たぶん全てこのようになります。つまり、上記のコード内には幾つかのアンチパターンがあるということです。それらを1つずつ洗い出していきましょう。

まず問題となるのは、プログラムのロジックにおけるエラーの使い方です。try-catchをプログラムロジックに使うことにより、コード内の実際のエラーを簡単にマスクして、コンソールに表示されないようにすることは可能です。例えば、if (reponse.status)の部分のスペルを間違えていたとしたらどうでなるでしょうか? その場合、例外エラーにはまってしまって、バグの追跡が非常に難しくなります。ですから、catchステートメントを削除しましょう。

 export function makePayment(loanId, amount, paymentMethodId) { 
   return dispatch => { 
     dispatch(makePaymentSent()) 
     fetch(`/api/loans/${loanId}/payments`, { 
       headers: new Headers({ 
         'Content-Type': 'application/json', 
       }), 
       credentials: 'same-origin', 
       method: 'POST', 
       body: JSON.stringify({ 
         'amount': amount, 
         'paymentMethodId': paymentMethodId, 
       }) 
     }) 
     .then(response => { 
       dispatch(closeModal()) 
       if (response.status !== 200) { 
         dispatch(makePaymentFailed(response.statusText)) 
       } else { 
         return response.json() 
         .then(result => { 
           dispatch(makePaymentSuccess(result.data)) 
         }) 
       } 
     }) 
   } 
 }

次にアクションの目的をアクションの実装から切り離します。HTTPのリクエストがどのように設定されて送られるのかについて、このアクションが気にしなければならない理由はありません。ですので、専用のファイルに抜き出しましょう。

 // api.js 
export function makePayment(loanId, amount, paymentMethodId) { 
   return fetch(`/api/loans/${loanId}/payments`, { 
     headers: new Headers({ 
       'Content-Type': 'application/json', 
     }), 
     credentials: 'same-origin', 
     method: 'POST', 
     body: JSON.stringify({ 
       'amount': amount, 
       'paymentMethodId': paymentMethodId, 
     }) 
   }) 
 } 
 // actions.js 
 import * as api from './api' 
 export function makePayment(loanId, amount, paymentMethodId) { 
   return dispatch => { 
     dispatch(makePaymentSent()) 
     api.makePayment(loanId, amount, paymentMethodId) 
     .then(response => { 
       dispatch(closeModal()) 
       if (response.status !== 200) { 
         dispatch(makePaymentFailed(response.statusText)) 
       } else { 
         return response.json() 
         .then(result => { 
           dispatch(makePaymentSuccess(result.data)) 
         }) 
       } 
     }) 
   } 
 }

これで、このActionには実装よりも目的が反映されるようになりました。リファクタリングの際に、ES7 async-await構文を使ってこの深いネスト構造を処理しましょう(注意:babel-polyfillと一緒にBabel stage-0プリセットを使う必要があります)。

 export function makePayment(loanId, amount, paymentMethodId) { 
   return async dispatch => { 
     dispatch(makePaymentSent()) 
     const response = await api.makePayment(loanId, amount, paymentMethodId) 
     dispatch(closeModal()) 
     if (response.status !== 200) { 
       dispatch(makePaymentFailed(response.statusText)) 
     } else { 
       const result = await response.json() 
       dispatch(makePaymentSuccess(result.data)) 
     } 
   } 
 }

少し改善したようですが、まだ十分ではありません。

他にも、不要な再描画を引き起こす、過剰なディスパッチをしているという問題があります。アクションをディスパッチするたびに、そのアクションはReducerに渡され、新しいstateがレンダリングされていることに気付く人はほとんどいません。上記の例では、リクエストが戻って来ると、直ちに2つのレンダリングを引き起こします。それは、closeModalによって引き起こされるレンダリングと、makePaymentSuccessもしくはmakePaymentFailedのどちらかによって引き起こされるレンダリングです。

closeModalアクションをディスパッチすることは、このアクションの意図を台無しにしてしまうことにもなります。makePaymentは、モーダルウィンドウのクローズに関わるべきではありません。代わりに、成功アクションまたは失敗アクションを受け取ると、reducerでこのロジックを実行する必要があります。

// reducer.js
export default function reducer(state=initialState, action) {
  switch(action.type) {
    case defs.MAKE_PAYMENT_SUCCESS:
      return state.withMutations(s =>
        s.set('modalOpen', false)
         .set('loading', false)
         .setIn(['loans', action.loan.id], Immutable.fromJS(action.loan))
      )
    case defs.MAKE_PAYMENT_FAILED:
      return state.withMutations(s =>
        s.set('modalOpen', false)
         .set('loading', false)
         .set('paymentError', action.error)
      )
    // ...
  }
}
// actions.js
export function makePayment(loanId, amount, paymentMethodId) {
  return async dispatch => {
    dispatch(makePaymentSent())
    const response = await api.makePayment(loanId, amount, aymentMethodId)
    if (response.status !== 200) {
      dispatch(makePaymentFailed(response.statusText))
    } else {
      const result = await response.json()
      dispatch(makePaymentSuccess(result.data))
    }
  }
}

これは一石二鳥です! 最後に、同期アクションと非同期アクションを別々のファイルにリファクタリングしなければいけません。こうすることにより非同期アクションがよりテストしやすいものになります。なぜなら、同期アクションが呼び出された時に、それらをspyOnし、アサーションすることが可能になるからです。

// actions/sync.js
export const makePaymentSent = () => ({
  type: defs.MAKE_PAYMENT_SENT,
})
export const makePaymentFailed = (error) => ({
  type: defs.MAKE_PAYMENT_FAILED,
  error,
})
export const makePaymentSuccess = (loan) => ({
  type: defs.MAKE_PAYMENT_SUCCESS,
  loan,
})
// actions/async.js
import * as sync from './sync'
export function makePayment(loanId, amount, paymentMethodId) {
  return async dispatch => {
    dispatch(sync.makePaymentSent())
    const response = await api.makePayment(loanId, amount, paymentMethodId)
    if (response.status !== 200) {
      dispatch(sync.makePaymentFailed(response.statusText))
    } else {
      const result = await response.json()
      dispatch(sync.makePaymentSuccess(result.data))
    }
  }
}
// actions/index.js
export * from './async'
export * from './sync'
// actions/test.js
import expect from 'expect'
import mockApi from 'crm/src/api/mocks'
import * as actions from 'crm/src/actions'
describe('makePayment(loanId, amount, paymentMethodId)', () => {
  it('onSuccess', async () => {
    const dispatch = expect.createSpy()
    const loanId = Symbol()
    const amount = Symbol()
    const paymentMethodId = Symbol()
    const loan = Symbol()
    const apiMakePayment = mockApi.makePayment.success(loan)
    await actions.fetchUserLoans(loanId, amount, paymentMethodId)(dispatch)
    expect(dispatch).toHaveBeenCalledWith(actions.makePaymentSent())
    expect(apiMakePayment).toHaveBeenCalledWith(loanId, amount, paymentMethodId)
    expect(dispatch).toHaveBeenCalledWith(actions.makePaymentSuccess(loan))
  })
})

このテストは、実際には、関数自身の逆関数です。もっとも、この関数をテストする必要があるかどうかという疑問が湧いてきます。私は、コーディングのグッドプラクティスである証だと考えています。

ReduxのReducer

アクションを整理したので、先ほど定義したreducer関数を見てみましょう。

export default function reducer(state=initialState, action) {
  switch(action.type) {
    case defs.MAKE_PAYMENT_SUCCESS:
      return state.withMutations(s =>
        s.set('modalOpen', false)
         .set('loading', false)
         .setIn(['loans', action.loan.id], Immutable.fromJS(action.loan))
      )
    case defs.MAKE_PAYMENT_FAILED:
      return state.withMutations(s =>
        s.set('modalOpen', false)
         .set('loading', false)
         .set('paymentError', action.error)
      )
    // ...
  }
}

幸先よくwithMutationsを適切に使っていますが、実装からロジックを切り離すことができなかったため、幾つかの場所でコードの繰り返しが起きています。ここでは、各更新をその機能ごとに分類し、まとめて組み立てることができます。この様子を見てみましょう。

// mutators.js
export const closeModal = s => s.set('modalOpen', false)
export const stopLoading = s => s.set('loading', false)
export const updateLoan = loan => s => s.setIn(['loans', loan.id], Immutable.fromJS(loan))
export const setPaymentError = error => s => s.set('paymentError', error)
// utils.js
const applyFn = (state, fn) => fn(state)
export const pipe = (fns, state) =>
  state.withMutations(s => fns.reduce(applyFn, s))
// reducer.js
import * as mutate from './mutators'
export default function reducer(state=initialState, action) {
  switch(action.type) {
    case defs.MAKE_PAYMENT_SUCCESS:
      return pipe([
        mutate.closeModal,
        mutate.stopLoading,
        mutate.updateLoan(action.loan),
      ], state)
    case defs.MAKE_PAYMENT_FAILED:
      return pipe([
        mutate.closeModal,
        mutate.stopLoading,
        mutate.setPaymentError(action.error),
      ], state)
    // ...
  }
}

ここでpipe関数を使用して、withMutationsの呼び出しと、各更新関数の適用を処理するようにしました。これでreducerはずっと読みやすくなります。また、このreducerを試すのも簡単になります。アクションの時と同じように、実際には関数自身の逆関数です。

describe('App Reducer', () => {
  it('MAKE_PAYMENT_SUCCESS', () => {
    const state = Immutable.Map()
    const loan = Symbol()
    const mutations = [
      expect.spyOn('mutate', 'updateLoan').andCall(loan => s => s)
      expect.spyOn('mutate', 'closeModal').andCall(s => s)
      expect.spyOn('mutate', 'stopLoading').andCall(s => s)
    ]
    const result = reducer(state, actions.makePaymentSuccess(loan))
    expect(mutations[0]).toHaveBeenCalledWith(loan)
    mutations.slice(1).forEach(mutation => {
      expect(mutation).toHaveBeenCalled()
    })
    expect(result).toEqual(state)
  })
})

ここでも、この関数をテストする価値があるかどうかという問題は残っています。真の目的は、ミューテータがUIで期待されているような方法で状態を更新しているということです。そこで、それらに対して幾つかの単体テストを記述することができます。しかし、UIが他のフォーマットでの状態を期待しているなら、テストはあまり役には立ちません。ですから恐らく、FlowTypeあるいはTypeScriptのような静的タイプチェッカーを使用すれば、努力に見合うだけの価値があるかもしれません。これについては、また後日お話ししようと思います。

まとめ

Reduxを使えば、非常にクリーンな高性能で分かりやすいコードになるともいわれています。しかし、間違った使い方をしてしまうと、むしろ害になります。こういう類のソフトウェアパターンについてもっと知りたいと思われたら、この話をチェックすることをお勧めします。私はこの話で、良質のコードを書く方法について、ある程度の高水準概念の考え方の基礎固めをきっちりと行うことができました。もしあなたが仕事を探している最中で、ReactやReduxを使って働くことに興味があれば、採用情報のページをチェックするか、私宛にEメールをお送りください