reactjs
redux
redux-saga

Reduxでコンポーネントを再利用する

More than 2 years have passed since last update.

Reduxはとりあえず使えるようになった後の情報が少ないように感じています。よく出回っているサンプルコードは「Real World 〜」のような名前がついていたとしても、あくまで雰囲気を味わうために用意されたものに毛が生えた程度で、現実に起こる問題に対する回答や指針を示しているわけではありません。業務で使うことを検討するのであれば、プロダクトの成長と共にどうやってスケールしていくかイメージできないと導入に踏み切れないですよね。本稿ではサンプルコードより大きな規模で開発していくために、Reduxにおけるコンポーネントの再利用について紹介します。

実現したいこと

コンポーネントの再利用によってどのようなことを実現したいのかイメージしてもらうため、馴染みのあるアプリケーションの機能を具体例として挙げてみます。

  • Gmailで名前にマウスオーバーしたときに出るプロフィール情報
    • プロフィール画像の表示
    • メールの作成、Hangoutの開始
    • メールの検索
    • Google+の所属サークルの表示、追加操作
  • SoundCloudのどこでも使える音楽プレーヤ
    • 独立したアプリのように見えるが、メイン画面と連動する
    • 別タブで再生を開始すると、他のタブの再生が停止する
  • Facebookのどの画面にいても使えるチャット機能
    • 独立したアプリと言えるレベルだけど、メイン画面とも連携する
  • GitHubの通知機能
    • どの画面にいてもリアルタイムに通知が来る
    • PRとかIssueのコメント機能もリアルタイム

どれか1つくらいはそのアプリケーションで再利用されているコンポーネントのイメージがつかめたのではないでしょうか。ただ、コンポーネントという用語はあまりにも大雑把なので、まずはReduxにおけるコンポーネントについて整理してみます。

コンポーネントの種類と役割

アプリケーションは様々なコンポーネントを組み合わせることで構築されます。Flux/Reduxにおいてもそれは同じですが、役割や粒度によっていくつかに分けることができます。ここでは次の3つについて考えてみます。

  • Providerコンポーネント
  • Containerコンポーネント
  • Presentationalコンポーネント

同列に並べてしまいましたが、Providerコンポーネントだけは react-redux が提供する実在するReactコンポーネントです。ContainerコンポーネントとPresentationalコンポーネントはカテゴリーの名前で、そういった名前のコンポーネントは存在していません。Reactコンポーネントはこの2つのカテゴリのどちらかに分類できるので、厳密に言うならばProviderコンポーネントはContainerコンポーネントに含まれることになります。

サンプルコードによくある構成だと次のような階層構造になります。

redux-arch-01.png

大まかな関係がわかったところで個々のコンポーネントについて説明していきます。

Providerコンポーネント

Providerコンポーネントは唯一Storeを持つことを許された存在で、Contextを使ってStateやdispatch関数を配下のContainerコンポーネントで利用可能にします。Reduxでは大きな1枚のStateでアプリケーションの状態管理をするため、Providerコンポーネントはアプリケーションに1つだけ含まれるようにします。ページ内で完全に独立したアプリケーションが2つ動いているといった状況でもない限り、単純にデバッグしにくくなるので複数のProviderコンポーネントを設置することは避けるべきです。

Containerコンポーネント

Reduxにおいては connect 関数でReactコンポーネントをラッピングしたものがContainerコンポーネントになります。 Connected Component とも呼びます。Reactではもう少し抽象的に「親コンポーネントがStateを持つ」みたいな言い方になっていますね。ReduxはそれにContainerコンポーネントという名前を付けてあげただけです。が、Stateを持ちません。どういうことかというと、Stateは持っていないけど、子コンポーネントにデータを提供するという役割はきちんと果たしているんです。

雑な流れとしては次のような感じです。

  • 素のReactだとStateが各コンポーネントに分散して管理が大変
  • だったらStateを1つに集約しよう(Redux)
  • Stateを引っこ抜かれた親コンポーネントにconnectしてStateをPropsとして注入する

react-reduxが提供する connect 関数は前提知識なしに公式のドキュメントを読んでも、何を言っているのかさっぱり理解できない初見殺しなんですが、主な役割を理解するのは簡単です。

Connected Component #1.png

connectしたとき、内部処理的にはStoreで発生するStateの変更通知を受け取れるようにContainerコンポーネント(図中ではConnected Component)がイベントの購読を開始します。変更通知を受け取ったContainerコンポーネントは巨大なStateオブジェクトから、ラッピングしているReactコンポーネント(図中ではOriginal Component)が必要としているデータだけを「選択(select)」してPropsとして渡します。そのためconnectするときに渡す関数はセレクタ関数と呼ばれています。サンプルコードだとAPIドキュメントでの引数名にもとづいて mapStateToProps という名前になっていることが多いです。

渡されたPropsには dispatch という名前の関数がこっそり含まれていて、これはContainerコンポーネントがActionをStoreに投げるために呼び出す大事な関数です。FluxアーキテクチャではActionを投げてStateを変化させるのでこれがないとアプリケーションはまったく動きません。

以上のように、ContainerコンポーネントはProviderコンポーネントよりはるかに大きな役割と自由度を持つコンポーネントと言えます。

Presentationalコンポーネント

Presentationalコンポーネントは一見すると単純で理解しやすいですが、アプリケーションを健全にスケールさせるためには注意深く設計する必要があります。役割はPropsのデータにもとづいてDOMをレンダリングして、ユーザーからのイベントを受け取ったらPropsで受け取ったコールバック関数を呼び出すことです。裏を返せばProps経由で何も与えられていないPresentationalコンポーネントは、常に同じDOMを描画するべきですし、Stateの変更を一切トリガーできないことになります。このように「ピュア」であることがPresentationalコンポーネントの特徴です。

すべてのPresentationalコンポーネントは必ずContainerコンポーネントから必要なもの(データとコールバック関数)を受け取らなければなりません。Presentationalコンポーネントはネストして構築するのが基本ですから、何層も下にある末端のPresentationalコンポーネントのために使いもしないPropsを上から下に流していくわけです。これが俗に言う「Propsのバケツリレー」というやつですね。Containerコンポーネントが目に見えるDOM要素を直接レンダリングすることはないため、実質的にすべてのデータ表示とユーザーからのあらゆる入力イベントはPresentationalコンポーネントが受け持っています。つまり多かれ少なかれPropsのバケツリレーは避けられない運命にあると言えます。

だからといって安易にconnectしてはいけません!

connectするとdispatchが使えるようになり、さらにStateを丸ごと取得できてしまうのでバケツリレーから完全に解放されます。しかし、その代償はあまりにも大きいんです。なぜかというと

connectすること = Reduxへの依存

だからです。dispatchを使えるということはActionを投げますね。ActionはAction Creatorを使って生成します。投げたActionはReducerで処理されますね。もしかしたらMiddlewareが組み込まれているかもしれない。Stateを丸ごと取得できるということはセレクタ関数が必要です。あれ、だけどそもそもセレクタ関数ってStateを統合したから必要になったんだよね・・・。

ほら、Reduxにベッタリになる

Reduxを想定せずにContainerコンポーネントを作ることは困難であるのは明らかです。

これは何もReduxに限った話ではありません。開発者に受け入れられているFlux実装はたいてい「コミュニティ指向」であり、それがいつまで現役なのか誰にもわかりません。いずれより洗練されたFlux実装が登場して置き換わっていくでしょう。

そんな状況で取れる行動は1つで「いつでも交換可能にしておくこと」ではないでしょうか。つまり、Presentationalコンポーネントを極端に「受け身」にしておくのはどれだけFlux実装が変わっても、もしくはFluxの概念が消えても、アプリケーションの大部分を占めるであろうPresentationalコンポーネントは使い続けることができるわけです。

Provider < Container <<<< 超えられない壁 <<<< Presentational

なぜContainerコンポーネントとPresentationalコンポーネントというカテゴリ分けを意識してアプリケーションを組むのか、その意義について理解してもらえたのではないでしょうか。Reduxに依存しないということ以外にも、当然ですがテストもしやすくなります。Flux実装に依存しないのであればコードの寿命を延ばすことができて、書いたテストが無駄にならずに済みますね。

再利用可能なコンポーネント

ようやく本題です。冒頭で示した再利用可能なコンポーネントの具体例から、実現したい特徴を抽出すると以下のようになります。

  • 様々なアプリケーションに組み込んで使える
  • 発生したイベントを自分自身で処理できる
  • 通信処理などの非同期処理も扱える
  • 必要に応じてアプリケーションと連携できる

これらの実現に共通して必要で、かつ中心となるものは Containerコンポーネント です。ついさっき安易にContainerコンポーネントを増やすな!と言ったばかりですが、設計というものはいつだってトレードオフです。得られるメリットとデメリットをしっかり天秤にかけましょう。Containerコンポーネントにすることで上で挙げた要件のうち、3つ目以外(と4つ目の半分くらい)が実現できてしまいます。connect 関数すごい。恐ろしい。Containerコンポーネントを導入した場合のコンポーネントの階層構造は次のようになります。

redux-arch-02.png

さて、ここからそれぞれの要件について細かく見ていきます。説明の流れの都合上、要件の順番を入れ替えています。

必要に応じてアプリケーションと連携できる

アプリケーションとの連携には2つの方向があります。1つはアプリケーションからコンポーネント、もう1つはコンポーネントからアプリケーションです。まずは簡単な前者から考えてみます。

ContainerコンポーネントといえどもReactコンポーネントなので、セレクタ関数で注入されるPropsとは別に外部からPropsを受け取ることができます。ただし、最終的にはすべてのPropsが1つにマージされて渡されるのでコンフリクトには注意してください。マージ対象となるPropsにはどのようなものがあるのか、どのPropsが優先されるのか、デフォルトのマージ動作を変えたい場合はどうするのかなど詳細については react-redux公式ドキュメントを参照してください。図にすると次のような感じです。

Connected Component #2.png

アプリケーションからコンポーネントの機能を使うにはPropsを直接渡すか、コンポーネントが提供しているActionを投げることで可能です。さらにアプリケーションはコンポーネントのStateも保持しているため、コンポーネントのStateに応じた処理を書くこともできます。アプリケーションはコンポーネントを使う立場なのでコンポーネントについて知っていても問題ありません。

一方でコンポーネントからアプリケーションの連携は本当に必要かどうか考えて下さい。コンポーネントはconnectされているため、アプリケーションとほぼ同等のことができてしまいます。だからといってむやみにアプリケーション固有のActionやStateに依存すると 再利用性が著しく低下する 可能性があります。つまり、コンポーネントがアプリケーションについてよく知りすぎているという状況はまずいのです。そのため、連携するとしてもそのコンポーネントを利用するアプリケーションで共通のActionやStateに限定しておくと安全です。[変更ここから 2016/04/21] 極端な例ではありますが、Reduxが初期化時に発行するActionであれば問題は起きにくいです。 ただし、Reduxの @@INIT Actionを拾って処理することはHot Reloadingを壊すのでアンチパターンとのことです。詳しくはこのIssueをご覧ください。[変更ここまで]

発生したイベントを自分自身で処理できる

Containerコンポーネントは下位コンポーネントから受け取ったユーザーの入力イベントをたらい回しにせず、dispatch 関数を呼び出すことでActionを投げて処理できます。さて、ここで気になるのは投げられたActionを誰が(どのReducerが)処理するのか、そして処理された結果としてのStateは誰が管理するのかです。逆算して考えればReduxは単一のStateしか持っていないため、ContainerコンポーネントのStateはアプリケーションが管理することになります。専用のStateがあるということは専用のReducer、そして専用のActionが必要です。

Containerコンポーネントを動作させるためにはさまざまな要素をアプリケーションに組み込む(というか織り込んでいく)必要がありますが、恐れることはありません。ReduxではReducerがいくつあってもcombineReducerで簡単に結合可能ですし、Reducerは自分の管理するState以外に興味が無い(というより基本的にいじくれない)ので、特にコンフリクトを起こしたりしません。何食わぬ顔でアプリケーション側のReducerと一緒にcombineReducerに渡してしまいましょう。Reducerを提供するともれなく初期状態もついてきます。なのでアプリケーション側はそこについても気にする必要はありません。コンポーネント側も同様で、基本的にはどういったアプリケーションで動いているのか気にすることなく開発することができます。

ここまでで基本的な動作をする再利用可能なコンポーネントは作成できます。目的とするコンポーネントの複雑さによってはここまでで十分の可能性もあります。

通信処理などの非同期処理も扱える

提供する特徴の中でもっとも複雑そうな通信処理にはどのようにして扱うべきでしょうか。そもそも通信処理のような非同期処理をReduxでどう扱うのがよいのかは人それぞれで、私自身もいくつかの方法を組み合わせているのが現状です。焦点となるのは どこで処理するか なんですが、パッと思いつくのは以下になります。

  • Containerコンポーネントで処理する
  • Action Creatorでredux-thunkを使う
  • Middlewareを使う
  • redux-sagaを使う ←NEW!

1つ目はさすがにありえないので却下。2つ目はやっている人もいるかと思いますが、「読込中」などの表示のために状況をStateとして持つ必要がでてくるとどんどん複雑化してメンテナンスしにくくなります。そのため3つ目のMiddlewareで処理することで膨れ上がるコードを小さく保つことができます。MiddlewareもReducerと同様に複数あってもapplyMiddleware関数を使ってまとめることができるので、最小限の処理だけを書いたMiddlewareを提供することで分離可能です。しかし素のMiddlewareというのはいろいろと扱いづらい側面があるのと、モックやスタブでガチガチにしないとテストを書けないため、できる限り redux-saga を使って対処します。

大雑把に説明するとredux-sagaというのは、koaで使用されているcoのように非同期処理を同期処理のように書き下すことができる世界をReduxに持ち込むMiddlewareです。これによって通信処理だけでなくあらゆる非同期処理を、専用のスレッドを立ち上げるようなイメージのコードとして表現することができます。

例えば次の showTooltip 関数(redux-sagaでは "Saga" と呼ばれる )は take 関数でひたすら指定した SHOW_PROFILE Actionを待ち続けて、それが来たら中身を取り出してツールチップを表示するために SHOW Actionを put 関数に渡すことでStoreにdispatchしています。それが終わったらwhileループで戻って、また SHOW_PROFILE Actionを待ち続けます。

function* showTooltip() {
  while (true) {
    const { payload: { el } } = yield take(actions.SHOW_PROFILE);
    yield put(tooltip.show({ name: 'popover-profile', origin: el }));
  }
}

この showTooltip Sagaを立ち上げるのには fork 関数を使います。redux-sagaはMiddlewareの一種なので初期化はStoreの作成時に行われます。そのとき、redux-sagaには最初に呼び出してもらう初期化用のSagaを渡します。例えば以下の様なコードです。

export default function* rootSaga() {
  yield fork(showTooltip);
  yield fork(saga1);
  yield fork(saga2);
}

rootSaga では fork 関数を使っているため指定したSagaを起動したあと、終了をまたずにすぐに戻ってきて次の行に進みます。まさにプロセスのforkに似たフローです。従って、上記3つのSagaを起動して初期化Sagaは終了します。

これらの処理は本質的にはMiddlewareでできることです。しかし様々なことを考慮しなければならないMiddlewareに比べて心配事が少ないですし、1つのことに集中できるのでバグの温床にもなりにくいです。そしてなにより複雑なモックやスタブも不要です。後日redux-sagaの解説記事を書こうと思いますが、なぜ不要になるのか今すぐ知りたい方は公式ドキュメントをご覧ください。ちなみにちょっと前に書いた「redux-sagaでズンドコキヨシ」は理解の助けになるかもしれません。混乱させるだけな可能性もありますが・・・。

さて、このSagaもアプリケーション側のSagaに簡単に組み込みできます。これでContainerコンポーネントが独立して通信処理を行うことが可能になります。

様々なアプリケーションに組み込んで使える

connectして、専用のReducerとSagaを提供することで要件を満たすことができました。以上が様々なアプリケーション間で共有可能なコンポーネントの構成になります。

全体像

最後に全体の構成を示します。SagaはMiddlewareの一種なので図中ではMiddlewaresの部分に存在します。

Redux-saga.png

サンプルコード

再利用可能なコンポーネントの実装サンプルをGitHubに置きました。

kuy/popover-profile

デモ1 / デモ2

SoundCloudのプロフィール情報のツールチップのような機能をContainerコンポーネントとして実装し、別々のアプリケーションであるデモ1とデモ2で共有するサンプルになります。今のところプロフィール情報の読み込みだけを行うのでインパクトに欠けますが、フォローボタンをつけて、フォローの状況をStateで共有してアプリケーション側でもフォロー状況を表示するとReduxっぽさが実感できて楽しいと思います。サーバーとの通信処理は擬似的にsetTimeoutを使っています。Webpack設定もスタイルもいろいろ荒削りなところがありますが参考になれば幸いです。

コード解説

WIP(力尽きたのであとで)

似たような設計になっているライブラリ

redux-form

Reduxで、というかそもそもReactでフォームを扱うのはとても厄介なんですが、必要となる基本的な機能以外にも、バリデーション、動的なフォームにまで対応していて便利です。が、まもなく大幅なAPI変更を伴うv6がリリースされるのでご注意を。

フォームのStateをReduxで管理することであらゆる入力フィールドでBlurしたら何かしたりとかがめちゃくちゃ簡単にできます。このあたり、Stateの変更にはActionを投げて、Storeが1つしかないRedux/Fluxを使っていて気持ちのスカッとする部分です。

redux-tooltip

手前味噌ですがReduxで扱いやすいツールチップのライブラリです。React向けのツールチップのライブラリはわりとあるんですが、どれもStateを内部に持ってしまうので遅延表示したり、表示されたあとに何かしたり、といった副作用的な処理がやりにくくて作りました。