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

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

React.memo を使ったパフォーマンス最適化について

パフォーマンス・チューニングには、「こうすれば必ず上手くいく」という方法論や銀の弾丸はなく、地道に試行と計測を繰り返すしかない。
しかしだからこそ、基本的な考え方や仕組みを理解することが大切であり、それがなければ、どのように対処していけばいいのか見当をつけることすら出来ず、的外れな対応をすることにもなりかねない。
React.memoを使った処理の最適化は、React アプリのパフォーマンス改善のための、基本となるテクニックのひとつである。

この記事のコードは React のv16.10.2で動作確認している。

メモ化という概念

React アプリのパフォーマンス最適化を理解するためにはまず、メモ化(Memoization)という概念を把握しておく必要がある。
大雑把に言ってしまうとメモ化とは、何らかの計算によって得られた値を記録しておき、その値が再度必要になったときに、再計算することなく値を得られるようにすることである。
メモ化によって都度計算する必要がなくなるため、パフォーマンスの向上が期待できる。
React.memoとは言ってみれば、メモ化を利用して不要な処理をスキップするための機能であり、それを活用することが、React アプリのパフォーマンス最適化の基本的な戦略である。

JavaScript においてはメモ化にはもうひとつ重要な役割がある。それは、同じ参照のオブジェクトを得られるようになるということ。
記録しておいたオブジェクトを取り出すので、「値が一緒だが参照が異なる別のオブジェクト」になってしまうことを防げる。
JavaScript のオブジェクトの等価性は「参照が同じかどうか」で判断することが多く、React も例外ではないため、これは重要な意味を持つ。
後述するように、React.memoを上手く使うためにはこの効果も把握しておく必要がある。

再レンダーによるコスト

コンポーネントのstateが更新されると、そのコンポーネントは再レンダーされる。
そして、親コンポーネントが再レンダーされると、その子コンポーネントも無条件で再レンダーされる。

下記の例では、ボタンを押す度にAppChildが再レンダーされるため、その度にログが流れる。

import React, {useState} from 'react';

const App = () => {
  const [state, setState] = useState(0);

  const onClick = () => {
    setState(s => s + 1);
  };

  console.log('render App');

  return (
    <div>
      <button type="button" onClick={onClick}>
        count up
      </button>
      <br />
      {state}
      <Child />
    </div>
  );
};

const Child = () => {
  console.log('render Child');

  return <div>Child</div>;
};

export default App;

f:id:numb_86:20191220213937g:plain

再レンダーには当然、それを実行するためのコストがある。
上記のサンプルではconsole.logを実行しているが、この部分で高コストな処理を行っていれば、その分だけパフォーマンスに悪影響を与える。

また、再レンダーの度に React が内部的に行う処理もある。
例えば、再レンダーによって返された React 要素を前回のレンダー時の React 要素と比較して、DOM を更新する必要があるかをチェックしている。
そして、更新する必要があった場合は、差分に応じて DOM を更新する。
これらの処理にも当然、少なからずコストが掛かっている。

だが上記のChildは、stateが何であれレンダーする内容は変わらないため、Childの再レンダーが不要であることは一目瞭然。
そのため再レンダーをスキップできれば、その分だけ、パフォーマンスの向上が期待できる。

それを実現するために利用するのがメモ化であり、その具体的な方法がReact.memoである。

props の等価性をチェックする

React.memoでは、コンポーネントが返した React 要素を記録する。
そして、再レンダーが行われそうになったときは本当に再レンダーが必要なのかチェックし、必要なときだけ再レンダーし、必要ないときは記録しておいた React 要素を再利用することで、再レンダーを最低限に抑える。

再レンダーが必要かどうかは、propsの等価性によって判断される。
新しく渡されたpropsを前回のpropsと比較し、同じであると見なせば再レンダーは不要であると判断し、異なると見なせば再レンダーを行う。

デフォルトでは、等価性の判断にshallow compareを使う。これは、オブジェクトの1階層のみを比較することである。
先程の例のChildではpropsは常に{}であるため、shallow compareによって等価であると判断される。
早速React.memoを使ってみる。

React.memoは、メモ化したいコンポーネントをラップして使う。そのため、Childを以下のように書き換える。

const Child = React.memo(() => {
  console.log('render Child');

  return <div>Child</div>;
});

これで、Childの再レンダーは常にスキップされるようになった。

f:id:numb_86:20191220214002g:plain

次は、propsshallow compareでは再レンダーをスキップできないパターンを見てみる。

stateをオブジェクトにして、そのなかにappchildというプロパティを持たせた。どちらもボタンを押して個別にカウントアップできる。
Childコンポーネントはstateを受け取り、state.childを画面に表示している。

import React, {useState} from 'react';

const App = () => {
  const [state, setState] = useState({
    app: 0,
    child: 0,
  });

  const countUpApp = () => {
    setState(s => ({
      app: s.app + 1,
      child: s.child,
    }));
  };

  const countUpChild = () => {
    setState(s => ({
      app: s.app,
      child: s.child + 1,
    }));
  };

  console.log('render App');

  return (
    <div>
      <button type="button" onClick={countUpApp}>
        count up app
      </button>
      <br />
      <button type="button" onClick={countUpChild}>
        count up child
      </button>
      <br />
      App: {state.app}
      <Child state={state} />
    </div>
  );
};

const Child = React.memo(({state}) => {
  console.log('render Child');

  return <div>Child: {state.child}</div>;
});

export default App;

この場合、state.childの値が更新されたとき、つまりcount up childボタンが押したときだけ再レンダーすれば、十分なはず。
しかしこの実装だと、count up appボタンを押したときも再レンダーされてしまう。

f:id:numb_86:20191220214043g:plain

原因は、Childstateというオブジェクトを渡してしまったこと。
これをshallow compareすると、前回のprops.stateと今回のprops.stateは常に違うオブジェクトになるので、等価性がないと判断される。そのため、必ず再レンダーされてしまう。

この問題の正しい解決法は、Childstateを渡すのを止め、state.childだけを受け渡すようにすることである。Childstate.appを必要としていないのだから、stateオブジェクトを丸ごと渡しているのが、そもそも間違っている。

だが今回はReact.memoの説明のため、敢えて違う方法で解決する。

React.memoの第二引数には関数を渡すことができ、公式ドキュメントではこの関数のことをareEqualと呼んでいる。
areEqualは、第一引数として前回のpropsprevProps)を、第二引数として今回のpropsnextProps)を受け取る。
そしてareEqualは、真偽値を返すように書く必要がある。
そのため、このような構文になる。

const メモ化されたコンポーネント = React.memo(元のコンポーネント, (prevProps, nextProps) => {/* 真偽値を返す */})

areEqualtrueを返したときは再レンダーをスキップし、falseを返したときは再レンダーを行う。
areEqualを省略した場合は、上述の通りpropsshallow compareで等価性を判断する。

そのため、Childを以下のように書き換えると、state.childの値が変化していないときは再レンダーを不要と判断し、スキップする。

const Child = React.memo(
  ({state}) => {
    console.log('render Child');

    return <div>{state.child}</div>;
  },
  (prevProps, nextProps) => prevProps.state.child === nextProps.state.child
);

f:id:numb_86:20191220214124g:plain

繰り返しになるが、今回のケースでは、propsとしてstate.childだけを渡す、というのが正しい解決方法である。パフォーマンスの問題以前にコンポーネントの設計として、不必要なpropsを渡すべきではない。今回は説明のしやすさのために、敢えてこのようなサンプルにした。

areEqualで再レンダーをコントロールできるので、これを使えば、表示を制御することが可能になる。データの変化を表示に反映させたい場合にのみ、areEqualfalseを返すようにすればよい。
だが、そのような使い方をしてはいけない。公式ドキュメントにあるように、React.memoはパフォーマンス最適化のためにのみ、使う。

これはパフォーマンス最適化のためだけの方法です。バグを引き起こす可能性があるため、レンダーを「抑止する」ために使用しないでください。

React.memo – React

もうひとつ注意しなければならないのは、等価性のチェックにも、当然コストがかかるということ。
そしてareEqualで複雑なことをすればするほど、コストは高くなっていく。
もし等価性のチェックにかかるコストが再レンダーにかかるコストより高くなれば、意味がないどころか逆効果ということになる。
そのため、常にReact.memoを使えばよいというものではない。冒頭に書いたように試行と計測を繰り返して、最適化を行っていく。
パフォーマンス向上に僅かな効果しか得られない場合、他の要素(可読性やメンテナンス性など)とのトレードオフを考慮し、敢えて最適化しないという選択肢も十分にあり得る。

useCallback と組み合わせる

メモ化が使われている機能はReact.memoだけではない。useCallbackもそのひとつ。
useCallbackはメモ化を利用して、関数の不要な再作成を防ぐ。

ある関数コンポーネントのなかに以下の記述があったとする。

const showAandB = () => {
  console.log(props.a, props.b);
};

showAandBは、再レンダーされる度に新しく作られる。
だが、props.aprops.bが変わらない限り、showAandBを新しく作り直す必要はない。
useCallbackを使えば、以前作ったshowAandBを再利用できる。

const showAndB = useCallback(() => {
  console.log(props.a, props.b);
}, [props.a, props.b]);

useCallbackの第一引数に作成する関数を、第二引数にその関数が使用している値を列挙した配列を、渡す。
そうすると、配列に列挙した値を前回と比較し、それがひとつでも変われば、新しくshowAandBを作り直す。
だが全て前回と同じであれば、前回のshowAandBを再利用する。そうすると、前回と同じ参照の関数を得ることになる。
関数やオブジェクトの等価性は参照が同じかどうかで判定することが多いので、このことはメモ化を利用する上で大きな意味を持つ。

サンプルを少し書き換えて、ChildvalueonClickを渡すようにした。valueは数値だが、onClickは関数である。

import React, {useState} from 'react';

const App = () => {
  const [state, setState] = useState({
    app: 0,
    child: 0,
  });

  const countUpApp = () => {
    setState(s => ({
      app: s.app + 1,
      child: s.child,
    }));
  };

  const countUpChild = () => {
    setState(s => ({
      app: s.app,
      child: s.child + 1,
    }));
  };

  const alertChildState = () => {
    alert(state.child);
  };

  console.log('render App');

  return (
    <div>
      <button type="button" onClick={countUpApp}>
        count up app
      </button>
      <br />
      <button type="button" onClick={countUpChild}>
        count up child
      </button>
      <br />
      App: {state.app}
      <Child value={state.child} onClick={alertChildState} />
    </div>
  );
};

const Child = React.memo(({value, onClick}) => {
  console.log('render Child');

  return (
    <div>
      Child: {value}
      <br />
      <button type="button" onClick={onClick}>
        click
      </button>
    </div>
  );
});

export default App;

ChildReact.memoでラップしているが、count up appを押下したときも再レンダーされてしまう。

f:id:numb_86:20191220214201g:plain

原因はprops.onClickにある。 count up app押下時にpropsshallow compareすると、valueは等価であると判断される。しかし、onClickとして渡されているalertChildStateは、Appが再レンダーされる度に新しく作られるため、参照が異なり、常に等価性がないと判断されてしまう。

このようなケースで、useCallbackが役に立つ。前述のように同じ参照の関数を返すので、React.memoが等価性があると判断するようになる。 alertChildStateの定義を以下のように書き換えると、state.childが変わったときにのみ関数を作り直すようになる。

// import React, {useState, useCallback} from 'react'; として useCallback をインポートしておくことを忘れない

const alertChildState = useCallback(() => {
  alert(state.child);
}, [state.child]);

これで、state.childが変わった時、つまりcount up childが押下された時にのみ、Childが再レンダーされるようになった。

f:id:numb_86:20191220214314g:plain

React.memo によるメモ化の影響範囲

React.memoで再レンダーをスキップすると、そのコンポーネントの子コンポーネントの再レンダーもスキップされる。

まずは、React.memoを使わない場合を見てみる。
Appが持っているstateを、Child、そしてGrandchildと受け渡している。

import React, {useState} from 'react';

const App = () => {
  const [state, setState] = useState(0);

  const onClick = () => {
    setState(s => s + 1);
  };

  console.log('render App');

  return (
    <div>
      <button type="button" onClick={onClick}>
        count up
      </button>
      <br />
      App
      <Child value={state} />
    </div>
  );
};

const Child = ({value}) => {
  console.log('render Child');

  return (
    <div>
      Child
      <Grandchild value={value} />
    </div>
  );
};

const Grandchild = ({value}) => {
  console.log('render Grandchild');

  return <div>{value}</div>;
};

export default App;

当然、ボタンを押す度に全てのコンポーネントが再レンダーされる。

f:id:numb_86:20191220214357g:plain

だが、React.memoを使ってChildの再レンダーを常にスキップするようにすると、ChildだけでなくGrandchildの再レンダーも行われなくなることを確認できる。

const Child = React.memo(
  ({value}) => {
    console.log('render Child');

    return (
      <div>
        Child
        <Grandchild value={value} />
      </div>
    );
  },
  () => true // 検証用のコードであり、areEqual が常に true や false を返す書き方は通常はしない
);

f:id:numb_86:20191220214444g:plain

さらに検証してみると、GrandchildReact.memoでラップしても、そのReact.memoは実行されていない。

const Child = React.memo(
  ({value}) => {
    console.log('render Child');

    return (
      <div>
        Child
        <Grandchild value={value} />
      </div>
    );
  },
  () => {
    console.log("Child's areEqual");
    return true;
  }
);

const Grandchild = React.memo(
  ({value}) => {
    console.log('render Grandchild');

    return <div>{value}</div>;
  },
  () => {
    console.log("Grandchild's areEqual");
    return false;
  }
);

f:id:numb_86:20191220214507g:plain

Childの場合はareEqualを実行した上で再レンダーするかどうかを決めているが、Grandchildの場合はareEqualの実行自体が行われていない。
このことから、React.memoで再レンダーをスキップすると、その時点でその下のコンポーネントツリーの処理が全てスキップされるのだと思われる。

参考資料

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の全てが、再レンダーされてしまう。

参考資料