Reduxストアの概念をRubyで再実装して理解する(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

なお、reducerは「レデューサー」「リデューサー」の2とおりのカタカナがあるようですが、ネットで多数派の「レデューサー」にしました。

Reduxストアの概念をRubyで再実装して理解する(翻訳)

Reduxは最近のJavaScriptアプリのステート管理ライブラリとして人気が高まっており、実際にはその中心にシンプルなデータストアがあるだけです。Reduxのストアは他と何が違うのでしょうか?データを保存する点では他のストアと同様ですが、Reduxのデータ変更は常に「アクション」を「レデューサー」と呼ばれるものにディスパッチすることで行い、サブスクライブしているリスナーに通知します。Reduxストアの話をするときには、「レデューサー」「アクション」「リスナー」の概念を理解しておく必要があります。

レデューサー(reducer)はRedux世界でよく知られた名前であり、ステートとアクションを引数として受け取り、新しいステートを返す純粋関数を指します。ある関数が「純粋」である場合、関数に副作用がないことを意味します。純粋関数は常に新しい値を返し、受け取った値(関数に渡した元のステート)を変更することは決してありません。よく例として使われるカウンタレデューサーを見てみることにしましょう。カウンタレデューサーは、カウントアップ(increment)やカウントダウン(decrement)に応答する方法を知っており、それ以外のデータを追加しません(Gist)。

counter_reducer = -> (state, action) {
  state ||= 0

  case action[:type]
  when 'increment'
    state += 1
  when 'decrement'
    state -= 1
  else
    state
  end
}

上のRubyコードがJavaScript版と唯一異なっている点は、関数定義でlambda(無名関数)を使っていることと、アクションの定義にRubyのハッシュ(JavaScriptのオブジェクトと対になります)を用いている点です。ステートは常に引数として渡されますが、デフォルト値(ゼロからカウントアップする)も与えています。これで新しいカウンタレデューサは、カウントアップとカウントダウンのアクションに応答し、カウンタの適切な新しい値を返すようになります。ここでもうひとつ注目したい重要な点は、カウンタレデューサーは指定のアクションへの応答方法については何の知識も持たず、元のステートを無変更のまま返していることです。ここが後で重要になってきます。

新しいカウンタは次のように使います。

counter.call(2, { type: 'increment' })
#=> 3
counter.call(nil, { type: 'decrement' })
#=> -1

レデューサーを見たことのない人には上のカウンタの例が冗長に見え、アクションがハッシュで定義されている理由が謎に思えるかもしれません。しかしアクションはこれよりもっと複雑になることもあり、他にも情報を伝える必要が生じる可能性もあります。アクションを定義する追加情報はハッシュ内で他のキーの下に保存されます。つまりReduxストアのアクションは実際には1個のオブジェクトなのです(技術的に言うと上のRubyコードではハッシュになっています)。

レデューサーとアクションを理解できたので、今度はリスナーを定義してみましょう。リスナーは、内部に保持しているのが単なる関数である点を除けばまさしく想像どおりです。リスナー関数は「純粋」である必要はなく、単にストア内でステートが変更されたときの挙動を定義します。

以下の典型的なリスナーもlambdaで定義してみました。

counter_listener = -> () { puts "I am counting numbers" }

ここでは文字列を標準出力に出力しているだけですが、元々のReduxのユースケースではおそらくDOMを更新することになるでしょう。

基本的な知識を一通り押さえることができ、カウンタレデューサーとリスナーもできあがったので、基本的なReduxStoreをRubyベースで実装する準備が整いました(Gist)。

class ReduxStore
  attr_reader :current_state

  def initialize(reducer)
    @reducer = reducer
    @listeners = []
    @current_state = nil
    dispatch({})
  end

  def dispatch(action)
    @current_state = @reducer.call(@current_state, action)
    @listeners.each { |l| l.call }
  end

  def subscribe(listener)
    @listeners.push(listener)
    ->{ @listeners.delete(listener) }
  end
end

新しいReduxStoreは、作成時に渡される任意のレデューサーを使って動作する、一般的なストアです。初期化プロセスの部分は、基本的にはJavaScriptのcreateStore()に相当します。

let createStore(reducer)

createStore()は、渡されたレデューサーに応じたストアを作成して返す関数です。1つのストアで使えるレデューサーは常に1つだけなので、カウンタレデューサーは自分のステートをカウンターのストアに保存します。アクションがディスパッチされると、サブスクライブしているすべてのリスナーに通知(呼び出し)されます。以下は新しいストアの利用例です。

my_counter_store = ReduxStore.new(counter_reducer)
my_counter_store.dispatch({type: 'increment'})

最初にカウンタストアを作成し、次に、ストアのcurrent_stateを変更するincrementアクションをディスパッチします。ステートが変更されたときに何らかの操作を実行するリスナーの使い方を見てみましょう。

my_counter_store.subscribe(counter_listener)
my_counter_store.dispatch({type: 'increment'})
#=> I am counting numbers
my_counter_store.dispatch({type: 'decrement'})
#=>I am counting numbers
puts "Counter is #{my_counter_store.current_state}"
#=>Counter is 3

わずか数行のコードでRedux的なストアが準備できました。しかしReduxストアについてまだ説明していなかったことがひとつあります。Reduxストアは本質的に「アプリの全ステートの保存に用いる」ものであり、1個のレデューサーから返される1個の値のためだけのものではありません。

「ちょっと待った!1つのストアは常に1つのレデューサーの上で動作するって最初に言ってたじゃないの: 今度は複数の値を保存するってどういうこと?」

ストアの挙動をまったく変更せずに、複数のレデューサーが渡すステートを1つのストアに同時に保存できます。そのためには、複数のレデューサーを1つのルート(app)レデューサーにまとめなければなりません。こうすることで複数のストアを保存し、すべてのレデューサーが渡す値を表すツリー状の構造を更新します。先ほど、レデューサーは自分が扱えないアクションを受け取ったときには渡された値を無変更のまま返すと申し上げたのを覚えていますでしょうか?これがまさしく、1つのアクションをルートストアにディスパッチすることで、ツリーのアップデートすべき値だけをアップデートし、それ以外のすべての値を安全に保つ仕組みです。

複数のレデューサーをまとめるクラスメソッドでReduxStoreを拡張してみましょう(Gist)。

class ReduxStore
  def self.combine_reducers(reducers)
    -> (state, action) {
      state ||= {}

      reducers.reduce({}) { |next_state, (key, reducer)|
        next_state[key] = reducer.call(state[key], action)
        next_state
      }
    }
  end
end

これはReduxのcombineReducers()関数に対応するもので、別の関数を返します。返される関数は、実際にステートとアクションをパラメータとして受け取って新しいステートを再度返すレデューサーです。このときだけ、ツリー全体に渡って動作します(Rubyではネストしたハッシュ、JavaScriptではオブジェクトになります)。

実際の動作を見るために、カウンタのレデューサーと組み合わせる別のレデューサーを定義してみましょう(Gist)。

todos_reducer = -> (state, action) {
  state ||= []

  case action[:type]
  when 'add'
    state.push(action[:todo])
  when 'remove'
    state.remove(action[:todo])
  else
    state
  end
}

新しいtodos_reducerにはTODOの項目リストが保存されます。todoという追加パラメータを受け取ることで、アクションをオブジェクトの概念として利用していることにご注目ください。それでは2つのレデューサーを1つにまとめるroot_reducerを作成してみましょう。

root_reducer = ReduxStore.combine_reducers({ counter: counter_reducer, todos: todos_reducer })

レデューサーを再び1つにできたので、アプリのストアを作成できるようになりました。

app_store = ReduxStore.new(root_reducer)
app_store.dispatch({type: 'increment'})
app_store.dispatch({type: 'add', todo: 'Buy milk'})
app_store.dispatch({type: 'increment'})
app_store.current_state
# => {:counter=>2, :todos=>["Buy milk"]}

ルートのレデューサーを作成したことで、1つのルートハッシュに2つのレデューサーのステートを両方とも保存できました。そしてこれが、複数のレデューサーを実装してアクションやリスナーとまとめることで、ツリー状の構造を持つ唯一のストアをビルドする方法です。

私の記事が、Reduxストアとは一体何かについて関心のお持ちの方がReduxの背後のコンセプトを理解する助けになることを願っています。

関連記事

JavaScript: Reduxが必要なとき/不要なとき(翻訳)

[Ruby]クロージャーを使ってブロックを1回だけ実行する

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ