· 6分で読了

useMemo のいくつかの使用シーン

この記事は中国語から自動翻訳されたものです。翻訳によりニュアンスが失われている場合があります。

useMemo のいくつかの使用シーン

フロントエンド開発では、画面に表示する値が他の値から複合的に計算されたものだったり、何らかの計算を経てから画面に配置されたりするケースによく遭遇する。例えば次のようなものだ:

  • 時間:秒を xx:xx という分秒の形式に変換して表示する
  • フィルターのオプションにチェックを入れると、データが処理されてから表示される

computed prop として使用する

次のように書くことができる:

const FormatTime = (ts) => {
  const formattedTime = `${Math.floor(ts/60)}:${ts%60}`
  return <time>{formattedTime}</time>
}

このように書いても実際大きな問題はないが、僕はこの「何らかの計算を経てから表示する」ようなシーンにおいて、useMemo を使って意図をより明確に表現するのが好きだ:

const FormatTime = (ts) => {
  const formattedTime = useMemo(() => {
    return `${Math.floor(ts/60)}:${ts%60}`
  }, [ts])
  
  return <time>{formattedTime}</time>
}

時間フォーマットの例では、元の書き方がすでにかなりシンプルで計算量も多くないため、両者の違いはそれほど大きくなく、useMemo を使うのは過剰な最適化(オーバーエンジニアリング)に見えるかもしれない。しかし実際の現場では、値の依存関係が増えるにつれて、useMemo を使うことで開発者が関数内で計算を行えるようになり、過度な三項演算子(ternary)による混乱を避けることができる。また、他の開発者も useMemo を見たときに「ああ、ここの計算はこのいくつかの変数に依存していて、別の変数を生み出しているんだな」と瞬時に認識できる。

それなら関数でラップするのと何が違うのか、と反論する人もいるかもしれない。例えば次のようなコードだ:

const Component = (ts) => {
  const computed = calculateMyProp(ts)
  return <div>...</div>
}

僕の考えでは、その計算が非常に汎用的な関数でない限り、計算を関数にまとめることは見かけ上のコンポーネントの行数を減らしているに過ぎない。開発者がコードを読む際には、結局その関数の実装までジャンプして何をしているかを確認する必要がある。それなら、useMemo を使ってその場に直接実装を書いたほうがまだましだ。

複数の状態に依存する計算

もう一つのユースケースは、データが他のフィルターに依存している場合だ。例えば:

const MyComponent = ({ data }) => {
  const [filtered, setFiltered] = useState(false)
  
  return <div>
    <button onClick={() => setFiltered(s => !s)}>toggle filtered</button>
    {data.filter(d => filtered ? d.favorite : true).map(...)}
  </div>
}

ユーザーがボタンを押すと filtered が変更され、filtered が true の場合は favorite なデータが抽出される。この書き方の欠点は、データの処理ロジックが JSX の中に書かれているため、ロジックが複雑になったときにメンテナンスが難しくなることだ。そのため、独立した変数として切り出すことができる:

const MyComponent = ({ data }) => {
  const [filtered, setFiltered] = useState(false)
+ const filteredData = data.filter(d => filtered ? d.favorite : true)
  return <div>
    <button onClick={() => setFiltered(s => !s)}>toggle filtered</button>
+   {filterData.map(...)}
  </div>
}

こうすることで JSX 内の式はよりシンプルになるが、filteredData の変数宣言内の実装は少し直感的ではなく見える。今回の例では条件式がまだ単純だが、前述の通り、依存関係が増えるにつれてコードはどんどん複雑になっていく。

こういうとき、僕は計算を useMemo の中に逃がすようにしている:

const MyComponent = ({ data }) => {
  const [filtered, setFiltered] = useState(false)
  const [sorted, setSorted] = useState(false)
  const filteredData = useMemo(() => {
    if (filtered) {
      return data.filter(d => d.favorite)      
    }

    return data
  }, [data.length, filtered])
  return <div>
    <button onClick={() => setFiltered(s => !s)}>toggle filtered</button>
  	{filterData.map(...)}
  </div>
}

もし仮に、新しく sorted というオプションが追加されたとしても、useMemo の中に対応する処理と依存関係を追加するだけで済む:

const MyComponent = ({ data }) => {
  const [filtered, setFiltered] = useState(false)
  const [sorted, setSorted] = useState(false)
  const filteredData = useMemo(() => {
    const origin = [...data]
    if (filtered) {
      return origin.filter(d => d.favorite)      
    }

    if (sorted) {
      return data.sort()
    }

    return origin
  }, [data.length, filtered, sorted]) // ここでは data の変化は長さが変わったときのみ発生すると仮定する
  return <div>
    <button onClick={() => setFiltered(s => !s)}>toggle filtered</button>
    {filterData.map(...)}
  </div>
}

このように書くメリットは、依存関係の宣言が非常に明確になり、開発者が「このデータはどの変数に依存しているか」を一目で把握できる点にある。

derived state

もう一つのユースケースは「derived state(派生状態)」、つまり状態そのものが props の変化に依存しているケースだ。先ほどの例を書き直してみよう:

const MyComponent = ({ data }) => {
  const [query, setQuery] = useState(value)
  const [filteredData, setFiltered] = useState(data.filter(d => query ? d.includes(query) : true))
  return <>
    <input onChange={e => setQuery(e.target.value)} value={query} />
  	{filteredData.map(...)}
  </>
}

query が更新されるたびに、コンポーネントの状態も合わせて更新したいとする。しかし、このアプローチには2つの問題がある:

  • data が変化するたびに状態をもう一度更新する必要があるため、不要な再レンダリングが1回増える
  • 入力値が prop と state の両方に依存するため、single source of truth(信頼できる唯一の情報源)が維持できなくなる

特に開発において最も危険なのは single source of truth が維持できないことであり、開発者にとって直感的でなく、デバッグも極めて困難になる。

先ほど挙げた2つの問題に加えて、実はこの書き方は正しくない。その理由は、useState(value) の引数は初期値として定義されているため、value に変化があっても state は更新されないからだ。これにより、後から dataquery が更新されても、filteredData は初回レンダリング時の値のままになってしまう。

useState の引数は初回レンダリング時にしか有効にならないためだ。正しい(ただし推奨されない)書き方は次のようになる:

/* この書き方は非推奨 */
const MyComponent = ({ data }) => {
  const [query, setQuery] = useState(value)
  const [filteredData, setFiltered] = useState(data.filter(d => query ? d.includes(query) : true))
+ useEffect(() => { data.filter(d => d.includes(query)) }, [data, query])
  return <>
    <input onChange={e => setQuery(e.target.value)} value={query} />
  	{filteredData.map(...)}
  </>
}

ある state を宣言し、その state が特定の props に依存している場合、大半のケースでは useMemo に書き換えることができる。この例で言えば、先ほどの例とほぼ同じように useMemo を使って次のように書き直せる:

const MyComponent = ({ data }) => {
  const [query, setQuery] = useState(value)
  const filteredData = useMemo(() => {
    if (query) {
      return data.filter(d => d.includes(query))
    }
    
    return data
  }, [data, query])
	
  return <>
    <input onChange={e => setQuery(e.target.value)} value={query} />
  	{filteredData.map(...)}
  </>
}

まとめ

これらを踏まえると、いくつかのポイントにまとめることができる:

  • computed props に useMemo を使うことで、他の開発者がコードを読む際の認知的負荷を軽減できる
    • 計算量が多い場合には、レンダリング毎に再計算するコストを削減できる
  • useState(props) を使う際は特に注意が必要である
  • derived state は、大半のケースにおいて useMemo を組み合わせることで解決できる

さらに、僕にとって useMemo の大きな利点は、コードの意図を表現しやすくなることだ。多少のオーバーヘッドがあるように見えるかもしれないが(実際その通りだ(笑)、他のフレームワークはそもそも memo 化なんて気にしていない)、多少のパフォーマンスと引き換えにコードの可読性を得られるなら、十分に価値がある。

React のベータ版ドキュメントには、いたるところで useMemo を使うべきかどうかについての議論が載っており、個人的にとても参考になると思う。

関連記事

他のトピックを探索