DEV Community

kaede
kaede

Posted on • Edited on

React.memo と useCallbackで state の変化に伴う{個別,共通}コンポーネントの再描画を抑制する

why

パフォーマンスチューニングのやり方を整理したかった

参考動画

https://www.youtube.com/watch?v=KXhE1tBBfJc

あべちゃんさんの React Hooks の動画

【ReactHooks入門】第6回:useCallbackの理解

を参考にしました。


そもそもなぜ React の useState は値が変わって再描画されるのか

https://zenn.dev/taroro28/articles/3bec0f3f4711e9#%E4%BD%99%E8%AB%87%3A-%E5%90%8C%E3%81%98%E5%80%A4%E3%81%A7setstate()%E3%81%97%E3%81%9F%E3%82%89%E5%86%8Drender%E3%81%95%E3%82%8C%E3%82%8B%E3%81%AE%E3%81%8B%EF%BC%9F

taroro28 さんの Zenn のこの記事に答えがあった

react/packages/react-reconciler/src/ReactFiberHooks.new.js
Enter fullscreen mode Exit fullscreen mode

react ライブラリのここで setState が行われたときに
eagerState, currentState の比較が行われる
それで違う場合に再描画が起こるらしい。


CRA

npx create-react-app pf --template typescript
Enter fullscreen mode Exit fullscreen mode

pf という名前で CRA


Title コンポーネントを作成

https://www.youtube.com/watch?v=KXhE1tBBfJc&t=510s

type Props = { titleText: string }
const Title: React.FC<Props> = ({titleText}) => {
  return (
      <h2> {titleText} </h2>
  );
}
export default Title;
Enter fullscreen mode Exit fullscreen mode

App から Props として受け取る titleText を
そのまま Props という型で定義して

コンポーネントに React.FC にはめて titleText を引数処理して
h2 でラップして 返す処理を書いた。


App で titleText を渡して呼び出す

import Title from './components/Title'
function App() {
  const titleText = '#6 useCallback'
  return (
    <div className="App">
      <Title titleText={titleText} />
    </div>
  );
}
export default App;
Enter fullscreen mode Exit fullscreen mode

Image description

これで読み込めた。

Subtitle も同じように作る


A と B のカウンターを div で追加

これらに console.log を仕込んでも最初は一度ずつしか読み込まれない。
useState とか一切ないから。

しかし、ここに状態を持ち込んで変化させる関数を useState で導入すると
問題が表明化する。

function App() {
  const [countA, setCountA] = useState<number>(0)
  const [countB, setCountB] = useState<number>(0)

  const titleText = '#6 useCallback'
  const subTitleText = 'アンケート'

  return (
    <div className="App">
      <Title titleText={titleText} />
      <SubTitle subTitleText={subTitleText} />
      <div>{countA}</div>
      <div>{countB}</div>
      <button onClick={ () => setCountA(countA+1)}>A  1 </button>
      <button onClick={ () => setCountB(countB+1)}>B  1 </button>
    </div>
  );
}
export default App;
Enter fullscreen mode Exit fullscreen mode

countA と countB の状態を 0 で作って
押すと加算される ボタンをそれぞれ作る。


Title, SubTitle に console.log を追加する

type Props = { titleText: string }
const Title: React.FC<Props> = ({titleText}) => {
  console.log('Title rendered');

  return (
      <h2> {titleText} </h2>
  );
}
export default Title;
Enter fullscreen mode Exit fullscreen mode

Title と SubTitle の内部で console.log を呼ぶようにしておく


countA, countB が動いた時に Title, SubTitle まで再度読み込まれているところを確認る

Image description

そうすると、countA, countB が変更して再レンダリングされたタイミングで
全く関係ない Title, SubTitle コンポーネントまで再度読み込まれてしまっているのがわかる。

これを useMemo を使って改善していく。


Title コンポーネントで React.memo を引数に追加して中身を () で囲う。

const Title: React.FC<Props> = React.memo(({titleText}) => {
  console.log('Title rendered');
  return (
      <h2> {titleText} </h2>
  );
})
Enter fullscreen mode Exit fullscreen mode

Image description

すると count の変化で Title コンポーネントが変化しなくなる。

一方、Button を共通コンポーネント化すると
countA が動いた時でも ButtonA だけでなく
ButtonB まで動いてしまう問題が残っている。

これを


Counter コンポーネントを作成して使って countA, countB を表示するようにする

import React from 'react'
type Props = { 
  counterTitle: string;
  count: number;
}

const Counter: React.FC<Props> = React.memo(({counterTitle, count}) => {
  console.log(`Counter: ${counterTitle} rendered`);

  return (
      <div> {counterTitle}: <span>{count}</span></div>
  );
})
export default Counter;
Enter fullscreen mode Exit fullscreen mode

counterTitle と count を受け取って表示するコンポーネントを作って

      <Counter counterTitle={'A'} count={countA} />
      <Counter counterTitle={'B'} count={countB} />
Enter fullscreen mode Exit fullscreen mode

App で呼び出す


Button コンポーネントを共通化して onClick と buttonText を受け取って {A,B} に一票を動かせるようにする

import React from 'react'
type Props = {
  buttonText: string;
  onClick: () => void;
};

const Button: React.FC<Props> = React.memo(({ buttonText, onClick }) => {
  console.log(`Button:${buttonText} rendered`);

  return (
    <div >
      <button onClick={onClick} type='button' >
        {buttonText}
      </button>
    </div>
  );
});

export default 
Enter fullscreen mode Exit fullscreen mode

buttonText と onClick を受けとる
Button コンポーネントを作って

      <Button onClick={handleCountUpA} buttonText='A に 1 票' />
      <Button onClick={handleCountUpB} buttonText='B に 1 票' />
Enter fullscreen mode Exit fullscreen mode

A に一票追加
B に一票追加

これらを App でこのコンポーネントで動かす。


handleCountUp{A,B} に useCallback を count{A,B} を引数で組み込んで動作時に App 全体を読み込まれないようにする

  const handleCountUpA = () => {
    setCountA(countA + 1)
  }
Enter fullscreen mode Exit fullscreen mode

この handleCountUpA を

  const handleCountUpA = useCallback(() => {
    setCountA(countA + 1)
  }, [countA])
Enter fullscreen mode Exit fullscreen mode

useMemo と同じように 引数から関数が閉じるまでの
() => {} の部分を useCallback() でくくる。

useEffect と同じように対象の変数を指定する(必須)


useCallBack

useCallBack なしで B に一票ボタンをクリックすると

Image description

A ボタンまで再度読み込まれてしまう。

ここにさっきの useCallback を追加して
B ボタンを押すと

Image description

今度は B ボタンのみが再描画された。


まとめ

通常は App の useState で実装された状態変数が変更されると
中の全てのコンポーネントが再描画されてしまう。
これは計算の無駄であり、パフォーマンスの低下につながる。

const Title: React.FC<Props> = React.memo(({titleText}) => {
  console.log('Title rendered');

  return (
      <h2> {titleText} </h2>
  );
})
Enter fullscreen mode Exit fullscreen mode

全く別のコンポーネントであれば
このように React.memo() で引数から関数の終わりまで括ると
引数に変化がないときは、関係ない状態変数が変化しても
再描画されないようになる。

Button のように複数のコンポーネントで関数を渡して使われている汎用コンポーネントでは、React.memo() を使っても、どれかの Button が使われるたびに全ての Button が再描画されてしまう。

そこで Button の onClick に仕込むハンドル関数自体に

  const handleCountUpA = useCallback(() => {
    setCountA(countA + 1)
  }, [countA])
Enter fullscreen mode Exit fullscreen mode

このように useCallback() で括って引数に特定の状態変数を取れば
その状態引数を使ったコンポーネントのみ描画されるようになる。

countA, countB の状態があってそれぞれ
ButtonA, ButtonB があると
countA の変化で ButtonA のみが再描画されるようになる。

Top comments (0)