React学習 React学習ログ

【React学習ログ③】メモ化で子コンポーネントが無駄に実行されるのを防ぐ

Reactでは親コンポーネントが実行されると、親がインポートしている子コンポーネントも勝手に実行されてしまいます。

便利なの時もあるのですが、不要な時もあるのでこの現象の制御方法を解説します。

具体例をお見せします。

早速次の例を見てみましょう。
親子関係の画像とソースコードです。

親子関係
import React, { useState } from 'react';
import Child from './components/Child';

function App() {
  const [showParagraph, setShowParagraph] = useState(false);

  console.log('APP RUNNNING');

  const toggleParagraph = () => {
    setShowParagraph((prevShowParagraph) => !prevShowParagraph);
  };
  return (
    <div className="container">
      <h1>Hi there!</h1>
      <Child />
      <div className="btn" onClick={toggleParagraph}>
        Toggle ボタン
      </div>
    </div>
  );
}

export default App;
import React from 'react';

const Child = (props) => {
  console.log('CHILD RUNNNING');
  return <p>Hi</p>;
};

export default Child;

実装内容は、ボタンをクリックすれば、useState()が実行され、Appコンポーネントが再実行されるというものです。

実装結果はこちらです↓

親であるAppコンポーネントが再実行されるので毎回「APP RUNNING」が出力されるのはわかるのですが、同時に子であるChildコンポーネントも実行されて「CHILD RUNNING」も出力されています。

この挙動の理由は、コンポーネントは正しくは関数コンポーネントであり、言ってしまえば関数なので、親の関数が実行されるとネスト状態である子の関数も順番が来たら実行されるからです。

渡されるpropsの値が常に同じでも実行される

次の例を見てみましょう。

import React, { useState } from 'react';
import Child from './components/Child';

function App() {
  const [showParagraph, setShowParagraph] = useState(false);

  console.log('APP RUNNNING');

  const toggleParagraph = () => {
    setShowParagraph((prevShowParagraph) => !prevShowParagraph);
  };
  return (
    <div className="container">
      <h1>Hi there!</h1>
      <Child show={false} />
      <div className="btn" onClick={toggleParagraph}>
        Toggle ボタン
      </div>
    </div>
  );
}

export default App;

Childコンポーネントに渡される値は常に「false」でも実行結果は先ほどと同じになります。

常に「false」が渡されるのでテキストは表示されませんが、検証モードでは「CHILD RUNNING」が出ていますね。

このことから、子コンポーネントに渡される値が変更されるから子コンポーネントが実行されるのではなく、親コンポーネントが実行されるから子コンポーネントも実行されると知っておきましょう。

しかしこの挙動からある問題が発生します。
それは、、

子コンポーネントが大量にあると親を実行すると毎回全ての子コンポーネントを再実行してしまうことになるということです。

例えば次のようにさらにコンポーネントを増やしてみましょう。

親子関係

App.jsはそのままでChild.jsを少し書き換えます。

import React from 'react';
import Grandchild from './Grandchild';

const Child = (props) => {
  console.log('CHILD RUNNING');
  return (
    <div>
      <Grandchild>{props.show ? 'Hi' : ''}</Grandchild>
    </div>
  );
};

export default Child;

新しく、Grandchild.jsを作ります。

import React from 'react';

const Grandchild = (props) => {
  console.log('GRANDCHILD RUNNING');
  return <p>{props.children}</p>;
};

export default Grandchild;

先ほどの解説通り、増えたコンポーネントも実行されてしまいます。

アプリが大きくなればなるほど、コンポーネントも増えていきます。
コンポーネントの数だけ毎回実行されるとアプリに負荷がかかりパフォーマンスに影響が出ます。

次からこの問題の解決方法を解説します。

React.memo()で子コンポーネントの再実行を制御

Reactには子コンポーネントが無駄に実行されるのを防ぐ方法が用意されています。
それがReact.memo()です。

React.memo()を使うことで、記載されたコンポーネントに渡されるprosに変更があった時にだけにコンポーネントを再実行させることができるのです。

このように再レンダリングの必要のない部分をスキップすることをメモ化と言いアプリケーションのパフォーマンスを向上させることができます。

例をお見せするために先ほどの実装を下記に変更します。Grandchild.jsを新しく追加します。

import React, { useState } from 'react';
import Child from './components/Child';

function App() {
  const [showParagraph, setShowParagraph] = useState(false);

  console.log('APP RUNNNING');

  const toggleParagraph = () => {
    setShowParagraph((prevShowParagraph) => !prevShowParagraph);
  };
  return (
    <div className="container">
      <h1>Hi there!</h1>
      <Child show={false} />  //←変更点
      <div className="btn" onClick={toggleParagraph}>
        Toggle ボタン
      </div>
    </div>
  );
}

export default App;
import React from 'react';
import Grandchild from './ Grandchild';

const Child = (props) => {
  console.log('CHILD RUNNNING');
  return (
    <div>
      <Grandchild showText={props.show} />  //←変更点
    </div>
  );
};

export default Child;
//新しいファイル
import React from 'react';

const Grandchild = (props) => {
  console.log('GRANDCHILD RUNNNING');
  return <p>{props.shoText ? 'Hi' : ''}</p>;
};

export default Grandchild;

この実装だけでは結果は先ほどと同様で子要素の全てが実行されています。(「CHILD RUNNNING」と「GRANDCHILD RUNNNING」が出力された)

ここでReact.memo()で子コンポーネントの実行を制御してみましょう。

先ほど、「React.memo()を使うことで、Reactにpropsの値が変更された時にだけ対象の子コンポーネントを再実行させることができる」と説明しました。

今回は親であるAppコンポーネントのこの部分↓は、

<Child show={false} />

常にfalseを指定しているのでAppコンポーネントが再実行されても子であるChildコンポーネントのpropsの値は常にfalseで変更されません。

したがってChildコンポーネントにReact.memo()をこのように記述することで再実行を防ぐことができます。

import React from 'react';
import Grandchild from './ Grandchild';

const Child = (props) => {
  console.log('CHILD RUNNNING');
  return (
    <div>
      <Grandchild showText={props.show} />
    </div>
  );
};

export default React.memo(Child);  //←変更点

実行結果は下記になり、ボタンをクリックのたびに「APP RUNNING」だけが表示され、子コンポーネントの再実行を防ぐことができました。

Appコンポーネントを下記のようにすると、毎回propsの値がtrueとfalseの入れ替えが発生するのでボタンをクリックすると再び全ての子コンポーネントが動き出します。

<Child show={showParagraph} />

全てのコンポーネントでReact.memo()を使えばいいのでは?

全てのコンポーネントにReact.memo()を使っていれば必要に応じてReactが子コンポーネントを実行する/しないを勝手に判断して効率的じゃない?

いいポイントです。

しかし残念ながらReact.memo()を適切に使用しなければパフォーマンスに影響します。
React.memo()は次の2つのことをしており何も設定しない時よりも負荷がかかっているからです

  • propsの値を保存(記憶)
  • props比較

まずは、元々持っているpropsの値をReact.memo()が記憶しておかなければなりません、そのして変更がかかるたびに、前後のpropsの値を比較するという作業が発生しています。

これが増えすぎるとパフォーマンスに影響するので、各コンポーネントの再実行の負荷と各コンポーネントでReact .memo()を使う負荷を考えなければいけません。

React.memo()使用の判断基準

ではReact.memo()を使う/使わないはどのように判断するのでしょう?

それは対象コンポーネントの更新頻度です。

対象のコンポーネントのプロパティの更新頻度が少なく、下層に多くのコンポーネントがある時には有効です。

逆に頻繁にプロパティが更新されるコンポーネントは使う使う必要があまりない場合にはReact.memo()があろうがなかろうが再実行されるので、React.memo()に余計な仕事させることになる。

今回の例のAppコンポーネントが下記の記述の時は、クリックのたびに毎回propsの値がtrueとfalseの入れ替えが発生するので、React.memo()は不要ということです。

<Child show={showParagraph} />

一応App.jsを忘れた方に現状のApp.jsがどんな記述だったかをお見せします。

import React, { useState } from 'react';
import Child from './components/Child';

function App() {
  const [showParagraph, setShowParagraph] = useState(false);

  console.log('APP RUNNNING');

  const toggleParagraph = () => {
    setShowParagraph((prevShowParagraph) => !prevShowParagraph);
  };
  return (
    <div className="container">
      <h1>Hi there!</h1>
      <Child show={false} />
      <div className="btn" onClick={toggleParagraph}>
        Toggle ボタン
      </div>
    </div>
  );
}

export default App;

これまでのまとめ

  • Reactは親コンポーネントが再レンダリングされると全ての子要素も再レンダリングされてしまう
  • 子コンポーネントが増えれば増えるほどレンダリング増えアプリに負荷を与えてしまう
  • React.memo()を使うと、コンポーネントに渡されるpropの値が変わらなければそのコンポーネントは実行されない
  • React.memo()の実行も負荷がかかるので使う使わないの判断が重要
  • 対象のコンポーネントのプロパティの更新頻度が少なく、下層に多くのコンポーネントがある時には有効
  • 頻繁にプロパティが更新されるコンポーネントは使う必要があまりない

さらに深い理解へ

少し不思議な現象を紹介します。

Appコンポーネントに書かれていたボタンをコンポーネント化してみましょう。
今回触るのはApp.jsとButton.jsだけです。それ以外のソースはそのままです。

親子関係
import React, { useState } from 'react';
import Button from './components/Button';
import Child from './components/Child';

function App() {
  const [showParagraph, setShowParagraph] = useState(false);

  console.log('APP RUNNNING');

  const toggleParagraph = () => {
    setShowParagraph((prevShowParagraph) => !prevShowParagraph);
  };
  return (
    <div className="container">
      <h1>Hi there!</h1>
      <Child show={false} />
      <Button onClick={toggleParagraph}>Toggle ボタン</Button> //←変更点
    </div>
  );
}

export default App;
//新しいファイル
import React from 'react';

const Button = (props) => {
  console.log('BUTTON CLICKED');
  return (
    <div className="btn" onClick={props.onClick}>
      {props.children}
    </div>
  );
};

export default React.memo(Button);

注目ポイントは2ヶ所あります。

  1. Buttonコンポーネントの受け取るpropsは親コンポーネントが再構築されても同じ関数である
  2. Button.jsの最後にReact.memo(Button)と記述している

Buttonコンポーネントに送られてくるpropsは常に「toggleParagraph(関数)」です

したがって、今回の例でも、子コンポーネントのButton.jsはクリックされても実行されないはずです。
実行結果はこちらです↓

なぜかButton.jsに記載した「BUTTON CLICKED」がクリックのたびに出力されてしまいます。

結論からお伝えすると、実は今回のButtonコンポーネントで受け取るpropsの値は見た目は同じものなのですが、別の新しい値が入っているとみなされてしまうのです。

これはReactというよりJavaScriptの原理です。

<Child show={false} />
<Button onClick={toggleParagraph} />Toggle ボタン</Button>

各コンポーネントには送っているのは「false=Primitive型のデータ」で「toggleParagraph=Object型のデータ」です。
ここでは各データについて詳細な解説はしません。

Primitive型はこのよう↓に比較してもtrueが返ってきます。

しかしObject型(配列、オブジェクト、関数)は一見同じ値の見た目でもfalseが返ってきます。

つまり、親コンポーネントであるApp.jsが再構築された時、

<Child show={false} />
<Button onClick={toggleParagraph} />Toggle ボタン</Button>

上記の2つの値(falseとtoggleParagraph)は新しく書き換えられているのですが、Primitive型とObject型の性質の違いから、

Childコンポーネントはfalse === falseが成立し同じ値とみなされてReact.memo()によってClickコンポーネントが実行されない。

ButtonコンポーネントはtoggleParagraph !== toggleParagraphとなり新しい値とみなされてReact.memo()があってもButtonコンポーネントが実行されてしまう。

では、オブジェクト型のデータが送られた時の子コンポーネントの実行を制御する方法はないのか?

ReactのHooksの一つであるuseCallbackを使えば解決します。

useCallbackで制御する

useCallback()は指定の関数(オブジェクト型データ)をコンポーネント内で保存し、そのコンポーネントが再構築されても関数を新しく作るのではなく保存したものを使えるようにしてくれます。

つまり関数を再構築前後で同じものとしてみなされるようにしてくれるのです。

実装方法はこちらです。

import React, { useState, useCallback } from 'react';
import Button from './components/Button';
import Child from './components/Child';

function App() {
  const [showParagraph, setShowParagraph] = useState(false);

  console.log('APP RUNNNING');

  const toggleParagraph = useCallback(() => {  //←追加
    setShowParagraph((prevShowParagraph) => !prevShowParagraph);
  }, []);

  return (
    <div className="container">
      <h1>Hi there!</h1>
      <Child show={false} />
      <Button onClick={toggleParagraph} />Toggle ボタン</Button>
    </div>
  );
}

export default App; 

useCallbackは第一引数には記憶させる関数を指定します。そして第二引数には配列が入ります。

第二引数の配列の値が変更されたときにだけ、関数の更新がかかります=新しいものとみなされて子コンポーネントが実行されます。

今回の例のように配列を空にすると、「変更されることはないです=いつも同じ関数です」という宣言となります。

これでButton.jsにReact.memo()を記述することで渡されるpropsは常に同じものになり、Buttonコンポーネントは実行されることはなくなります。

以上です。

より詳しくなりたい人へ:useCallback()の第二引数を使う

ここからはかなりややこしくなるので、どうしても気になる人は呼んでください。

先ほどの例ではuseCallbackの第二引数の配列をからの状態にしていました。

今回はその第二引数を使う例を紹介します。

しかしこの例を理解するにはまず、JavaScriptのクロージャーを知っていなければなりません。

めちゃくちゃざっくりとクロージャーについていうと、

クロージャーとは関数を実行した後でも関数内の変数の値は保存されたままになる。

という仕組みです。

これを踏まえてApp.jsをこのように書き換えましょう。

import React, { useState, useCallback } from 'react';
import ButtonA from './components/ButtonA';
import ButtonB from './components/ButtonB';
import Child from './components/Child';

function App() {
  const [showParagraph, setShowParagraph] = useState(false);
  const [allowToggle, setAllowToggle] = useState(false);

  console.log('APP RUNNNING');

  const toggleParagraph = useCallback(() => {
    if (allowToggle) {
      setShowParagraph((prevShowParagraph) => !prevShowParagraph);
    }
  }, [allowToggle]);
  console.log(showParagraph);

  const allowToggleHandler = () => {
    setAllowToggle(true);
  };
  return (
    <div className="container">
      <h1>Hi there!</h1>
      <Child show={showParagraph} />
      <ButtonA onClick={allowToggleHandler}>Allow Toggle</ButtonA>
      <ButtonB onClick={toggleParagraph}>Toggle ボタン</ButtonB>
    </div>
  );
}

export default App;

変更点は、

  • useStateを追加して、allowToggleという状態を管理
  • JSX内のChildコンポーネントをshow={showParagraph}に変更
  • JSX内にButtonAとButtonBを作成
  • ButtonAを押すと、allowToggleがいつもtrueになるallowToggleHandler関数を作成

まだuseCallback()の第二引数の配列は空のままです。

ButtonA.jsとButtonB.jsはほぼ同じでconsole.logの出力だけを変更しています。React.memo()は設定されています。

import React from 'react';

const ButtonA = (props) => {
  console.log('BUTTON-A RUNNING');
  return (
    <div className="btn" onClick={props.onClick}>
      {props.children}
    </div>
  );
};

export default React.memo(ButtonA);
import React from 'react';

const ButtonB = (props) => {
  console.log('BUTTON-B RUNNING');
  return (
    <div className="btn" onClick={props.onClick}>
      {props.children}
    </div>
  );
};

export default React.memo(ButtonB);

この時の挙動を確認します。

「Allow Toggle」「Toggle ボタン」「Toggle ボタン」の順に3回クリックします。

最初「Allow Toggle」をクリックすると「APP RUNNING」と「BUTTON-A RUNNING」が表示されます。

理由は、allowToggleの初期値がfalseからtrueに変わったことで、そもそもの親であるAppコンポーネントが再実行されたのと、ButtonAコンポーネントに渡されるpropsもallowToggleHandler(関数)なので毎回違う値としてみなされるのでButtonAコンポーネントも再実行されました。

ちなみに「BUTTON-B RUNNING」が表示されていないのは、この時点ではtoggleParagraphがuseCallbackのおかげで同じpropsとみなされているので、ButtonBコンポーネントは再実行されていません。

次に「Toggle ボタン」を押すと「APP RUNNING」「CHILD RUNNING」「GRANDCHILD RUNNING」「BUTTON-A RUNNING」の全てが出力されます。

理由は、「APP RUNNING」はshowParagraphの値が変わるのでApp.jsが再実行されます。

ChildコンポーネントもshowParagraphがfalseからtrueに変わっているのでReact.memo()を書いているが実行されてしまいます。

したがってその子であるGrandchildコンポーネントも実行されます。

「BUTTON-A RUNNING」だけが表示されるのはallowToggleHandlerが反応して出力されています。見た目は同じだけど、違うものとして判断されるのでした。

3回目もう一回「Toggle ボタン」を押すと先ほどと同じ結果になります。出力理由も同じです。

次にApp.jsをこのように変更してみます。

import React, { useState, useCallback } from 'react';
import Button from './components/Button';
import Child from './components/Child';

function App() {
  const [showParagraph, setShowParagraph] = useState(false);
  const [allowToggle, setAllowToggle] = useState(false);

  console.log('APP RUNNNING');

  const toggleParagraph = useCallback(() => {
    if (allowToggle) {
      setShowParagraph((prevShowParagraph) => !prevShowParagraph);
    }
  }, []);

  const allowToggleHandler = () => {
    setAllowToggle(true);
  };
  return (
    <div className="container">
      <h1>Hi there!</h1>
      <Child show={showParagraph} />
      <Button onClick={allowToggleHandler}>Allow Toggle</Button>
      <Button onClick={toggleParagraph}>Toggle ボタン</Button>
    </div>
  );
}

export default App;

toggleParagraph()の中にif文を入れました。

allowToggleの値がtrueの時にだけ動くようにしています。

今回は「Toggle ボタン」「Allow Toggle」「Toggle ボタン」の順番に3回クリックします。

1回目の「Toggle ボタン」を押しても何も反応しません。

理由はallowToggleの初期値がfalseのままだからです。

2回目の「Allow Toggle」を押すと「APP RUNNING」「BUTTON-A RUNNING」が出力されます。

これは先ほどの例の1回目と同じです。

重要なポイントはここでallowToggleの値がfalseからtrueに変更されたという点です。

3回目の「Toggle ボタン」を押すと、allowToggleがtrueに変更されているにも関わらず何も起きません。

これはなぜでしょう,,,?

ここでクロージャーが出てきます。

先ほど説明したようにクロージャーは実行した関数の値を保存しておく仕組みです。

toggleParagraph()の中にあるallowToggleは初期値ではfalseになっています。

この状態は「AllowToggle」を押してsetAllowToggleによってallowToggleがtrueになったとしても、クロージャーによって、toggleParagraph()内のallowToggleはfalseの状態で保存されています。

したがって「Toggle ボタン」を押しても何も起きないのです。

ここでuseCallback()の第二引数の登場です。

const toggleParagraph = useCallback(() => {
    if (allowToggle) {
      setShowParagraph((prevShowParagraph) => !prevShowParagraph);
    }
  }, [allowToggle]);

第二引数にallowToggleを入れることで、クロージャーの保存された値ではなく、常に最新の値を取得できるようになります。

先ほどと同様に「Toggle ボタン」「Allow Toggle」「Toggle ボタン」の順番に3回クリックしてみます。

1回目の「Toggle ボタン」は何も反応しません。これは先ほどと同じ理由でallowToggleの値がfalseだからです。

2回目の「Allow Toggle」をクリックすると「APP RUNNING」と「BUTTON-A RUNNING」と「BUTTON-B RUNNING」がそれぞれ出力されています。

これはallowToggleがfalseからtrueに変わったのでApp.jsが実行されたのと、それに伴って子コンポーネントである2つのButtonA、ButtonBコンポーネントが再実行されたからです。

ButtonBコンポーネントはuseCallback()を使っていますが、第二引数でallowToggleを設定しているので、これがtrueに変わったので実行されています。

3回目の「Toggle ボタン」をクリックすると「APP RUNNING」「CHILD RUNNING」「GRANDCHILD RUNNING」「BUTTON-A RUNNING」が出力されます。

これで本当に以上となります。

最後に

今回は子コンポーネントの再実行を制御する方法を解説しました。

かなり長くなりましたが、中規模以上のアプリを作るのであれば必ず必要な知識なりますので、ぜひ理解しましょう!

このコンテンツでは一般のエンジニアがどのように言語(今回はフレームワークになりますが)を学習し、つまずき、乗り越えていっているのかを垣間見えるようにしていこうと思います。

できるだけ画像、動画などを盛り込んで視覚的に理解できるように工夫しています。

少しでもこれからプログラミングを学ぼうと考えている方の為になる発信をできれば思いますので、温かい目で見てもらえると嬉しいです。

今回学習のメイン教材として使用しているのは、Udemyのコースになります。興味があればぜひ使ってみてください。

ベストセラー取得

-React学習, React学習ログ