⏱️

Reactの再レンダリングを制御する4Step

2023/08/06に公開

はじめに

不要な再レンダリングはパフォーマンスに影響を及ぼすことがあるため、必要に応じて適切に制御しなければなりません。この記事では、私、いぬいが最近のTodoアプリ開発で学んだ再レンダリング制御の4ステップを共有します。

対象読者

Reactアプリのパフォーマンスが気になりはじめた人。
関数コンポーネントをメモ化したが、うまく再レンダリング制御できなかった人。

想定環境

Reactの使用したWebアプリケーション開発

この記事で得られる情報

  • 関数コンポーネントのメモ化
  • メモ化したコンポーネントを再レンダリングしないための具体的な手法

注意

私はReactの初学者であり、この記事の内容に誤りや誤解を生む表現がるかもしれません。もしこの内容に誤りや改善点があれば、お気軽にコメントしていただけると幸いです。

不要な再レンダリングとは?

Reactコンポーネントにおいて、再レンダリングは次のような状況で発生します:

  • コンポーネント内の状態(State)が更新された場合
  • 親コンポーネントが再レンダリングされた場合

不要な再レンダリングの抑制は、パフォーマンスの向上に重要な役割を果たします。この記事では特に不要な再レンダリングとして、親コンポーネントの再レンダリングが原因で、変更がない子コンポーネントも再レンダリングされる状況に焦点を当て扱います。

0. React Devloper Tools の利用

Reactのパフォーマンスを改善する場合、まずはReact Devloper Toolsを利用しましょう。
いろいろと便利な機能がありますが、まずはレンダリングされたコンポーネントの可視化を設定することをおすすめします。

設定方法

  1. Componentsタグの歯車アイコン:View settings をクリックする。
  2. "Highlight updates when components render" のチェックを入れる。

1. memo (React.memo)を利用する

関数コンポーネントの宣言時やexport時にmemoを利用できます。
memoを利用することで関数コンポーネントはメモ化され、関数コンポーネントへ渡すpropsが変更されない限りは、この関数コンポーネントは再レンダリングされなくなります。

例えば下記のように使用できます。

MemorizedSample
import React, { memo } from 'react';
const MemorizedSample = ()=>{
 return <div>I'm memorized</div>;
}
export default memo(MemorizedSample);
App.jsx
import React from 'react';
import MemorizedSample from './MemorizedSample';
const App = () => {
  return(
    <MemorizedSample/>
  );
};
export default App;

2. useCallback, useMemoを利用する

実際のプロジェクトでは、コンポーネントにプロップス渡します。
一つ前で使ったコードを少し変えてプロップスを渡してみましょう。

App.jsx
import React from 'react';
import MemorizedSample from './MemorizedSample';
const App = () => {
  const userData = {name: "inui", age: 13};
  const handleClick = () => {
    console.log("Button Clicked");
  };
  return(
    <MemorizedSample userData={userData} onClick={handleClick}>
  );
};
export default App;

困ったことに、

Appコンポーネントで定義した関数や変数は、レンダリングされるたびに新たに生成されます。つまり、変数の参照先が再レンダリングごとに変わってしまいます。React.memoは参照の変化を検知して再レンダリングを行うため、結果として、Appが再レンダリングされるたびにMemorizeSampleも毎回再レンダリングされてしまいます。

そこで、useCallback, useMemoを利用します。

useCallback, useMemoはそれぞれ関数と戻り値をメモ化します。メモ化することで再生成を避け、変数の参照を維持できます。

App.jsx
import React, { useCallback, useMemo } from 'react';
import MemorizedSample from './MemorizedSample'
const App = () => {
  const userData = useMemo(()=>{name:"inui", age:13},[]);
  const handleClick = useCallback(() => {
    console.log("Button Clicked")
  }, []);
  return(
    <MemorizedSample userData={userData} onClick={handleClick}>
  );
}
export default App;

ただし、これらのメモ化にも計算コストがかかります。再レンダリングの抑制や重たい関数の戻り値のメモ化など目的がある場合のみ、パフォーマンスを計測しながら利用するとよいと思います。

3. useStateで関数を渡す。

前提として、

React公式ページなどにもあるように、状態を管理する階層が適切であるか考える必要があります。適切に"リフトアップ", "リフトダウン" することで不要なレンダリングを改善できると思います。

ここでは、

状態が適切に管理されているとして、状態を更新するハンドル関数をプロップスとして渡す場合について考えます。

App.jsx
import React, { useState, useCallback } from 'react';
import MemorizedButton from './MemorizedButton'
const App = () => {
  const [count, setCount] = useState(0);
  const handleClick = useCallback(() => {
    setCount(count + 1);
  }, []);
  return(
    <div>
      {count}
      <MemorizedButton onClick={handleClick}>
    </div>
  );
}
export default App;

ここで、MemorizedButtonはメモ化されたbuttonコンポーネントです。

困ったことに、

useCallbackで宣言したhandleClickは一度宣言されると更新されないため、その内部のcount変数は初期値0のまま固定(キャプチャー)されてしまいます。

すぐ思いつく解決策は、useCallbackの依存配列を利用することです。依存配列に指定された変数はuseCallbackの宣言時に変更を比較され、変更がある場合は関数を再生成します。

  const handleClick = useCallback(() => {
    setCount(count + 1);
  }, [count]);

しかしながらこの解決策では、countを更新するたびhandleClickは再生成され、それを受け取ったコンポーネントも再レンダリングされることになります。多くの場合でhandleClickが渡されるコンポーネントはbuttonなど、count変数の更新で見た目の変わらないコンポーネントでしょう。この見た目の変わらない再レンダリング避けたいと思います。

解決策として、

状態を更新するset関数に関数を渡すことにします。実はset関数は関数も引数に受けとることができ、渡された関数の引数には最新の状態変数を利用することができます。

  const handleClick = useCallback(() => {
    setCount((previousCount)=>{previousCount + 1});
  }, []);

handleClickは関数コンポーネントのマウント時に一度だけ生成され、子コンポーネントのプロップスが変更されることはなくなりました。

4. プリミティブ値を利用する

プロップスがBool値などのプリミティブな値をもつ変数の場合、値の決定は親コンポーネントで毎回判断されるが、プリミティブな値があまり変化しない場合あるかと思います。

具体的には以下のような状況です。

App.jsx
import React from 'react';
import MemorizedButton from './MemorizedButton'
const App = () => {
const isTrue = complexEvaluateFunction();
  return(
    <div>
      {count}
      <MemorizedSample isTrue={isTrue}>
    </div>
  );
}
export default App;

complexEvaluateFunctionは毎回実行される必要があるとします。しかし、MemorizedSampleの再レンダリングはbool値の結果が変化したときだけで十分なはずです。

プリミティブ値を直接プロップスへ渡すことで、

再レンダリングを制御できます。

App.jsx
import React from 'react';
import MemorizedButton from './MemorizedButton'
const App = () => {
const isTrue = complexEvaluateFunction();
  return(
    <div>
      {count}
      <MemorizedSample isTrue={Boolean(isTrue)}>
    </div>
  );
}
export default App;

余談

私が再レンダリングの制御を学ぶことになったきっかけは開発中のToDoアプリにMaterial UIを導入したことです。それまではパフォーマンスに一切気を使っていなかったことに加えて、undo, redoを実装していたため、状態管理はすべてまとめて一番上のコンポーネントで行っていました。そして、ページのすべてのToDoリストが毎回再レンダリングされ、各タスクに付随するMaterial UIによってレンダリングが重くなった結果、カクカク動く便利ツールが爆誕しました。

本記事では、再レンダリングの制御としてメモ化を中心に扱いました。関数やコンポーネントのメモ化はそれ自体が処理の負担になるため、適切に運用する必要があるようです。実際の導入では、アプリの動作やReact developer toolsのプロファイラーなどを用いて、パフォーマンスを確認しながら行うとよいと思います。

Discussion