フロントエンドもやったりするエンジニアの福山です。AngularJS→React→Angularときていて、最近はすっかりAngular+ngrxでの開発が中心になっています。ReduxとRxが好きなので、ngrxはもう最高ですね。すでに普及しているような気もしますが、日本の記事はまだまだ少ない気がするので、少しずつ紹介できたらと思います。とは言うものの、いきなりngrxから始めるのもなんなので、今回はフレームワークなしでReduxを使ってみて理解を深めてみましょう。

Redux

ソースは本家の counter vanilla を参考に見ていきます。

まずはReduxといったらよく出てくるのが下図みたいなダイアグラムだと思います。

Redux Diagram

WebAPIの話はいったん置いておくとして、注目すべきは View, Action, ReducerStore で、データが 1方向 にしか流れないということです。「それの何がうれしいの?」と言われると、このcounterアプリだけみたら「(ほぼ)何もうれしくありません」。ちょっとReduxがわかった気になれるところだけうれしい点かもしれません。今のところは「へー。」で大丈夫です。それでは定番のcounterアプリの実装を見てみましょう。

counterアプリ

counterアプリが持っている機能は下のとおりです。

Counter Gif

  • インクリメント、デクリメントする
  • 現在の値が奇数であればインクリメントする
  • クリックしてから時間差で非同期にインクリメントする

これをReduxと生のJavaScriptだけで実現したコードが下のようになります。

<!DOCTYPE html>
<html>
  <head>
    <title>Reduxの基本</title>
    <script src="https://unpkg.com/redux@latest/dist/redux.min.js"></script>
  </head>
  <body>
    <div>
      <p>
        クリック: <span id="value">0</span> 回
        <button id="increment">+</button>
        <button id="decrement">-</button>
        <button id="incrementIfOdd">奇数ならインクリメント</button>
        <button id="incrementAsync">非同期インクリメント</button>
      </p>
    </div>
    <script>
      function counter(state, action) {
        if (typeof state === 'undefined') {
          // 初期値は0にする
          return 0
        }
        switch (action.type) {
          case 'INCREMENT':
            // 1加算
            return state + 1
          case 'DECREMENT':
            // 1減算
            return state - 1
          default:
            return state
        }
      }
      var store = Redux.createStore(counter)
      
      // 表示用の要素を保持
      var valueEl = document.getElementById('value')
      function render() {
        // storeの最新状態をHTMLに反映
        valueEl.innerHTML = store.getState().toString()
      }
      // 初回の0を描画
      render()
      
      // storeに変化があれば再描画する
      store.subscribe(render)
      
      // 以下はボタンのクリックのハンドラ
      document.getElementById('increment')
        .addEventListener('click', function () {
          store.dispatch({ type: 'INCREMENT' })
        })
      document.getElementById('decrement')
        .addEventListener('click', function () {
          store.dispatch({ type: 'DECREMENT' })
        })
      document.getElementById('incrementIfOdd')
        .addEventListener('click', function () {
          if (store.getState() % 2 !== 0) {
            // 奇数の場合だけインクリメント発火
            store.dispatch({ type: 'INCREMENT' })
          }
        })
      document.getElementById('incrementAsync')
        .addEventListener('click', function () {
          // 時間差(1秒)でインクリメントを発火
          setTimeout(function () {
            store.dispatch({ type: 'INCREMENT' })
          }, 1000)
        })
    </script>
  </body>
</html>

それでは順番に見ていきましょう。

初期状態

アプリの状態を管理している store
Redux.createStore(counter) となっている部分で初期化されています。この createStore の引数に入っている counter はJavaScriptの関数です。そしてその関数の型は (state, action) => state と表現することができて、文章で言うと 状態(state)とアクション(action)を引数で受け取って、状態(state)を戻り値としている関数 になります。この関数のことを Reducer と呼びます。Reduxではアプリの状態(state)は Reducerだけ が変化させます。それ以外の場所で state に手を加えてはいけません。
ReducerはただのJavaScriptの関数であり、かつ 参照透過な関数(同じ入力に対して必ず同じ出力となる関数) なのですごくテストもしやすい存在です。

と、話がそれてしまいましたが、要は counter 関数が store 初期化時に発火して、そのときに第一引数の state は(初期状態は何もないから) undefined なので、初期値 0 となるのです。

function counter(state, action) {
        if (typeof state === 'undefined') {
          // 初期値は0にする
          return 0
        }
...

それでは、 store の状態を今度は画面(view)に出したいのでどうするかというと、ここで render 関数が登場します。

// 表示用の要素を保持
var valueEl = document.getElementById('value')
function render() {
  // storeの最新状態をHTMLに反映
  valueEl.innerHTML = store.getState().toString()
}
// 初回の0を描画
render()

store.getState() を呼ぶことで、現在の状態(state)が取得できて(初期状態であれば 0 )、それを文字列に変換して要素の innerHTML に入れています。初回は定義したすぐあとに手動で render() を呼び出して 0 を描画しています。

値が変わるたびに手動で render を呼ぶのはしんどいので、 state が変わるたびに自動的に render が呼ばれてほしいですよね。そこで登場するのが store.subscribe(<関数>) です。これは、 store に変化があるたびに引数の関数を読んでくれる便利関数です。ここに render を差し込んでおけば、値が変わるたびに自動的に画面が再描画されるようになります。これが初期状態で 0 が描画されるところまでの流れで、図にすると↓のような感じです。

Init Flow

Action!

次に、ボタンクリックでどのように値が変化するのか見てみましょう。ここで登場するのが action です。 action の形は決まっていて、 どういったactionなのか という 型(type) の情報と、その action に対する 付加情報(payload) で構成されています。JSONっぽく表現すると

{
  type,
  payload
}

という感じです。今回のアプリでは payload は関係ないので登場しません。というのも 1を加算するのか(Increment)1を減算するのか(Decrement) という機能しかないので、 type だけわかれば何をすれば良いか明白です( payload が必要ない)。では +ボタン が押されたらどうなるかを例にとって見てみましょう。

document.getElementById('increment')
        .addEventListener('click', function () {
          store.dispatch({ type: 'INCREMENT' })
        })

+ボタン にクリックハンドラを定義して、クリックされるたびに store.dispatch({ type: 'INCREMENT' }) が呼ばれているのがわかります。ここで何が起きるかというと、 store が今の自分の状態( state ) と、発火した action を組み合わせて、 reducer (counter) に渡すのです。値をつめこんで counter を表すと↓のようになります。

function counter(state = 0, action = {type: 'INCREMENT'}) {
...
  switch (action.type) { // 'INCREMENT'なので
    case 'INCREMENT':
      // ここに入ってくるから
      // 0に1加算して1をreturnする
      return state + 1
    case 'DECREMENT':
      return state - 1
    default:
      return state
  }
}

これで stateINCREMENT アクションによって更新されて store が更新されるのがわかります。 ViewActionReducerStore ときて、それがまた View に描画されます。単一方向のデータフローが確立しているのがわかると思います。ベースの考えはこれだけで、これを応用していく(といっても癖のある部分はそれなりにある)ことで大規模なアプリも作っていけます。ビジネスロジック部分も基本的には参照透過なJavaScriptの関数で書けて、テストコードもフレームワーク(Angularなど)に依存せずに書けます。楽しいですよ!

今回のソースは以下Plunkerにて公開しています。
http://embed.plnkr.co/TFnfCDVkmUzMKC7lQwVQ/

© 2018. SuperSoftware Co., Ltd. All Rights Reserved.