なぜグローバルな Store を作るのか
React アプリの設計論では、複数のコンポーネントで利用する値をどのように管理するか、というテーマがよく話題になる。
前提として、コンポーネントは小さく分割すべき、という考え方がまずある。
これは React に特有のものではなく、プログラミングの一般論として、ひとつひとつの関数は小さくするのがベストプラクティスだとされる。それには様々な理由があるが、単一責任の原則、疎結合、テスタブル、などがよく理由として挙げられる。
React のコンポーネントも同じで、肥大化しないように管理することが、保守しやすいアプリへの道だ。いかに適切な粒度でコンポーネントを分割できるかが、React を使いこなす上で重要となる。
だがコンポーネントを分割していくと、複数のコンポーネントで共通の値を扱う、という状況が発生しうる。
それにどのように対処するか、というのが、議論の出発点。
自然に考えれば、親コンポーネントに値をまとめればいい、ということになる。
A
というコンポーネントの子としてB
とC
があり、B
とC
で共通の値を扱っているのなら、その値はA
に持たせ、それを子に渡す。
「データは下方向に伝わる」という React の基本的な考え方にも沿っており、実装がシンプルになる。
多くのコンポーネントで使われている値はその分だけコンポーネントツリーの上部に押し上げられていき、特にアプリ全体で使われるような値については、ルートコンポーネントで持つことになる可能性が高い。
絶対にそうする必要があるわけではないが、アプリ全体で使うような値が散在しているとメンテナンス性や可読性に悪影響を及ぼすため、自ずと一箇所に集約されていき、それはルートコンポーネントである可能性が高い。
しかしそうすると今度は、ルートコンポーネントが肥大化してしまうという問題が発生する。
「アプリ全体で使うような値」が定数であることは少なく、大抵は動的であり、アプリが動くなかで移り変わっていく。
そして動的であるということは、その値を操作するための関数も必要になることを意味する。そしてその関数には、値をどのように操作するのかを記述したビジネスロジックが含まれる。
アプリ全体で使うような値、それを操作したり参照したりするための関数、値に関するビジネスロジック、をひとまとめにして、ここでは便宜的に状態と呼ぶことにする。
状態に関する記述を全てルートコンポーネントに入れてしまうと、単に肥大化するだけでなく、責務の分割という観点からも問題になる。ルートコンポーネントはコンポーネントツリーの頂点として子コンポーネントを束ねるのが本来の役割であり、そこに状態に関する定義がびっしりと書かれてしまうのはおかしい。
そもそも、ルートコンポーネントに限らず、コンポーネントはビュー以外のロジックやデータを内部に抱えるべきではない。
状態の規模が小さいならまだしも、ある程度以上の規模のものをルートコンポーネントに詰め込むと取り回しが悪くなり、管理に問題が出てくる。状態とコンポーネントが密結合になってしまう。
そのため、一定以上の規模を持った React アプリでは、コンポーネントツリーから状態を切り離すのが一般的である。
状態について記述する場所を、コンポーネントツリーとは別の場所に用意する。状態に関する記述をまとめるためのその場所を、ここでは便宜的にStore
と呼ぶことにする。
コンポーネントツリーが抱えていた「状態」をStore
に切り出すことで、それぞれの役割が明確になり、両者は疎結合になる。
これの何が嬉しいのかと言うと、コンポーネントとStore
がお互いに独立して存在しているため、それぞれ単独で変更作業やテストを行えるようになる。
ビューを変えたいときはコンポーネントを編集し、状態に関する変更を行いたい場合はStore
を編集すればいい。疎結合になっているから、ユニットテストも書きやすい。
ここまでは、React アプリの設計論としてかなり一般的な内容だと思う。
コンポーネントツリーから独立した形でStore
を作り、そこに状態に関する事柄を記述していく。
この手法に対する異論は少ないはず。
意見が分かれるのはここからで、主な論点は以下の2つ。
Store
をどのように作るかStore
とコンポーネントツリーをどのように連携させるか
まず、「Store
をどのように作るか」から見ていく。
どのように Store を作るか
Store
の構成には、様々な形があり得る。1つのアプリに対して1つのStore
を用意するのかもしれないし、複数のStore
を使うのかもしれない。
Store
内部の構造も、ベストプラクティスが確立されているわけでなく、様々な意見やアイディアがある。
移り変わる値をどのように管理すればよいのか、というのは難しい課題であり、絶対的な答えは存在しない。
プロダクトやチームによって、重視するポイントも異なる。
一貫性や分かりやすさを重視する人たちもいるだろうし、それよりも既述の少なさに重きを置く人もいる。テストの書きやすさも重要な観点だろうし、プロダクトによってはパフォーマンス性能も判断基準になるかもしれない。チーム開発なら、学習コストについても考慮しないといけない。
現実的にはゼロベースでStore
を設計するのは稀で、既存のライブラリを使ってStore
を組み立てることになる。
広く使われているのは Flux という思想の実装である Redux だが、他にも様々なライブラリがあり、それぞれに特色がある。
React Hooks の登場によって、他のライブラリを使わずに React だけで状態を扱おう、という考え方も出てきた。
React の話題で「状態管理」という言葉が出てきたときは、このような「Store
をどのように、どのライブラリを使って作るのか」についての議論であることが多い。
だが状態については、もうひとつ重要な論点がある。それが、「Store
とコンポーネントツリーをどのように連携させるか」である。
Store とコンポーネントツリーをどのように連携させるか
値やそれに関するロジックをStore
としてコンポーネントツリーから切り離したが、それらはあくまでも、コンポーネントに使われるために存在する。
ユーザーに見せる画面を作るために値が存在するのであり、ユーザーの操作によって値が適切に更新されるためにロジックや関数が存在する。
Store
は必ずコンポーネントツリーと連携して仕事を行う。そうでなければ、Store
が存在している意味がない。
そして共同作業をする以上、必ず依存関係が発生する。切り離しはしたが、何らかの形で、Store
とコンポーネントツリーを接続することになる。
それをどう行うかが、問題となる。
大きく分けて2つのアプローチがある。
prop drilling
を使い、Store
とコンポーネントとの接続は最低限にする考え方。
prop drilling
を避け、Store
とコンポーネントとの接続を積極的に行う考え方。
prop drilling
とは、props
を親要素から子要素へ、子要素から孫要素へと受け渡していくことで、React で開発したことがある人なら見慣れた光景だと思う。
説明のために簡単なサンプルを使った。
App
がルートコンポーネントで、その下にChild
、Grandchild
とつながっていく、ツリー構造になっている。
この例ではApp
がStore
と接続し、そこで手に入れた値を、バケツリレーの要領でGrandchild
に渡している。
// prop drilling を使う import React from 'react'; const Store = { value: { id: 1, name: 'Alice', }, getStore: () => Store.value, }; const App = () => { const value = Store.getStore(); return <Child name={value.name} />; }; const Child = ({name}) => <Grandchild name={name} />; const Grandchild = ({name}) => <div>{name}</div>; export default App;
分かりやすさのためにかなり単純な構成になっているが、現実のプロダクトではもっと複雑なツリー構造になっているはず。
そうすると、ひたすら子要素にprops
を受け渡していくこのやり方は、とにかく単純で分かりやすく、複雑さを軽減する効果がある。
その一方で、冗長でムダが多いし、バケツリレーが長くなると却って分かりづらいし管理が面倒になる、という考え方もある。
上記の例だと、Child
はただGrandchild
にリレーするためだけにprops.name
を受け取っており、Child
自体はprops.name
を必要としていない。
そこで、App
ではなく、値を必要としているGrandchild
をStore
と接続させてみる。
// prop drilling を避ける import React from 'react'; const Store = { value: { id: 1, name: 'Alice', }, getStore: () => Store.value, }; const App = () => { return <Child />; }; const Child = () => <Grandchild />; const Grandchild = () => { const {name} = Store.getStore(); return <div>{name}</div>; }; export default App;
こうすると、不要なprop drilling
をする必要がなく、ムダが無くなる。
しかし上手く使わないと、どこからStore
を参照、更新しているのかが分かりづらくなり、データの流れを理解するのが難しくなっていく。
これは、どちらのアプローチを採用するかではなく、どちらにどれくらいの比重を置いてアプリを作っていくのか、という議論。
どちらにもメリットとデメリットがある。
Store
そのものをどのように作るか、という議論の影に隠れがちではあるが、保守性が高く変更に強いアプリを作るためには、Store
とコンポーネントツリーの連携方法もまた重要な論点だと思う。
最後に、prop drilling
のメリットとデメリットを掘り下げて紹介する。
prop drilling のメリット
prop drilling
と関係の深い概念として、コンテナコンポーネントとプレゼンテーショナルコンポーネントがある。
簡単に言ってしまえば、Store
と接続しているコンポーネントがコンテナコンポーネントであり、自らはStore
とは接続せずコンテナコンポーネントからprops
を受け取るコンポーネントがプレゼンテーショナルコンポーネントである。
そのため、prop drilling
を多様するパターンでは「少数のコンテナコンポーネントと多数のプレゼンテーショナルコンポーネント」という構成になり、prop drilling
を避けるパターンではコンテナコンポーネントの数が相対的に多くなる。
prop drilling
を多用するということは、プレゼンテーショナルコンポーネントの数を増やすということでもある。なぜそうするのか。
これは既に述べたが、データフローが明示的かつ単純になる。必要なデータは常にコンポーネントツリーの上から流れてくるという、単純な構造になる。
単純であることの効用は大きい。複数人で開発するときは特に、大きなメリットを得られる。誰でも読めるし、誰が読んでも間違わない。
文脈に依存しなくなる。
プレゼンテーショナルコンポーネントはprops
さえ渡せば使えるので、どのような文脈でも使える。そのため再利用性が高まり、テスタブルにもなる。
コンポーネントのなかでStore
と接続していると、そのコンポーネントは、対応するStore
が存在している環境でしか使えなくなってしまう。再利用性が下がるし、テストを書くのにも手間が増えてしまう。
責務の分割や単一責任を徹底できる。
コンポーネントを、ビューの構築という本来の役割に専念させることが出来る。余計な概念や知識が入り込まないことで、ひとつひとつのコンポーネントが単純な作りになり、可読性、再利用性、テスタブル、の向上に貢献する。
Store
に関する変更の影響を受けにくい。
例えば、今まで React Hooks と Context API でStore
を作っていたものを Redux を使うように変えた場合、変更の影響を受けるのはStore
とコンテナコンポーネントのみで、親からprops
を受け取るだけのプレゼンテーショナルコンポーネントは影響を受けない。Store
と接続しているコンポーネントが増えれば増えるほど、変更の影響が大きくなってしまう。
同様に、Store
のデータ構造や設計が変わったときも、影響を受けにくい。
そのため、複雑な要素や変更の多い要素をStore
とコンテナコンポーネントに閉じ込め、プレゼンテーショナルコンポーネントを中心にアプリを作れば、変更に強くなりやすい。
コンポーネントを使う上で知っていなければならない暗黙の知識が存在しなくなる。
コンポーネントのなかでStore
を使っている場合、Store
がどんな名前のプロパティを持っているのか、知らないといけなくなる。
例としてGrandchild
を再掲する。
// プレゼンテーショナルコンポーネント const Grandchild = ({name}) => <div>{name}</div>; // コンテナコンポーネント const Grandchild = () => { const {name} = Store.getStore(); return <div>{name}</div>; };
コンテナコンポーネントは、Store
がgetStore
というメソッドを持っていることを知っている。さらに、それによって得られるオブジェクトがname
というプロパティを持っていることも知っている。つまり、コンポーネント側がStore
の構造について知っていなければならなくなる。
プレゼンテーショナルコンポーネントは渡されたprops.name
を表示するだけなので、Store
について知っている必要はない。
Store
が複雑になればなるほど、Store
について知っておかなければならないことが増え、Store
の複雑さがコンポーネントに漏れ出す危険性も増える。
props drilling のデメリット
props drilling
は単純で明示的だが、その代わり、記述が冗長になりやすい。
特に、以下のChild
のように、子コンポーネントに渡すためだけにprops
を受け取るのは、ムダに思える。
const Child = ({name}) => <Grandchild name={name} />;
そして、props
の受け渡しが何回も繰り返されると、読むための労力が増え、可読性は悪化していく。
また、あるコンポーネントのインターフェイスを変えると、他のコンポーネントもその影響を受けることが多い。
例えば、Grandchild
が、name
だけでなくid
も表示するようになったとする。
prop drilling
を使っていた場合、Grandchild
だけでなくApp
とChild
にも変更を加えないといけない。
import React from 'react'; const Store = { value: { id: 1, name: 'Alice', }, getStore: () => Store.value, }; const App = () => { const value = Store.getStore(); return <Child id={value.id} name={value.name} />; }; const Child = ({id, name}) => <Grandchild id={id} name={name} />; const Grandchild = ({id, name}) => ( <div> {id}: {name} </div> ); export default App;
prop drilling
を避けていれば、このような問題は発生せず、Grandchild
だけを拡張すればよい。
import React from 'react'; const Store = { value: { id: 1, name: 'Alice', }, getStore: () => Store.value, }; const App = () => { return <Child />; }; const Child = () => <Grandchild />; const Grandchild = () => { const {id, name} = Store.getStore(); return ( <div> {id}: {name} </div> ); }; export default App;
今回はprops
の追加だったが、削除であったり、props
の名前を変更したりした際も、同じ問題が発生する。
prop drilling
の規模が大きければ大きいほど、影響を受けるコンポーネントも増える。
prop drilling
を使えばStore
とは疎結合になるが、コンポーネント同士の関係は密になっていく、と言えるかもしれない。
コンポーネント同士の関係が密になることで、パフォーマンスについても考慮しないといけない要素が増える。
この記事のサンプルはStore
の値を参照するだけだったが、もしid
やname
を更新できる場合、どうなるか。
prop drilling
を使っていた場合、例えばname
の値が変わると、App
、Child
、Grandchild
の全てが、再レンダーされてしまう。