ReactのuseMemoはすべての引数の計算結果をキャッシュしているわけではない

ReactのuseMemoは誤用が多い

Reactのメモ化にはReact.memo, useCallback, useMemoの3つが使用されますが、useMemoの使い方を誤解している人をよく見かけます。

特に多いのが、一度指定した引数が次回以降に使用されたときにキャッシュが使用されるという誤解です。

ReactのuseMemoとは

useMemoはReactのフックの一つで、パフォーマンスを最適化するために使用されます。

主に計算結果をメモ化(キャッシュ)することで、不要な再計算を避けるために利用されます。

Reactの公式サイトではuseMemoの引数について、以下のように書かれています。

calculateValue: キャッシュしたい値を計算する関数。純関数で、引数を取らず、任意の型の何らかの値を返す必要があります。React は初回レンダー中にこの関数を呼び出します。次回以降のレンダーでは、直前のレンダーと dependencies が変化していなければ、同じ値を再度返します。dependencies が変化していれば、calculateValue を呼び出してその結果を返し、同時に、後から再利用するためにその結果を保存します。

useMemo(calculateValue, dependencies) 

つまり、引数の結果をすべて保存しているわけではないのです。

useMemoが直前以外もキャッシュしているか試すサンプル

ボタンを押した際に引数に1, 2, 3を順に渡しすサンプルを試しに作成しました。

3の次は1に戻って 1, 2, 3のループになります。

TSX
import { useState, useMemo } from 'react'

const HeavyComponent = ({ data }: { data: number }) => {
  const result = useMemo(() => {
    let num = 0
    // 重い処理
    for (let i = 0; i < 1000000000; i++) {
      num = data * 2
    }
    return num
  }, [data])

  return <p>Result: {result}</p>
}

const App = () => {
  const [count, setCount] = useState(0)

  const handleClick = () => {
    if (count < 3) {
      setCount((count) => count + 1)
    } else {
      setCount(1)
    }
  }

  return (
    <>
      <button id="count" onClick={handleClick}>count: {count}</button>
      <HeavyComponent data={count} />
    </>
  )
}

export default App

useMemoが直前以外もキャッシュしているか試すサンプル

サンプルを試すと、1, 2, 3, 1, 2, 3 ... と引数が変化していますが、デベロッパーツールのPerformanceを確認すると、常に処理が重く、過去の引数と結果を記憶して返していないことがわかります。

ReactのuseMemoはすべての引数の計算結果をキャッシュしているわけではない

Mapオブジェクトに結果をキャッシュする

過去の引数と結果をすべて記録して返すには、useMemoではなくMapオブジェクトを使用します。

やり方はnew Map()でMapオブジェクトを生成して、has()で過去に取得した引数か判定し、過去に取得した引数ならget()でMapオブジェクトに保存されている値を返して、過去に取得していなければset()で保存します。

TSX
import { useState } from 'react'

const memoMap: Map<number, number> = new Map()

interface HeavyComponentProps {
  data: number
}

const HeavyComponent: React.FC<HeavyComponentProps> = ({ data }) => {
  const result = (): number => {
    if (memoMap.has(data)) {
      return memoMap.get(data)!
    }

    let computedResult = 0
    // 重い処理
    for (let i = 0; i < 1000000000; i++) {
      computedResult = data * 2
    }

    memoMap.set(data, computedResult)
    return computedResult
  }

  return <p>Result: {result()}</p>
}

const App: React.FC = () => {
  const [count, setCount] = useState<number>(0)

  const handleClick = (): void => {
    if (count < 3) {
      setCount((count) => count + 1)
    } else {
      setCount(1)
    }
  }

  return (
    <>
      <button id="count" onClick={handleClick}>
        count: {count}
      </button>
      <HeavyComponent data={count} />
    </>
  )
}

export default App

ReactでuseMemoではなくMapオブジェクトでキャッシュするサンプル

MapオブジェクトでキャッシュするサンプルをChromeデベロッパーツールのPerformanceでInteractionsを確認すると、最初の1, 2, 3の引数を取得した際の処理は重いですが、次の1, 2, 3 はMapオブジェクトのキャッシュが使用されるので処理が軽くなっていることがわかります。

ReactでuseMemoではなくMapオブジェクトでキャッシュするサンプル

処理時間も10分の1以下になっているので、体感だけでなく数値からも軽くなっていることがわかります。

useMemoだと使用しても「直前のレンダーと dependencies が変化していなければ、同じ値を再度返す」という特性上、軽くなったことを体感しにくいので、記載したサンプルのような内容であればMapオブジェクトでキャッシュした方が良いです。

以下のようなIDを入力して、APIのJSONを取得するような場合もMapオブジェクトのキャッシュが適しているので、覚えておくと良いでしょう。

TSX
import { useState, ChangeEvent } from 'react'
import axios from 'axios'

const memoMap = new Map<string, string>()

type User = {
  id: number
  name: string
}

const App = () => {
  const [num, setNum] = useState<string>('')
  const [userName, setUserName] = useState<string>('')

  const handleChange = async (e: ChangeEvent<HTMLInputElement>) => {
    const targetId = e.target.value
    if (memoMap.has(targetId)) {
      setNum(targetId)
      setUserName(memoMap.get(targetId)!)
      return
    }
    setNum(targetId)

    const { data } = await axios.get<User>(`https://jsonplaceholder.typicode.com/users/${targetId}`)
    const dataName = data.name
    memoMap.set(targetId, dataName)
    setUserName(dataName)
  }

  return (
    <>
      <input
        type="number"
        min="1"
        max="10"
        value={num}
        onChange={handleChange}
      />
      <p>{userName}</p>
    </>
  )
}

export default App

デベロッパーツールを見ると、一度取得したものはAPIから取得せずに、Mapオブジェクトのキャッシュから取得されていることがわかります。

axiosで取得した結果をMapオブジェクトでキャッシュするサンプル