Arantium Maestum

プログラミング、囲碁、読書の話題

Clojure Web Development勉強 - Reagent(その3)状態の管理

Reagentでは、Hiccup構文を使ったコンポーネント関数とReagent Atomを使って、複雑な状態を持つクライアントサイド・アプリケーションを作成できる。

前回の例ではコードをなるべく単純に留めるために、トップレベルでReagent Atomを定義し、各コンポーネントはそのAtomを直接参照していた。基本的なモデルは古き悪きグローバル変数である。

グローバル変数の弊害について多くを語る必要はさほどないとは思うが、一つすぐにぶつかる問題として、コンポーネント関数のモジュール性が著しく損なわれてしまうことが挙げられる。

前回のコードを見てみると:

(def clicks 
  (r/atom {:John 0 :James 0}))

(defn clicker [name-key]
  [:div
    [:button {:on-click #(swap! clicks update name-key inc)} name-key]
    [:p "Click count: " (get @clicks name-key)]])

(defn my-app []
  [:div
    [clicker :John]
    [clicker :James]])

clickerコンポーネント関数がname-keyだけを引数にしていて、clicks atomはトップレベル定義のものを直接参照している。

clickerコンポーネントがclicksという名前に依存しているというのも、clickerの再利用性を低くしコード全体の変更をめんどくさくする要因となり得る。が、まあそれはそれなりのエディタを使っていればなんとか・・・

グローバルなatomのせいでテスト性が低下しているのは痛い。

そして何より、clicks(というかアプリケーションのグローバル・ステートatom)の構造とコンポーネントのロジックが分離できていないのが最大のポイントとなる。どういうことかというと、例えば上記のコードでmy-appとしている部分を別のコンポーネントとして切り出して、ステートatom

(def app-state 
  (r/atom {:names {:John "John Doe" :James "James Bond"}
           :clicks {:John 0 :James 0}))

のように変えたとすると、最末端の枝部分になるclicker関数のコードを変更しなくてはいけなくなる。そしてその後も変更が加わる事に、すべてのコンポーネントをアップデートする羽目になる。

こういう状況を回避するためにうまくreagent atomを使うパターンがいくつか存在している。

引数に値(状態の表示のみ)

コンポーネントが値を表示するだけなら非常に簡単。atomをまったく使わずに、引数にコンポーネントの状態を渡してやってそれで終わり。

(def app-state 
  (r/atom {:names {:John "John Doe" :James "James Bond"}
           :clicks {:John 0 :James 0}))

(defn namer [name-string]
  [:h1 name-string])

(defn names [name-state]
  [:div
    [namer (get name-state :John)]
    [namer (get name-state :James)]])

(defn my-app []
  [:div
    [names (get @app-state :names)]])

これだとnamer関数はデータ構造の知識はまったく入っておらず、単に文字列を受け取って表示しているだけ。names関数は自分が担当する名前の入ったimmutableなmapを引数にしていて、それ以外のapp-stateについての情報はまったくない。app-stateの構造がどう変わろうが、namesとnamerが必要な部分がちゃんとあれば、これら枝部分の関数は変更なしで済む。

ただし、これはデータを単純に受けとって表示するようなコンポーネントでしか使えない手だ。clickerのように、コンポーネントに対するユーザの行動から状態を更新していくようなコンポーネントを書く場合、状態atomになんらかのアクセスが必要になる。

Cursor (状態の表示と変更)

そのためのメカニズムがcursorである。これはもともとはOmにあったのをReagentにも導入したもののようだ。

状態atomを細分化し、参照・変更どちらも可能な新しいatom(的なもの)を作り出してくれる。実際にはatomとはちょっと違うのだが、同じAPIで使えて、元のatomと参照・変更の双方向でつながっている。

(def my-data (r/atom {:a 0}))
(def a (r/cursor my-data [:a]))
(js/console.log @a)

(swap! my-data update :a inc)
(js/console.log @a)

(swap! a inc)
(js/console.log @my-data)

これでブラウザのログに0と1と{:a 2}が表示される。my-dataの変更が自動的にaの値を更新し、aの変更が自動的にmy-dataの値を更新していることがわかる。

これをreagent的に使ってみると:

(def app-state 
  (r/atom {:names {:John "John Doe" :James "James Bond"}
           :clicks {:John 0 :James 0}}))

(defn namer [name-string]
  [:h1 name-string])

(defn clicker [click-atom]
  [:div
    [:button {:on-click #(swap! click-atom inc)} "Click me"]
    [:p "Click count: " @click-atom]])

(defn name-and-click [name-key names-map clicks-atom]
  [:div
    [namer (get names-map name-key)]
    [clicker (r/cursor clicks-atom [name-key])]])

(defn my-app []
  [:div
    [name-and-click :John (get @app-state :names) (r/cursor app-state [:clicks])]
    [name-and-click :James (get @app-state :names) (r/cursor app-state [:clicks])]])

(普通はmy-app内がごちゃごちゃしているのをletで変数束縛してスッキリさせるが、reagentコンポーネント内でのletは少し特殊でまだ説明していないので割愛)

上記のコードではname-and-clickコンポーネント

  • immutableなnames-map
  • cursorで細分化されたclicks-atom
  • 両方のkeyとなるname-key

を渡していて、そこからさらに細分化して下位コンポーネントのclickerにclicks-atomをcursorを使って細分化したclick-atomを、そしてnamerには単なる文字列を渡している。

これで下位のコンポーネントからは全体のデータ構造を隠蔽して必要な部分だけの参照・変更ができる。

コンポーネントレベルでの状態保持

最後に、トップレベルではなく、コンポーネントが自分の状態をClosureを使って保持することも可能だ。

clickのデータをClosureで保持するとしたらこのようになる:

(defn clicker [name-string]
  (let [clicks (r/atom 0)]
    (fn [name-string]
      [:div
        [:button {:on-click #(swap! clicks inc)} name-string]
        [:p "Click count: " @clicks]])))

(defn my-app []
  [:div
    [clicker "John"]
    [clicker "James"]])

注目するべきポイントは二つ。

  • letの中にfnが入っている
  • コードが非常に簡単になっている

まず第一のポイントについてはRe-FrameのComponent Tutorialに詳しい。

外側のclicker関数は「コンポーネントが作成された」一回のみ実行され、clicksのr/atomを作成し、そしてそのclicksがローカルクロージャで束縛されている内側の無名関数を返す。その後clicksが変更され、レンダし直す必要があるたびに実行されるのはこの内側の匿名関数である。

この内側の匿名関数を作らずに

(defn clicker [name-string]
  (let [clicks (r/atom 0)]
    [:div
      [:button {:on-click #(swap! clicks inc)} name-string]
      [:p "Click count: " @clicks]]))

のようにしてしまうと、ボタンをクリックしても数字が変わらない。なぜならclicks r/atomが変更されるたび、clickerが実行され、0の値の新しいclicks r/atomが作成されてその値が表示されるからである。

第二のポイントは実に甘美な誘惑で、状態をコンポーネント自身に管理させると非常に綺麗かつ簡潔に書ける。

ただし、これには大きな陥穽が二つある。

外部から参照も変更もできない

例えばアプリケーションの他の部分でこのクリック数を使おうと思ってもそのデータはローカルクロージャに隠蔽されていてアクセスできない。なのでこの手法は本当にそのコンポーネントだけで完結するデータでしか使えない

「アプリケーション全体の状態がデータによって一義的に決まる」という原則から外れてしまう

もし本当にコンポーネント固有のデータだったとしても、ローカルクロージャに入ったデータは「外部から変更もできない」ので、その状態を再現することが難しくなる。ReactとClojureScriptの大きな思想的売りである「データによる一義的に決定される関数型・宣言的プログラミング的フロントエンド開発」から離れてしまう。これは実際のところコストは大きい。

以上の二点を踏まえて、この手法は理想としては「マウスが現在ボタンの上にあるかどうかなど、アプリケーションの状態というにはささやかすぎることを保持するのには利用するべき」とされることが多い。ただし実際にはローカルステートは非常に多用される。楽だから。

今回は「Reagentでアプリケーションの状態をどう管理するのか」について簡単に見てきた。

次は簡単なReactのサンプルコードをReagentに移植してみたい。