Open13

useMemo

あおけんあおけん

リファレンス

useMemo(calculateValue, dependencies)
レンダー間で計算をキャッシュします。

引数

calculateValue
キャッシュしたい値を計算する関数。
純関数で、引数を取らず、任意の型の何らかの値を返す必要がある。

React は初回レンダー中にこの関数を呼び出す。
次回以降のレンダーでは、直前のレンダーと dependencies が変化していなければ、同じ値を再度返す。

dependencies が変化していれば、calculateValue を呼び出してその結果を返し、同時に、後から再利用するためにその結果を保存する。

dependencies
calculateValue のコード内で参照されているすべてのリアクティブ値の配列。
リアクティブ値には、props、state、およびコンポーネント本体で直接宣言されているすべての変数と関数が含まれる。

リンタが React 向けに設定されている場合は、すべてのリアクティブ値が正しく依存値として指定されているかを確認。
依存配列は、[dep1, dep2, dep3] のようにインラインで記述され、配列の長さは一定である必要がある。
各依存値は、Object.is を用いて、前回の値と比較される。

返り値

初回のレンダーでは、引数なしで calculateValue を呼び出した結果が、useMemo の返り値となる。

次回以降のレンダーでは、依存配列が変化していない場合は、以前のレンダーで保存された値を返す。
変化している場合は、calculateValue を再度呼び出し、その結果をそのまま返す。

注意点

1つ目
useMemo はフックなので、カスタムフックかコンポーネントのトップレベルでしか呼び出すことができない。
ループや条件分岐の中で呼び出すことはできない。
もしループや条件分岐の中で呼び出したい場合は、新しいコンポーネントに切り出して、その中に state を移動させる。

2つ目
Strict Mode では、純粋でない関数を見つけやすくするために、計算関数 (calculateValue) が 2 度呼び出される。
これは、開発時のみの挙動で、本番では影響は与えない。
もし、計算関数が純粋であれば(純粋であるべきです)、2 回呼び出されてもコードに影響はない。
2 回の呼び出しのうち、一方の呼び出し結果は無視される。

3つ目
特別な理由がない限り、キャッシュされた値が破棄されることはない。
キャッシュが破棄されるケースの例

  • 開発時にコンポーネントのファイルを編集
  • 開発時および本番時に、初回マウント中にコンポーネントがサスペンド

将来的には、キャッシュが破棄されることを前提とした機能が React に追加される可能性がある。
例えば、将来的に仮想リストが組み込みでサポートされた場合、仮想テーブルのビューポートからスクロールアウトした項目は、キャッシュを破棄するようになるかもしれない。
このような挙動は、パフォーマンス最適化のみを目的として useMemo を使っている場合には問題ない。
しかし、他の目的で利用している場合は、state 変数 や ref を利用した方が良いかもしれない。

あおけんあおけん

使用法

高コストな再計算を避ける

複数レンダーを跨いで計算をキャッシュするには、コンポーネントのトップレベルで useMemo を呼び出し、計算をラップする。

import { useMemo } from 'react';

function TodoList({ todos, tab, theme }) {
  const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
  // ...
}

useMemo には、2 つの引数を渡す必要がある。

  1. () => のように、引数を取らず、求めたい計算結果を返す計算関数。
  2. コンポーネント内にある値のうち、計算関数内で使用されているすべての値を含む、依存配列。

初回レンダーでは、useMemo から返される値は、計算関数を呼び出した結果。
次回以降のレンダーでは、今回のレンダー時に渡した依存配列と、前回のレンダー時に渡した依存配列が比較される。
依存値のいずれも変化していない場合、useMemo は以前に計算した値を返す。変化している場合は、再度計算が実行され、新しい値が返す。

つまり useMemo は、依存配列が変化しない限り、複数のレンダーを跨いで計算結果をキャッシュする。

役立つ場面

React では通常、再レンダーが発生するたびに、コンポーネント関数全体が再実行される。
例えば、以下の TodoList で、state が更新されたり、親から新しい props を受け取ったりした場合、filterTodos 関数が再実行される。

function TodoList({ todos, tab, theme }) {
  const visibleTodos = filterTodos(todos, tab);
  // ...
}

ほとんどの計算は非常に高速に処理されるため、何か問題になることは通常ない。
しかし、巨大な配列をフィルタリング・変換している場合や、高コストな計算を行っている場合には、データが変わっていなければこれらの計算をスキップしたくなる。
todos と tab の値が前回のレンダー時と同じ場合、先ほどのように計算を useMemo でラップすることで、以前に計算した visibleTodos を再利用することができる。

このようなキャッシュのことを、メモ化と呼ぶ。

あおけんあおけん

計算コストが高いかどうか見分ける方法

一般的に、何千ものオブジェクトを作成したりループしたりしていない限り、おそらく高価ではない。
より確信を持ちたい場合は、コンソールログを追加して、コードの実行にかかった時間を計測する。

console.time('filter array');
const visibleTodos = filterTodos(todos, tab);
console.timeEnd('filter array');

測定したいユーザ操作(例えば、入力フィールドへのタイプ)を実行。
その後、コンソールに filter array: 0.15ms のようなログが表示。
全体のログ時間がかなりの量(例えば 1ms 以上)になる場合、その計算をメモ化する意味があるかもしない。
実験として useMemo で計算をラップしてみて、その操作に対する合計時間が減少したかどうかをログで確認。

console.time('filter array');
const visibleTodos = useMemo(() => {
  return filterTodos(todos, tab); // Skipped if todos and tab haven't changed
}, [todos, tab]);
console.timeEnd('filter array');

useMemo は初回レンダーを高速化しない。
更新時に不要な作業をスキップするときにのみ役立つ。

また、ほとんどの場合に、あなたが使っているマシンは、ユーザのマシンより高速に動作する。
そのため、意図的に処理速度を低下させてパフォーマンスをテストするのが良い。
例えば、Chrome では CPU スロットリングオプションが提供される。

また、開発環境でのパフォーマンス測定では完全に正確な結果は得られないことに注意。
(例えば、Strict Mode がオンの場合、各コンポーネントが 1 度ではなく 2 度レンダーされることがあります。)
最も正確にパフォーマンスを計測するためには、アプリを本番環境用にビルドし、ユーザが持っているようなデバイスでテストする。

あおけんあおけん

あらゆる場所に useMemo を追加すべきか?

あなたのアプリがこのサイトのように、ほとんどのインタラクションが大まかなもの(ページ全体やセクション全体の置き換えなど)である場合、メモ化は通常不要。
一方、あなたのアプリが描画エディタのようなもので、ほとんどのインタラクションが細かなもの(図形を移動させるなど)である場合、メモ化は非常に役に立つ。

useMemo を利用した最適化が力を発揮するケース

1つ目
useMemo で行う計算が著しく遅く、かつ、その依存値がほとんど変化しない場合

2つ目
計算した値を、memo でラップされたコンポーネントの props に渡す場合

この場合は、値が変化していない場合には再レンダーをスキップしたい。
メモ化することで、依存値が異なる場合にのみコンポーネントを再レンダーさせることができます。

3つ目
その値が、後で何らかのフックの依存値として使用されるケース

例えば、別の useMemo の計算結果がその値に依存している場合や、useEffect がその値に依存している場合など。

まとめ
これらのケース以外では、計算を useMemo でラップすることにメリットはない。
それを行っても重大な害はないため、個別のケースを考えずに、可能な限りすべてをメモ化するようにしているチームもある。
このアプローチのデメリットは、コードが読みにくくなるという点。
また、すべてのメモ化が有効であるわけではない。
例えば、毎回変化する値が 1 つ存在するだけで、コンポーネント全体のメモ化が無意味になってしまう。

いくつかの原則に従うことで、多くのメモ化を不要にすることができる

1つ目
コンポーネントが他のコンポーネントを視覚的にラップするときは、それが子として JSX を受け入れるようする

これにより、ラッパコンポーネントが自身の state を更新しても、React はその子を再レンダーする必要がないことを認識する。

2つ目
ローカル state を優先し、必要以上に state のリフトアップを行わない

フォームや、アイテムがホバーされているかどうか、といった頻繁に変化する state は、ツリーのトップやグローバルの状態ライブラリに保持しない。

3つ目
レンダーロジックを純粋に保つ

コンポーネントの再レンダーが問題を引き起こしたり、何らかの目に見える視覚的な結果を生じたりする場合、それはあなたのコンポーネントのバグ。
メモ化を追加するのではなく、バグを修正する。

4つ目
state を更新する不要なエフェクトを避ける

React アプリケーションのパフォーマンス問題の大部分は、エフェクト内での連鎖的な state 更新によってコンポーネントのレンダーが何度も引き起こされるために生じる。

5つ目
エフェクトから不要な依存値をできるだけ削除

例えば、メモ化する代わりに、オブジェクトや関数をエフェクトの中や外に移動させるだけで、簡単に解決できる場合がある。

まとめ
それでも特定のインタラクションが遅いと感じる場合は、React Developer Tools のプロファイラを使用して、どのコンポーネントでのメモ化が最も有効かを確認し、そこでメモ化を行う。
これらの原則を守ることで、コンポーネントのデバッグや理解が容易になるため、常に原則に従うことをおすすめする。
長期的には、この問題を一挙に解決できる自動的なメモ化について研究を行っている。

あおけんあおけん

コンポーネントの再レンダーをスキップする

useMemo は、子コンポーネントの再レンダーのパフォーマンスを最適化する際にも役に立つことがある。
これを説明するために、TodoList コンポーネントが、子コンポーネントの List の props として、visibleTodos を渡すことを考える。

export default function TodoList({ todos, tab, theme }) {
  // ...
  return (
    <div className={theme}>
      <List items={visibleTodos} />
    </div>
  );
}

props である theme を変化させると一瞬アプリがフリーズしますが、<List /> を JSX から削除すると、高速に動作するようになったはず。

すなわち、この List コンポーネントには最適化する価値があるということ。

通常、あるコンポーネントが再レンダーされたときは、その子コンポーネントも再帰的にすべて再レンダーされる。
これが、TodoList が異なる theme の値で再レンダーされたとき、List コンポーネントも一緒に再レンダーされる理由。
この動作は、再レンダーにそれほど多くの計算コストを必要としないコンポーネントには適している。
しかし、もし再レンダーが遅いと分かった場合は、List コンポーネントを memo で囲うことで、与えられた props が前回のレンダーと同じである場合に List の再レンダーをスキップすることができる。

import { memo } from 'react';

const List = memo(function List({ items }) {
  // ...
});

この変更によって、props の全項目が前回のレンダーと等しい場合には、List の再レンダーはスキップされるようになる。
これが、計算のキャッシュが重要になる理由。
useMemo を使わずに visibleTodos の計算を行うことを想像してみる。

export default function TodoList({ todos, tab, theme }) {
  // Every time the theme changes, this will be a different array...
  const visibleTodos = filterTodos(todos, tab);
  return (
    <div className={theme}>
      {/* ... so List's props will never be the same, and it will re-render every time */}
      <List items={visibleTodos} />
    </div>
  );
}

上記の例では、filterTodos 関数が毎回異なる配列を生成。
(これは、{} というオブジェクトリテラルが、毎回新しいオブジェクトを生成することと似ています。)
通常これが問題になることはありませんが、今回の場合は、List の props が毎回別の値になってしまう。
そのため、memo による最適化が意味をなさなくなってしまう。
ここで、useMemo が役に立つ。

export default function TodoList({ todos, tab, theme }) {
  // Tell React to cache your calculation between re-renders...
  const visibleTodos = useMemo(
    () => filterTodos(todos, tab),
    [todos, tab] // ...so as long as these dependencies don't change...
  );
  return (
    <div className={theme}>
      {/* ...List will receive the same props and can skip re-rendering */}
      <List items={visibleTodos} />
    </div>
  );
}

visibleTodos の計算を useMemo でラップすることで、複数の再レンダーの間でその結果が同じになることを保証できる(依存配列が変わらない限り)。
通常、特別な理由がなければ、計算を useMemo でラップする必要はない。
この例では、memo で囲われたコンポーネントに値を渡しておりレンダーのスキップができるということが、その特別な理由にあたる。
他にも useMemo を追加する動機はいくつかあり、このページで詳しく解説。

あおけんあおけん

個々の JSX ノードをメモ化する

List を memo でラップする代わりに、<List /> JSX ノード自体を useMemo でラップしても構わない。

export default function TodoList({ todos, tab, theme }) {
  const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
  const children = useMemo(() => <List items={visibleTodos} />, [visibleTodos]);
  return (
    <div className={theme}>
      {children}
    </div>
  );
}

挙動は同じ。
visibleTodos が変化していない場合は、List は再レンダーされない。

<List items={visibleTodos} /> のような JSX ノードは、{ type: List, props: { items: visibleTodos } } のようなオブジェクトと同じ。
このオブジェクトを作成するコストは非常に小さいですが、React はその内容が前回の内容と同じかどうかは分からない。
そのため、React は、デフォルトで List コンポーネントを再レンダーする。

しかし、React が前回のレンダー時と全く同じ JSX を見つけた場合、コンポーネントの再レンダーを行わない。
これは、JSX ノードがイミュータブル (immutable) であるため。JSX ノードオブジェクトは時間が経過しても変化することはないため、再レンダーをスキップしてしまって問題ない。
しかし、これが機能するには、ノードが真に全く同一のオブジェクトである必要があり、コード上で同じように見えるだけでは不十分。
この例では、useMemo のおかげで、ノードが全く同じオブジェクトとなっている。

useMemo を使って、JSX ノードを手動でラップするのは不便。
例えば、条件付きでラップすることはできない。そのため、通常は useMemo で JSX ノードをラップする代わりに、memo でコンポーネントをでラップする。

あおけんあおけん

エフェクトが過度に実行されるのを抑制する

エフェクト内で、以下のようにして何らかの値を使用したくなる場合がある。

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  const options = {
    serverUrl: 'https://localhost:1234',
    roomId: roomId
  }

  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();
    // ...

しかしこれにより問題が生じる。
すべてのリアクティブ値はエフェクト内で依存値として宣言する必要がある。
しかしこの options を依存値として宣言してしまうと、エフェクトがチャットルームへの再接続を繰り返すようになってしまう。

  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [options]); // 🔴 Problem: This dependency changes on every render
  // ...

これを修正するために、エフェクト内で使用されるオブジェクトを useMemo でラップする。

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  const options = useMemo(() => {
    return {
      serverUrl: 'https://localhost:1234',
      roomId: roomId
    };
  }, [roomId]); // ✅ Only changes when roomId changes

  useEffect(() => {
    const options = createOptions();
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [options]); // ✅ Only changes when createOptions changes
  // ...

これで、useMemo がキャッシュ済みのオブジェクトを返している限り、options オブジェクトが再レンダー間で等しくなることが保証される。

しかし useMemo はパフォーマンス最適化のためのものであり、意味的な保証があるものではない。
特定の理由がある場合は、React はキャッシュされた値を破棄することがある。
これによりエフェクトも再実行されるため、エフェクト内にオブジェクトを移動することでこのような依存値自体を不必要にする方がより良い。

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  useEffect(() => {
    const options = { // ✅ No need for useMemo or object dependencies!
      serverUrl: 'https://localhost:1234',
      roomId: roomId
    }
    
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // ✅ Only changes when roomId changes
  // ...

これでコードはよりシンプルになり、useMemo も不要となった。

あおけんあおけん

他のフックに渡す依存値をメモ化する

ある計算が、コンポーネントの本体で直接作成されたオブジェクトに依存しているとする。

function Dropdown({ allItems, text }) {
  const searchOptions = { matchMode: 'whole-word', text };

  const visibleItems = useMemo(() => {
    return searchItems(allItems, searchOptions);
  }, [allItems, searchOptions]); // 🚩 Caution: Dependency on an object created in the component body
  // ...

このようなオブジェクトを依存値として使うとメモ化の意味がなくなる。
コンポーネントが再レンダーされたとき、コンポーネントの本体に含まれるコードはすべて再実行される。
searchOptions オブジェクトを作成するコードも、毎回再実行される。
searchOptions は useMemo の依存値であり、毎回異なる値となるため、依存値が変化したと判断され、searchItems が毎回再計算される。

これを修正するには、searchOptions オブジェクトを依存配列に渡す前に、searchOptions オブジェクト自体をメモ化する。

function Dropdown({ allItems, text }) {
  const searchOptions = useMemo(() => {
    return { matchMode: 'whole-word', text };
  }, [text]); // ✅ Only changes when text changes

  const visibleItems = useMemo(() => {
    return searchItems(allItems, searchOptions);
  }, [allItems, searchOptions]); // ✅ Only changes when allItems or searchOptions changes
  // ...

上記の例では、text が変化しなければ、searchOptions オブジェクトも変化しない。
しかし、さらに良い修正方法は、searchOptions オブジェクトの宣言を useMemo の計算関数の中に移動すること。

function Dropdown({ allItems, text }) {
  const visibleItems = useMemo(() => {
    const searchOptions = { matchMode: 'whole-word', text };
    return searchItems(allItems, searchOptions);
  }, [allItems, text]); // ✅ Only changes when allItems or text changes
  // ...

これで、計算が直接 text に依存するようになった。
(text は文字列なので「意図せず」変化してしまうことはない。)

あおけんあおけん

関数をメモ化する

Form コンポーネントが memo でラップされているとする。
関数を props として渡してみる。

export default function ProductPage({ productId, referrer }) {
  function handleSubmit(orderDetails) {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails
    });
  }

  return <Form onSubmit={handleSubmit} />;
}

{} が異なるオブジェクトを生成するのと同様に、function() {} のような関数宣言や、() => {} のような関数式もまた、レンダーごとに異なる関数を生成する。
新しい関数が生成されること自体は問題ではなく、避けるべきことでもない。
しかし、Form コンポーネントがメモ化されている状況では、Form の props に渡す値が変わっていない場合は Form の再レンダーをスキップしたい。
毎回異なる値が props にあると、メモ化は無意味になってしまう。

useMemo で関数をメモ化する場合は、計算関数がさらに別の関数を返す必要がある。

export default function Page({ productId, referrer }) {
  const handleSubmit = useMemo(() => {
    return (orderDetails) => {
      post('/product/' + productId + '/buy', {
        referrer,
        orderDetails
      });
    };
  }, [productId, referrer]);

  return <Form onSubmit={handleSubmit} />;
}

関数のメモ化はよくあることなので、それ専用の組み込みフックが提供されている。
余計な関数の入れ子を避けるには、useMemo の代わりに useCallback で関数をラップする。

export default function Page({ productId, referrer }) {
  const handleSubmit = useCallback((orderDetails) => {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails
    });
  }, [productId, referrer]);

  return <Form onSubmit={handleSubmit} />;
}

上記の 2 つの例は完全に等価。
useCallback のメリットは、余計な関数の入れ子が不要になることだけ。

あおけんあおけん

再レンダーのたびに計算が 2 回実行される

Strict Mode では、本来 1 回だけ関数が呼び出されるところで、2 回呼び出されることがある。

function TodoList({ todos, tab }) {
  // This component function will run twice for every render.

  const visibleTodos = useMemo(() => {
    // This calculation will run twice if any of the dependencies change.
    return filterTodos(todos, tab);
  }, [todos, tab]);

  // ...

これは想定通りの挙動であり、これでコードが壊れることがあってはいけない。

これは開発時のみの挙動で、開発者がコンポーネントを純粋に保つために役立つ。
呼び出し結果のうちの 1 つが採用され、もう 1 つは無視される。
あなたが実装したコンポーネントと計算関数が純粋であれば、この挙動がロジックに影響を与えることはない。
しかし、もし意図せず純粋ではない関数になっていた場合は、この挙動によって間違いに気づき、修正することができる。

たとえば、以下の計算関数は、props として受け取った配列の書き換え(ミューテーション)をしてしまっており、純粋ではない。

  const visibleTodos = useMemo(() => {
    // 🚩 Mistake: mutating a prop
    todos.push({ id: 'last', text: 'Go for a walk!' });
    const filtered = filterTodos(todos, tab);
    return filtered;
  }, [todos, tab]);

しかし、この関数は 2 度呼び出されるため、todo が 2 回追加されたことに気づくはず。
計算関数は、既存のオブジェクトを変更してはいけませんが、計算中に作成した新しいオブジェクトを変更することは問題ない。
たとえば、filterTodos 関数が常に異なる配列を返す場合は、その配列を変更しても問題ない。

  const visibleTodos = useMemo(() => {
    const filtered = filterTodos(todos, tab);
    // ✅ Correct: mutating an object you created during the calculation
    filtered.push({ id: 'last', text: 'Go for a walk!' });
    return filtered;
  }, [todos, tab]);
あおけんあおけん

useMemo の返り値が、オブジェクトではなく undefined になってしまう

以下のコードはうまく動作しない。

  // 🔴 You can't return an object from an arrow function with () => {
  const searchOptions = useMemo(() => {
    matchMode: 'whole-word',
    text: text
  }, [text]);

JavaScript では、() => { というコードでアロー関数の本体を開始するため、{ の波括弧はオブジェクトの一部にはならない。
したがってオブジェクトは返されず、ミスにつながる。
({ や }) のように丸括弧を追加することで修正できる。

  // This works, but is easy for someone to break again
  const searchOptions = useMemo(() => ({
    matchMode: 'whole-word',
    text: text
  }), [text]);

しかし、これでもまだ混乱しやすく、誰かが丸括弧を削除してしまうと簡単に壊れる。

このミスを避けるために、明示的に return 文を書く。

 // ✅ This works and is explicit
  const searchOptions = useMemo(() => {
    return {
      matchMode: 'whole-word',
      text: text
    };
  }, [text]);
あおけんあおけん

コンポーネントがレンダーされるたびに useMemo 内の関数が再実行される

第 2 引数に依存配列を指定しているか確認。
依存配列を忘れると、useMemo は毎回計算を再実行してしまう。

function TodoList({ todos, tab }) {
  // 🔴 Recalculates every time: no dependency array
  const visibleTodos = useMemo(() => filterTodos(todos, tab));
  // ...

第 2 引数に依存配列を渡した修正版は以下の通り。

function TodoList({ todos, tab }) {
  // ✅ Does not recalculate unnecessarily
  const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
  // ...

これで解決しない場合は、少なくとも 1 つの依存値が前回のレンダーと異なっていることが問題。
手動で依存値をコンソールに出力して、デバッグすることができる。

const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
  console.log([todos, tab]);

コンソール上で、別々の再レンダーによって表示された 2 つの配列を選ぶ。
それぞれについて、配列を右クリックし、“Store as a global variable(グローバル変数として保存)” を選択することで、配列を保存することができる。
1 回目に保存した配列が temp1、2 回目に保存した配列が temp2 として保存されたとすると、ブラウザのコンソールを使用して、両方の配列の各依存値が同じかどうかを確認できる。

Object.is(temp1[0], temp2[0]); // Is the first dependency the same between the arrays?
Object.is(temp1[1], temp2[1]); // Is the second dependency the same between the arrays?
Object.is(temp1[2], temp2[2]); // ... and so on for every dependency ...

メモ化を妨げている依存値を見つけたら、その依存値を削除する方法を探すか、その依存値もメモ化する。

あおけんあおけん

ループ内のリストの各項目について useMemo を呼び出したいが、禁止されている

Chart コンポーネントが memo でラップされているとする。ReportList コンポーネントが再レンダーされた場合でも、リスト内の各 Chart の再レンダーはスキップしたい。
ところが、以下のようにループ内で useMemo を呼び出すことはできない。

function ReportList({ items }) {
  return (
    <article>
      {items.map(item => {
        // 🔴 You can't call useMemo in a loop like this:
        const data = useMemo(() => calculateReport(item), [item]);
        return (
          <figure key={item.id}>
            <Chart data={data} />
          </figure>
        );
      })}
    </article>
  );
}

その場合は、各アイテムをコンポーネントに切り出し、アイテムごとにデータをメモ化。

function ReportList({ items }) {
  return (
    <article>
      {items.map(item =>
        <Report key={item.id} item={item} />
      )}
    </article>
  );
}

function Report({ item }) {
  // ✅ Call useMemo at the top level:
  const data = useMemo(() => calculateReport(item), [item]);
  return (
    <figure>
      <Chart data={data} />
    </figure>
  );
}

あるいは、useMemo を削除し、Report 自体を memo でラップする。
item が変化しない場合は、Report の再レンダーはスキップされ、Chart の再レンダーもスキップされる。