Reactを始めてみたはいいけど、最初の設計段階で状態管理することを考慮してライブラリ導入しなきゃいかんのでしょ?
と思っていたんですが、意外とそうでもなくて、アプリの骨子をReact標準のPropsとStateだけで組んでから、複雑度が増してきた段階でReduxやMobXを導入しても遅くないんだなぁというのが最近ようやく分かったので、道のりをまとめておこうと思います。
また、ReduxとMobXを両方試してみての感想も後半に書いてみました。
なお、Reduxについての言及が多めですが、これは「Reduxは5時間ぐらい試して上手く行かなかったけど、MobXは1時間で上手くいくようになった」というだけの話ですので、特に意図はないです。
状態管理にライブラリは必須ではない
これは僕も勘違いしていたのですが、素のReactのデータ管理機構だけで事足りる場合というのは意外と多いです。
- Stateで持つデータの数を、最も最上位のコンポーネントを除いて1コンポーネント辺り1,2個程度にする
- なるべくStateless Functional Components(Stateを持たないコンポーネント)にする
- Stateから次のStateを作るという構造は意識しておく(つまりなんちゃってFlux)
この辺を守ってさえいれば、コンポーネントの階層が深くなってもある程度の規模まではなんとかなります。
まあ、「データフローを一方通行にする」というFluxの思想のおかげで必要以上にコードが複雑にならないので、その利点が壊れない限りは余計なものを導入する必要はないということです。
ではいつ導入すればいいのか
いつでも良いと思います。
ただ、自分の場合はReactを使ったコードを1から設計したことがなく、アプリの要件からデータ構造を設計するのがまだ良くわかってないということも相まって、前述のように最初はシンプルに組んでからボトムアップでやっていくのが上手くハマりました。
実際にやったこと
↑は「複数の画像からGIF画像を生成する」というツールですが、以前このコードは、前述のように素のReactのPropsとStateによってデータを管理していましたが、機能追加やリファクタリングに伴ってデータ構造が複雑化したので、ライブラリを使った状態管理に移行しました。
このコードでは以下のような状態を扱っています。
- 画像の一覧
- GIF画像の高さ
- 幅
- 1枚当たりのディレイ数
- リピート回数
- base64化したGIF画像のデータ
MobX化する前、これらのデータのうち
- 画像の一覧
- GIF画像の高さ
- 幅
- 1枚当たりのディレイ数
- リピート回数
以上の5つを親のStateとして管理し、子のGIF画像を表示するコンポーネント (GifViewerComponentという名前のComponentがそれに当たります) にPropsとして渡していました。
そして、親から渡されたPropsが更新されると、GifViewerComponentはそれらのデータをGIFEncoder.jsというライブラリに渡してGIF画像を生成し、base64エンコードして自分のStateとして保持します。
まとめると、GifViewerComponentの要件は「画像データと幅や高さなどからGIF画像を表示する」ということになります。
さて、このコードの状態管理をライブラリに移行しようとしたモチベーションですが、以下の様なことを考えていました。
- RootComponentに子コンポーネントの処理を置くのをやめたい
- 画像データや各種パラメータをlocalStorageに保存出来るようにしたい。
- 画像データを並び替えて、アニメーションさせる順番を入れ替えられるようにしたい。
下2つに関しては正直なところどうとでもなると思いましたが、1つ目を解決するにあたって、RootComponentの役割をGifViewerComponentに移行したくなりました。
すると、GifViewerComponentは以前Propsとして受け取っていた値をStateで管理する必要が出てきます。
これによって、GifViewerComponentが持つべきStateの数が以下の6つになります。
- 画像の一覧
- GIF画像の高さ
- 幅
- 1枚当たりのディレイ数
- リピート回数
- base64化したGIF画像データ
以前はPropsが変更されたタイミングで更新処理を行えばよかったのが、この変更をきっかけに、GifViewerComponent自らが値の更新監視と状態遷移を行う必要が出てきました。
これをハンドラとsetStateだけでやるのは酷だなと感じ、状態管理ライブラリを導入することにしました。
Redux と 挫折
さて、ここからある意味本題なのですが、最初はReduxの導入を試していました。
ReduxはFluxアーキテクチャの最もよく使われている実装であり、実際僕のタイムライン上でもよく話題に上がるライブラリだったので、その流れでということで使ってみました。
ところが、すぐに問題が生じました。
それは 「非同期処理との相性がとても悪い」 ということです。
GIFEncoder.jsは画像を扱う関係上それなりに処理の完了まで時間のかかるライブラリなので、Promiseで処理結果を返していました。
ところが、Reduxは工夫しなければ非同期処理を扱えないため、「何をStateにするか」を一度考え直す必要が出てきたのです。
Redux (が存在するコードベース) で非同期処理を実現するには以下のアプローチがあります。
- Componentに非同期処理を書き、Actionのpayloadにその処理結果のみを渡す
- redux-thunk を導入して、Actionの中で非同期処理を行う
- redux-saga、redux-promiseを調べる(これらは試す前にReduxをやめた)
1 について
これはつまり「Componentに更新処理の一部が露出する」ということです。
// Componentのコード // 前後は省略 onClickUpdate = () => { // パラメータ更新用のActionを最初にdispatch store.dispatch(updateParam(param)); // 非同期のライブラリ関数を同じパラメータで呼ぶ GifFactory.createData(param).then(result => { store.dispatch(updateImage(result)); }); }
Redux的にはこれが最もシンプルな実装らしいですが、見ての通りコードベースからStateの更新処理を一掃したくてライブラリを導入しているのに、更新処理の一部がComponent側に見えてしまっているのでは本末転倒です。
それに、同じタイミングで更新されて欲しいStateなのに、パラメータ更新用と結果格納用のActionに分かれてしまっているのがなんとなく気に入りませんでした。(もしかしたらなんとかなるのかもしれないが)
よって却下。
2 について
通常、ReduxのActionは次のような関数を定義します。
export function updateImage(payload) { // typeとpayloadを持つプレーンなオブジェクトを返す。 // 戻り値をオブジェクトにしなければならないので、ここに非同期処理を書くことは出来ない。 return { type: "GifViewer.UPDATE_IMAGE", payload } }
redux-thunk (middleware と呼ぶらしいです) を使う場合、Actionは以下のように定義します。
export function updateImage(param) { // 「dispatchハンドラへデータを適用する関数」を返す return dispatch => { // 非同期処理が終わったら dispatch に処理結果を渡すように書けるが… GifFactory.createData(param).then(result => { dispatch({ type: "GifViewer.UPDATE_IMAGE", payload: result }); }); } }
率直に え?全部こういうふうに書くの?? と思いました。
流石にいくらComponentがシンプルになるからと言って、こんなコードをいちいち書くほど僕はドMではないので、これも諦めました。
と、こんな感じでどれもいまいち回りくどく感じてしまい、ピンと来ませんでした。
というか、そもそも「ライブラリに合わせてアプリケーションのデータ構造を変えるのは本末転倒ではないか?」というふうにも思ったので、ReduxをやめてMobXを使うことにしました。
MobXのよかったところ
Reduxに比べて圧倒的にコードがシンプルです。ボイラープレートがとにかく少ないのが魅力でした。
他にも
- Storeに対してデコレータを付けてViewでactionを実行するように書けばそれだけで完成する
- 非同期処理をすんなりと書ける
- データ更新のきっかけになるハンドラ(MobXでもactionと呼びます)もStoreの中に書ける
という、触ってすぐに気がつく利点が数多くありました。
導入から実装完了までがとてもスピーディなのもMobXの魅力だと思います。Reduxは本当にボイラープレートが多いし用意するファイルも多くて心が折れてしまった…。
軽く紹介
MobXはStoreにデコレータを付けていろいろなものを表現します。
// Storeのコード // コード例なのでプロパティ等はめっちゃ省略している export default class GifViwerStore { // 変更を監視したいプロパティに @observable デコレータを付ける @observable base64: string = ""; // 他のプロパティに依存したデータを返したい場合は @computed を使う @computed get dataUrl() { return `data:image/gif;base64,${this.base64}`; } // 非同期処理をするAction @action.bound async updateImage(param: Param) { // Reduxで悩んでいたのがアホみたいに超シンプルに非同期処理が書ける this.base64 = await GifFactory.createData(param); } }
RootComponentでいろいろStoreを適用したりといったコードはReduxもMobXもあまりやることは変わらないのでそちらは省略しますが、こんな感じでとてもシンプルにStateの変更を表現出来ます。
実際この辺りがどうなっているかどうかは、先程も貼ったリポジトリの
src/logic/store/GifViewerStore.ts
や
src/component/GifViewer/GifViewerComponent.tsx
を辺りを見ていただくのが良いと思います。
おわり
そんなわけで、MobXをしばらく使ってみようと思います。
Storeの見た目的には副作用を生むコードになっているので、Fluxの思想から見るとちょっとウッとならなくもないですが、考えてみれば値の変更をするところでは素直に値が代入されているほうがコードの見た目はわかりやすいのではないかと考えています。
使い込むうちに気がついたことがあればまたまとめられればいいなと思います。