💬

useMemo と useCallbackをいつ使用するか

2023/02/02に公開

お菓子の自動販売機があります。

Candy Dispenser
* Available Candy
* grab snickers
* grab skittles
* grab twix
* grab milky way

こちらが実装になります。

function CandyDispenser() {
  const initialCandies = ['snickers', 'skittles', 'twix', 'milky way']
  const [candies, setCandies] = React.useState(initialCandies)
  const dispense = candy => {
    setCandies(allCandies => allCandies.filter(c => c !== candy))
  }
  return (
    <div>
      <h1>Candy Dispenser</h1>
      <div>
        <div>Available Candy</div>
        {candies.length === 0 ? (
          <button onClick={() => setCandies(initialCandies)}>refill</button>
        ) : (
          <ul>
            {candies.map(candy => (
              <li key={candy}>
                <button onClick={() => dispense(candy)}>grab</button> {candy}
              </li>
            ))}
          </ul>
        )}
      </div>
    </div>
  )
}

さて、ここで皆さんに質問をしたいのですが、上記のコードに手を加えるのでどちらの方がパフォーマンスが良くなるのか教えて欲しいです。

dispense関数をReact.useCallbackでラッピングする方法。

const dispense = React.useCallback(candy => {
  setCandies(allCandies => allCandies.filter(c => c !== candy))
}, [])

もう一つはそのままの実装です。

const dispense = candy => {
  setCandies(allCandies => allCandies.filter(c => c !== candy))
}

さてこの場合、どちらがパフォーマンス的に優れているでしょうか?

original

正解!!

useCallback

不正解..

答えのネタバレにならないようにスペースを空けて...
 
 
 
 
 
 
 
 
 
 
 
更にスクロールして.. ちゃんと答えましたか??
 
 
 
 
 
 
 
 
 
 
 

なぜuseCallbackの方が悪いのか?

インライン関数はパフォーマンス上問題があるのでパフォーマンス向上の為にReact.useCallbackを使うべきということをよく聞きますが、なぜuseCallbackを使わない方が良いのでしょうか?

具体例から、そしてReactからも一歩引いて実行されるコード全てにコストがかかることについて考えてみてください。useCallbackの例を少しリファクタして(実際の変更はなく、コードを分離しただけです)、より明確に説明しましょう。

const dispense = candy => {
  setCandies(allCandies => allCandies.filter(c => c !== candy))
}
const dispenseCallback = React.useCallback(dispense, [])

そしてここでオリジナルを再掲します。

const dispense = candy => {
  setCandies(allCandies => allCandies.filter(c => c !== candy))
}

何か気付きましたか? 差分を見てみましょう

const dispense = candy => {
  setCandies(allCandies => allCandies.filter(c => c !== candy))
}
+ const dispenseCallback = React.useCallback(dispense, [])

useCallbackバージョンの方がより多くの作業を行なっている以外は、全く同じです。
関数を定義するのではなく、配列([])を定義してReact.usecallbackを呼び出し、それ自体がプロパティの設定/論理式の実行などを行なっています。

つまり、どちらの場合もJavaScriptはレンダリングの度に関数定義のためのメモリを確保しなければならず、useCallbackの実装によっては、より多くのメモリを確保するかもしれません(実際にはそうではありませんが、要点は変わりません)。これが、私がこちらのTwitter投票で伝えようとしたことです。

https://twitter.com/kentcdodds/status/1135943012410830848?s=20&t=pxotLSdspVGkJ_TlFH5iDg

コンポーネントの2回目のレンダリング時に、オリジナルのdispense関数がガベージコレクション(メモリ領域が開放)され、その後新しい関数が作成されることにも触れておきたいと思います。しかし、useCallbackを使用すると、オリジナルのdispense関数はガベージコレクションされず、新しい関数が作成されるため、メモリの観点からも不利になります。

関連する事として、依存関係がある場合、Reactが以前の関数への参照を保持している可能性が高いです。なぜなら、メモ化は通常、以前の値と同じ依存関係を得た場合に返すための古い値のコピーを保持することを意味するからです。特に勘のいい人は、Reactも等価であるかを確認する為に依存関係の参照を保持しなければならないことに気付くでしょう。(ちなみに、クロージャのおかげでどのみち起こっていることですが、とにかく言及する価値のあることです。)

よく似たuseMemoは?

useMemouseCallbackと似ていますが、あらゆる値の型(関数だけでなく)にメモ化を適用できる点が異なります。これは値を返す関数を受け取り、その関数は値を取得する必要がある場合のみ呼び出されます。(通常、レンダリング中に依存関係の配列内の要素が変更される度に一度だけ実行されます。)

つまり、レンダリングの度にinitialCandiesの配列を初期化したくない場合は、このように変更すればよいのです。

- const initialCandies = ['snickers', 'skittles', 'twix', 'milky way']
+const initialCandies = React.useMemo(
+  () => ['snickers', 'skittles', 'twix', 'milky way'],
+  [],
+)

そして、その問題を回避したいのですがやれることはごくわずかで、コードをより複雑にするコストに見合うものではありません。実際、このような場合にもuseMemoを使うことは悪いことかもしれません。なぜなら、ここでも関数を呼び出して、そのコードでプロパティの割り当てなどを行なっているからです。

この特別なシナリオでの更に良い方法は下記の変更を行うことです。

+ const initialCandies = ['snickers', 'skittles', 'twix', 'milky way']
function CandyDispenser() {
-   const initialCandies = ['snickers', 'skittles', 'twix', 'milky way']
  const [candies, setCandies] = React.useState(initialCandies)

しかし、時には上記のような贅沢は許されません。なぜなら、propsや関数本体の中で初期化された変数から渡される場合があるからです。

要はどっちでも良いのです。そのコードの最適化することのメリットは非常に小さく、プロダクトをより良くすることに時間を費やした方がずっと良いのです。

ポイントは?

要はこういうことです。

パフォーマンスの最適化はタダではありません。最適化には常にコストが掛かりますが、そのコストを相殺するようなメリットが常にあるわけではありません。

したがって、責任を持って最適化する必要があります。

では、どのような場合にuseMemouseCallbackを使えば良いの?

この2つのフックがReactに組み込まれているのには、特別な理由があります。

  1. Referential equality(参照元が同じ)
  2. Computationally expensive calculations(重い処理)

Referential equality

あなたがJavaScript初心者の場合、その理由が分かるまでにそう時間は掛からないでしょう。

true === true // true
false === false // true
1 === 1 // true
'a' === 'a' // true

{} === {} // false
[] === [] // false
(() => {}) === (() => {}) // false

const z = {}
z === z // true

// NOTE: React actually uses Object.is, but it's very similar to ===

あまり深入りするつもりはないですが、React関数コンポーネント内でオブジェクトを定義した時、その同じオブジェクトが前回定義された時と参照的に等しくなることはない(たとえ、全てのプロパティが同じ値であっても)、とだけ言っておけば十分だろう。

ReactでReferential equalityが問題となる場合は2つあり、1つずつ見ていきましょう。

依存関係のリスト

例を見てみましょう。

function Foo({bar, baz}) {
  const options = {bar, baz}
  React.useEffect(() => {
    buzz(options)
  }, [options]) // we want this to re-run if bar or baz change
  return <div>foobar</div>
}

function Blub() {
  return <Foo bar="bar value" baz={3} />
}

これが問題になるのは、useEffectがレンダリングの度にoptionsの参照一致を確認しますが、JavaScriptのおかげでoptionsは毎回新しくなる事です。そのため、Reactがレンダリング中にoptionsが変更されたかどうかをテストする時は、常にtrueと評価されます。つまり、useEffectコールバックはbarbazが変化した時ではなくレンダリング毎に呼び出されるようになります。

これを解決するために、できることは2つあります。

// option 1
function Foo({bar, baz}) {
  React.useEffect(() => {
    const options = {bar, baz}
    buzz(options)
  }, [bar, baz]) // we want this to re-run if bar or baz change
  return <div>foobar</div>
}

これは素晴らしいオプションで、もしこれが実際のシチュエーションなら私はこの方法で修正します。

しかし、これが実用的な解決策でない場合が1つあります。bar,bazが(非プリミティブな)オブジェクト/配列/関数/その他である場合です。

function Blub() {
  const bar = () => {}
  const baz = [1, 2, 3]
  return <Foo bar={bar} baz={baz} />
}

これこそがuseCallbackuseMemoが存在する理由なのです。では、どのような修正を行うかというと次のようになります。(今は全部まとめて)

function Foo({bar, baz}) {
  React.useEffect(() => {
    const options = {bar, baz}
    buzz(options)
  }, [bar, baz])
  return <div>foobar</div>
}

function Blub() {
  const bar = React.useCallback(() => {}, [])
  const baz = React.useMemo(() => [1, 2, 3], [])
  return <Foo bar={bar} baz={baz} />
}

React.memo(and friends)

こちらをご覧ください。

function CountButton({onClick, count}) {
  return <button onClick={onClick}>{count}</button>
}

function DualCounter() {
  const [count1, setCount1] = React.useState(0)
  const increment1 = () => setCount1(c => c + 1)

  const [count2, setCount2] = React.useState(0)
  const increment2 = () => setCount2(c => c + 1)

  return (
    <>
      <CountButton count={count1} onClick={increment1} />
      <CountButton count={count2} onClick={increment2} />
    </>
  )
}

これらのボタンのいずれかをクリックする度に、DualCounterのステート変化するので再レンダリングし、その結果、両方のCountButtonが再レンダリングすることになります。しかし、実際に再レンダリングが必要なのは、クリックされたボタンのみのはずですよね? つまり、最初のボタンをクリックすると、2番目のボタンが再レンダリングされますが、何も変化はありません。これを私達は不要な再レンダリングと呼んでいます。

ほとんどの場合、不要な再レンダリングを最適化する必要はないでしょう。

Reactは非常に高速で、このような最適化よりも、時間を掛けてやるべき事がたくさんあると思います。実際、これからお見せするもので最適化する必要性は非常に稀で、私がPayPalのプロダクトで働いた3年間、そして更に長い期間において、文字通り一度も必要なことはありませんでした。

しかし、レンダリングにかなりの時間がかかる場合があります(高度な相互作用を持つグラフ/チャート/アニメーションなどを考えてみてください)。Reactの実用的な性質のおかげで、逃げ道があります。

const CountButton = React.memo(function CountButton({onClick, count}) {
  return <button onClick={onClick}>{count}</button>
})

これでReactはCountButtonのpropsが変更された時のみ再レンダリングするようになりました。しかし、まだ終わりではありません。参照の等価性というのを覚えていますか? DualCounterコンポネートでは、increment1,increment2をコンポネート内で定義しています。これはDualCouterが再レンダリングする度に、これらの関数が新しくなり、それゆえにReactはCountButtonの両方を再レンダリングすることを意味します。

つまり、これはuseCallbackuseMemoが役立つもう1つの状況です。

const CountButton = React.memo(function CountButton({onClick, count}) {
  return <button onClick={onClick}>{count}</button>
})

function DualCounter() {
  const [count1, setCount1] = React.useState(0)
-  const increment1 = () => setCount1(c => c + 1)
+  const increment1 = React.useCallback(() => setCount1(c => c + 1), [])

  const [count2, setCount2] = React.useState(0)
-  const increment1 = () => setCount2(c => c + 1)
+  const increment2 = React.useCallback(() => setCount2(c => c + 1), [])

  return (
    <>
      <CountButton count={count1} onClick={increment1} />
      <CountButton count={count2} onClick={increment2} />
    </>
  )
}

これでCountButtonの「不要な再レンダリング」を回避することができます。

計測せずにReact.memo(もしくはその友人であるPureComponentshouldComponentUpdate)を使用しないことを強くお勧めすることを再度述べたいと思います。なぜなら、これらの最適化にはコストが掛かるため、そのコストに関連する利益を確認し、実際にあなたのケースに役立つかどうか(有害でないかどうか)を判断する必要があるからです。
上記で述べたようにメリットが全く得られない可能性があります

Computationally expensive calculations

useMemoがReactの組み込みフックであるもう1つの理由がこれです(こちらはuseCallbackに当てはまらない事に注意してください)。useMemoの利点は、次のような値を取れる事です。

const a = {b: props.b}

そして遅延評価を行う

const a = React.useMemo(() => ({b: props.b}), [props.b])

これは上記のケースではあまり役に立ちませんが、計算コストの掛かる値を同期的に計算する関数があるとします(実際にこのような素数計算が必要なアプリがどれだけあるかという事ですが、これは一例です。)

function RenderPrimes({iterations, multiplier}) {
  const primes = calculatePrimes(iterations, multiplier)
  return <div>Primes! {primes}</div>
}

このコードは適切なiterationsもしくはmultiplierが渡されると非常に処理が遅くなる可能性があり、
具体的にどうすれば良いのかはあまり分かりません。ユーザーのハードウェアを自動的に速くすることはできません。しかし、同じ値を2回続けて計算する必要がないようにすることはできます。それがuseMemoの役割です。

function RenderPrimes({iterations, multiplier}) {
  const primes = React.useMemo(
    () => calculatePrimes(iterations, multiplier),
    [iterations, multiplier],
  )
  return <div>Primes! {primes}</div>
}

これがうまくいく理由は、レンダリング毎に素数を計算する関数を定義しても(これは非常に高速です)、Reactは値が必要な時にだけその関数を呼び出すからです。その上で、Reactは入力された前の値も保存し、同じ前の入力があれば前の値を返すようにする。これがメモ化です。

結論

最後に、全ての抽象化(そしてパフォーマンス最適化)にはコストが掛かることを申し上げたいと思います。
本当に必要になるまで抽象化/最適化を行わないAHA Programming principleを適用することで、コストがかかってもメリットが得られないという事態を避けることができます。

具体的には、useCallbackuseMemoの代償として、同僚にとってコードを複雑にしてしまうこと、依存関係の配列でミスをすること、組み込みフックを呼び出して依存関係やメモした値がガベージコレクションされないようにしてパフォーマンスを悪化させる可能性があることなどです。必要な性能が得られるのであれば、それらは全て十分なコストですが、まずは測定してみるのが一番です

関連記事:

追記

フックの移行に伴い、クラスのメソッドとして定義していたものを関数コンポーネントの中で定義しなければならなくなることを心配される方がいらっしゃいますが、私達は当初からコンポネートのレンダーフェーズででメソッドを定義しているという事実を考慮して頂きたいと思います...。例えば:

class FavoriteNumbers extends React.Component {
  render() {
    return (
      <ul>
        {this.props.favoriteNumbers.map(number => (
          // TADA! This is a function defined in the render method!
          // Hooks did not introduce this concept.
          // We've been doing this all along.
          <li key={number}>{number}</li>
        ))}
      </ul>
    )
  }
}

Discussion