iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

Reactで再レンダリングを最適化する

こんにちは!

株式会社iimonでエンジニアをしている遠藤です。

最近業務でReactを触るので、再レンダリングについて勉強したことをアウトプットしようと思い記事にしました。

ハンズオンで挙動を試したい場合はcodeSandboxNew sandbox > React Typescriptを選択して、コードをコピペしていただければ、挙動を試せるかと思います。 ※ デフォルトで開発環境はStrictModeになっているので、1回の更新につき2回再レンダリングされます。
※ 記事ではレンダリング回数をわかりやすくするために、StrictModeにはせずに実行しています。
参考: https://blog-mayo.com/2022/10/1073/

レンダリングとは


reactの定義するレンダリングとは、簡単にいうと「コンポーネントをReactが呼び出すこと」を指すようです。

詳しいことはこの記事では触れませんが、下記の記事が参考になるかと思います。 https://zenn.dev/1129_tametame/articles/bf4fc2005bea4d

レンダリングされるタイミング


に再レンダリングされます。

例えば以下のように

App.tsx

import { useState } from "react";
import Child from "./Child";

const App = () => {
  const [text, setText] = useState("");
  const [count, setCount] = useState(0);
  console.log("親コンポーネントがレンダリングされました")

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value)
  }

  const handleClick = () => {
    setCount(count + 1)
  }

  return (
    <>
    <input onChange={handleChange} />
    <button onClick={handleClick}>カウントアップ</button>
    <Child text={text}/>
    </>
  )
} 

export default App;

Child.tsx

type ChildrenType = {
    text: string
}

const Child = ({text}: ChildrenType) => {
    console.log("子コンポーネントがレンダリングされました")
    return (
        <div>{text}</div>
    )
}
export default Child

text, countの値がそれぞれ更新された場合は、親コンポーネントと子コンポーネントがどちらも再レンダリングされます。

まず、textFieldに値を入力するとtextの値が更新されるため、親コンポーネントが文字数(stateが更新された回数)分再レンダリングされます。
また子コンポーネントにおいても、propsで値を受け渡しているかつ、親コンポーネントが再レンダリングされている為、同様の回数再レンダリングされていることがわかります。

また、カウントアップのボタンを押すと、同様に親コンポーネントでは押した回数(stateが更新された回数)分再レンダリングがされます。そして、子コンポーネントではpropsを受け渡していなくても、親コンポーネントの再レンダリングを受けて、同様の回数再レンダリングされていることがわかります。

改善


上記の例では、countが更新されても、子コンポーネントでは同様の結果を返します。そのため、本来は更新の必要がない子コンポーネントが再レンダリングされてるというのは、無駄な処理であることがわかります。

このような無駄な計算や処理を制御するために用意されているReactの機能が

  1. memo
  2. useCallback
  3. useMemo

です。

これらを用いることで、結果の更新が必要になるまでは、計算結果を保持して再利用することができます。(メモ化いいます!)

具体的にそれぞれの使い方について触れていきます。

memo


memoはコンポーネントをメモ化する機能です。

上記のようなコードの例ではmemoを利用することで、再レンダリングを最適化できます。

コンポーネントをmemo化すると、親コンポーネントが再レンダリングされた場合に、子コンポーネントに渡されるpropsの値が更新された場合のみ、子要素を再レンダリングすることができます。

実際に先ほどのChild.tsxのコードを以下のように修正します。

import { memo } from "react"

type ChildrenType = {
    text: string
}

// 修正箇所 memoを追加
const Child = memo(({text}: ChildrenType) => {
    console.log("子コンポーネントがレンダリングされました")
    return (
        <div>{text}</div>
    )
})
export default Child

すると、countの値が更新されて親コンポーネントが再レンダリングされても、子コンポーネントでは再レンダリングされなくなったことがわかります。

useCallback


useCallbackは用いると、メモ化したコールバック関数を返すことができるHooks APIです。

そのため、内容の更新が必要な際のみ、関数を再計算することができます。

というのも、関数をpropsで渡すと、レンダリングするごとに「再生成された関数がpropsとして渡された」と子コンポーネントで認識されてしまいます。

先ほど例に挙げたコードを以下のように修正してみるとわかりやすいかと思います。

App.tsx

...

  return (
    <div className="flex">
        {/** 修正箇所 inputタグをChild.tsxに記述してhandleChangeをpropsとして渡す */ }
    <Child text={text} handleChange={handleChange} />
    <button onClick={handleClick}>カウントアップ</button>
    <div>{count}</div>
    </div>
  )
}

export default App;

Child.tsx

import { memo } from "react"

type ChildrenType = {
    text: string
        // 修正箇所 handleChangeをpropsとして受け取る
    handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}

const Child = memo(({text, handleChange}: ChildrenType) => {
    console.log("子コンポーネントがレンダリングされました")
    return (
        <>
                {/** 修正箇所 inputタグを追加 */}
         <input onChange={handleChange} />
         <div>{text}</div>
        </>
    )
})
export default Child

コンポーネントをmemo化しているにも関わらず、countの値を更新すると再レンダリングされていることがわかります。

そこで、propsとして渡しているhandleChangeをmemo化します。

useCallbackの第一引数に実行したい処理、第二引数に見張る値(特定の値が更新された際に関数を再計算する為の値)を入れます。

今回の例では、setTextが更新された時のみ関数を再計算します。

const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value)
  }, [setText])

countの値を更新しても、子コンポーネントが再レンダリングされなくなったことがわかります。

useMemo


useMemoは、変数自体のメモ化ができるHooks APIです。

例えば、App.tsx下記のように編集し、入力された文字数を表示するとします。

...

  // 追記 文字数をカウントする関数を追加
  const countTextLength = () => {
    console.log("countTextLengthが呼ばれました")
    return text.length
  }

  return (
    <div className="flex">
    <Child text={text} handleChange={handleChange} />
    {/** 追記 文字数を表示 */}
     <div>{`入力文字数: ${countTextLength()}`}</div>
    <button onClick={handleClick}>カウントアップ</button>
    {/** なんの値かわかりやすくするためラベルを追加*/}
    <div>{`カウント数: ${count}`}</div>
    </div>
  )
}

export default App;

すると、countの値の更新により再レンダリングがされるたびに、countTextLenghが実行されていることがわかります。

けれども、countTextLengthの値はtextの値が更新された場合のみ再計算されれば良いので、useMemoを用いて変数を以下のようにメモ化します。

...

  // 修正 memo化
  const countTextLength = useMemo(() => {
    console.log("countTextLengthが呼ばれました")
    return text.length
  }, [text])

  return (
    <div className="flex">
    <Child text={text} handleChange={handleChange}/>
    {/** 修正 関数の結果が変数に入るため */}
     <div>{`入力文字数: ${countTextLength}`}</div>
    <button onClick={handleClick}>カウントアップ</button>
    <div>{`カウント数: ${count}`}</div>
    </div>
  )
}

export default App;

すると、countの値の更新により再レンダリングがされても、countTextLengthでは余計な再計算が行われなくなったことがわかります。

まとめ


memo化を上手く使うことで、不要な再レンダリングや再計算を減らしパフォーマンスを向上することができます。

ただ、何も考えずに使ってしまうと適切なところで再レンダリングや再計算がされず、バグに繋がってしまうことがあるので、使う際には注意しましょう!

レンダリングエラーを避けるために、useEffectを使って関心を分離するなどのことにも触れたかったのですが、そこはまた別の機会にします。

最後まで読んでいただきありがとうございました!

参考:

https://zenn.dev/b1essk/articles/react-re-rendering

https://zenn.dev/b1essk/articles/react-re-rendering

https://zenn.dev/azukiazusa/articles/react-rerender-patterns

https://qiita.com/seira/items/8a170cc950241a8fdb23

https://qiita.com/seira/items/42576765aecc9fa6b2f8