30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

React の状態管理についての論点整理

なぜグローバルな Store を作るのか

React アプリの設計論では、複数のコンポーネントで利用する値をどのように管理するか、というテーマがよく話題になる。

前提として、コンポーネントは小さく分割すべき、という考え方がまずある。
これは React に特有のものではなく、プログラミングの一般論として、ひとつひとつの関数は小さくするのがベストプラクティスだとされる。それには様々な理由があるが、単一責任の原則、疎結合、テスタブル、などがよく理由として挙げられる。
React のコンポーネントも同じで、肥大化しないように管理することが、保守しやすいアプリへの道だ。いかに適切な粒度でコンポーネントを分割できるかが、React を使いこなす上で重要となる。

だがコンポーネントを分割していくと、複数のコンポーネントで共通の値を扱う、という状況が発生しうる。
それにどのように対処するか、というのが、議論の出発点。

自然に考えれば、親コンポーネントに値をまとめればいい、ということになる。
Aというコンポーネントの子としてBCがあり、BCで共通の値を扱っているのなら、その値はAに持たせ、それを子に渡す。
「データは下方向に伝わる」という React の基本的な考え方にも沿っており、実装がシンプルになる。

多くのコンポーネントで使われている値はその分だけコンポーネントツリーの上部に押し上げられていき、特にアプリ全体で使われるような値については、ルートコンポーネントで持つことになる可能性が高い。
絶対にそうする必要があるわけではないが、アプリ全体で使うような値が散在しているとメンテナンス性や可読性に悪影響を及ぼすため、自ずと一箇所に集約されていき、それはルートコンポーネントである可能性が高い。

しかしそうすると今度は、ルートコンポーネントが肥大化してしまうという問題が発生する。

「アプリ全体で使うような値」が定数であることは少なく、大抵は動的であり、アプリが動くなかで移り変わっていく。
そして動的であるということは、その値を操作するための関数も必要になることを意味する。そしてその関数には、値をどのように操作するのかを記述したビジネスロジックが含まれる。
アプリ全体で使うような値、それを操作したり参照したりするための関数、値に関するビジネスロジック、をひとまとめにして、ここでは便宜的に状態と呼ぶことにする。

状態に関する記述を全てルートコンポーネントに入れてしまうと、単に肥大化するだけでなく、責務の分割という観点からも問題になる。ルートコンポーネントはコンポーネントツリーの頂点として子コンポーネントを束ねるのが本来の役割であり、そこに状態に関する定義がびっしりと書かれてしまうのはおかしい。
そもそも、ルートコンポーネントに限らず、コンポーネントはビュー以外のロジックやデータを内部に抱えるべきではない。
状態の規模が小さいならまだしも、ある程度以上の規模のものをルートコンポーネントに詰め込むと取り回しが悪くなり、管理に問題が出てくる。状態とコンポーネントが密結合になってしまう。

そのため、一定以上の規模を持った React アプリでは、コンポーネントツリーから状態を切り離すのが一般的である。
状態について記述する場所を、コンポーネントツリーとは別の場所に用意する。状態に関する記述をまとめるためのその場所を、ここでは便宜的にStoreと呼ぶことにする。

コンポーネントツリーが抱えていた「状態」をStoreに切り出すことで、それぞれの役割が明確になり、両者は疎結合になる。
これの何が嬉しいのかと言うと、コンポーネントとStoreがお互いに独立して存在しているため、それぞれ単独で変更作業やテストを行えるようになる。
ビューを変えたいときはコンポーネントを編集し、状態に関する変更を行いたい場合はStoreを編集すればいい。疎結合になっているから、ユニットテストも書きやすい。

ここまでは、React アプリの設計論としてかなり一般的な内容だと思う。
コンポーネントツリーから独立した形でStoreを作り、そこに状態に関する事柄を記述していく。
この手法に対する異論は少ないはず。

意見が分かれるのはここからで、主な論点は以下の2つ。

  1. Storeをどのように作るか
  2. 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がルートコンポーネントで、その下にChildGrandchildとつながっていく、ツリー構造になっている。
この例ではAppStoreと接続し、そこで手に入れた値を、バケツリレーの要領で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ではなく、値を必要としているGrandchildStoreと接続させてみる。

// 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>;
};

コンテナコンポーネントは、StoregetStoreというメソッドを持っていることを知っている。さらに、それによって得られるオブジェクトが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だけでなくAppChildにも変更を加えないといけない。

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の値を参照するだけだったが、もしidnameを更新できる場合、どうなるか。
prop drillingを使っていた場合、例えばnameの値が変わると、AppChildGrandchildの全てが、再レンダーされてしまう。

参考資料