ReactのuseMemoとuseCallback:本当に必要な場合
1. 概要
useMemoとuseCallbackは、直感的に使われる傾向があります。すべてを「念のため」でラップするか、決して使わないか。どちらも間違っています。この記事は、2つのhookの仕組みを説明し、それらが実際に役立つ3つの状況を指摘し、スキップすべき場合について説明します。また、メモ化が実際に機能することを証明するテストも作成します。
2. 根本的な問題:レンダリングのたびに新たに作成される
コンポーネントが再度レンダリングされるたびに、関数本体全体が最初から実行されます。関数内で宣言されたすべてのオブジェクト、配列、関数は新たに作成されます。
function Checkout({ customer }) {
const shipping = { city: customer.city, express: true };
const handlePay = () => console.log('支払い対象:', customer.name);
return <PaymentForm shipping={shipping} onPay={handlePay} />;
}
Checkoutがレンダリングされるたびに、shippingとhandlePayは新しい参照であり、内容は変わりません。JavaScriptはオブジェクトと関数を参照で比較します:
{ city: 'Huế' } === { city: 'Huế' } // false
(() => 1) === (() => 1) // false
通常、これは害がありません。オブジェクトの作成は非常に安いからです。これは参照を比較するもの(React.memo、useEffectのdependency配列、または別のhookなど)がある場合にのみ問題になります。useMemoとuseCallbackは、レンダリング間で参照を安定させるために生まれました。
3. useMemo:値を記憶する
useMemo(calculate, deps)は最初のレンダリングでcalculateを呼び出し、結果を保存します。その後のレンダリングでは、React はdeps内の要素が変わった場合にのみ再計算します。これはObject.isを使用して比較されます。
3.1. レンダリングのたびにコストのかかる計算を避ける
import { useMemo, useState } from 'react';
function OrderReport({ orders }) {
const [keyword, setKeyword] = useState('');
const [darkMode, setDarkMode] = useState(false);
const visibleOrders = useMemo(() => {
const kw = keyword.toLowerCase();
return orders
.filter((o) => o.customer.toLowerCase().includes(kw))
.sort((a, b) => b.total - a.total);
}, [orders, keyword]);
return (
<div className={darkMode ? 'dark' : ''}>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<button onClick={() => setDarkMode((d) => !d)}>UIを切り替え</button>
<ul>
{visibleOrders.map((o) => (
<li key={o.id}>{o.customer}: {o.total}</li>
))}
</ul>
</div>
);
}
説明:
filterとsortは、ordersまたはkeywordが変わった場合にのみ実行されます。- 「UIを切り替え」ボタンをクリックすると、
darkModeが変わり、コンポーネントが再度レンダリングされます。このとき、useMemoは保存されたメモを返し、もう一度フィルタリングすることなく配列を返します。 sortはここで安全です。filterは既に新しい配列を返しているからです。propのordersに直接変更する操作はありません。
計算が「コストがかかる」かどうかをどうやって知りますか?推測するのではなく、測定してください:
console.time('filter orders');
const result = orders.filter((o) => o.customer.includes(keyword));
console.timeEnd('filter orders');
filter orders: 0.08ms
実用的な目安:計算がdev環境で1ms未満の場合、memoはほぼ不要です。production buildで測定することを忘れずに。また、DevToolsでCPUスロットリングをオンにすることを強くお勧めします。ユーザーのマシンは通常、あなたのマシンより遅いからです。
3.2. オブジェクトを安定に保つために渡す
2番目のケースは計算速度とは関係ありません。ここでは、dependencyとして使用するための参照が安定したオブジェクトが必要です:
function CustomerOrders({ customerId, status }) {
const [orders, setOrders] = useState([]);
const query = useMemo(() => ({ customerId, status }), [customerId, status]);
useEffect(() => {
fetchOrders(query).then(setOrders);
}, [query]);
return <OrderTable orders={orders} />;
}
useMemoを省くと、queryはレンダリングのたびに新しいオブジェクトになります。その場合、effectが繰り返し実行され、setOrdersが新しいレンダリングを引き起こすため、無限のAPI呼び出しループに陥る可能性があります。
注記: この例には、より単純な別のアプローチがあります。オブジェクトをeffect内部で作成し、プリミティブ値をdependencyに配置します:
useEffect(() => { fetchOrders({ customerId, status }).then(setOrders); }, [customerId, status]);こちらの方法を優先してください。オブジェクトがeffectの外に存在する必要がある場合のみuseMemoを使用してください。例えば、それが子コンポーネントに渡される場合です。
4. useCallback:関数を記憶する
useCallback(fn, deps)はuseMemo(() => fn, deps)と同等です。渡した関数そのものを返し、参照をdepsが変わるまで保ちます。useCallbackは関数をより速く実行しません。このhookは、その関数がどこかで比較される場合にのみ意味があります。
4.1. React.memoと組み合わせる
React.memoは、propsが前回と同じ(浅い比較)場合、子コンポーネントのレンダリングをスキップします。propの1つが毎回新しく作成される関数の場合、memoは無意味になります。
import { memo, useCallback, useState } from 'react';
const OrderRow = memo(function OrderRow({ order, onSelect, onRender }) {
onRender?.(order.id);
return <li onClick={() => onSelect(order.id)}>{order.customer}</li>;
});
export function OrderList({ orders, onRender }) {
const [selectedId, setSelectedId] = useState(null);
const [note, setNote] = useState('');
const handleSelect = useCallback((id) => setSelectedId(id), []);
return (
<>
<input aria-label="note" value={note} onChange={(e) => setNote(e.target.value)} />
<ul>
{orders.map((o) => (
<OrderRow key={o.id} order={o} onSelect={handleSelect} onRender={onRender} />
))}
</ul>
<p>選択中:{selectedId ?? 'なし'}</p>
</>
);
}
説明:
handleSelectにはdependencyが空です。なぜなら、setSelectedIdは常に安定しているからです(ReactはuseStateのsetterの安定性を保証します)。- メモフィールドに入力すると、
OrderListが再度レンダリングされます。それでも、各OrderRowは同じorder、同じhandleSelect、同じonRenderを受け取るため、memoはそれらをスキップします。 handleSelectを通常のアロー関数に変更した場合、キーを入力するたびにすべての行が再度レンダリングされます。
覚えておくべき点:受け取り側にmemoがない場合、useCallbackはほぼ無用です。 memoされたコールバックをDOM要素の<button>に渡しても何も節約できません。
4.2. dependencyとして使用される関数
function useOrderPolling(customerId) {
const [orders, setOrders] = useState([]);
const refresh = useCallback(async () => {
const data = await fetchOrders({ customerId });
setOrders(data);
}, [customerId]);
useEffect(() => {
refresh();
const id = setInterval(refresh, 10_000);
return () => clearInterval(id);
}, [refresh]);
return { orders, refresh };
}
refreshはeffect内で使用され、同時に外のコンポーネントに返されます。useCallbackのおかげで、customerIdが変わった場合にのみintervalが作成し直されます。refreshをdependencyとして使用するコンポーネントも不要な実行を避けることができます。
これはcustom hookを作成するときに守るべきルールです:外に返す関数はuseCallbackでラップすべきです。hookの使用者がそれをdependencyに入れるかmemoに渡すかどうかを事前に知ることができません。
5. テストで検証する
memoが「確実に実行される」と信じるのではなく、テストを書くことができます。Vitet と React Testing Library をインストールしてください:
npm install -D vitest jsdom @testing-library/react
vite.config.jsでjsdom環境を設定してください:
export default {
test: { environment: 'jsdom' },
};
セクション4.1のOrderListのテスト:
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { OrderList } from './OrderList';
const orders = [
{ id: 1, customer: 'Lan' },
{ id: 2, customer: 'Minh' },
];
describe('OrderList', () => {
it('メモフィールドに入力するときはOrderRowをレンダリングしない', () => {
const onRender = vi.fn();
render(<OrderList orders={orders} onRender={onRender} />);
expect(onRender).toHaveBeenCalledTimes(2);
fireEvent.change(screen.getByLabelText('note'), { target: { value: '特急配送' } });
expect(onRender).toHaveBeenCalledTimes(2);
});
it('注文を選択するときでも更新される', () => {
render(<OrderList orders={orders} />);
fireEvent.click(screen.getByText('Minh'));
expect(screen.getByText('選択中: 2')).toBeTruthy();
});
});
✓ OrderList > メモフィールドに入力するときはOrderRowをレンダリングしない
✓ OrderList > 注文を選択するときでも更新される
useCallbackを削除してもう一度実行してください。最初のテストは失敗するはずです。予想では2回の呼び出しなのに、実際には4回の呼び出しを受け取ります。これは、hookが仕事をしている明らかな証拠です。onRenderを外部で宣言し、同じ参照を渡してください。そうしないと、このprop自体がmemoを破壊してしまいます。
テストの外では、React DevToolsのProfilerタブで「Highlight updates」オプションを使用して、どのコンポーネントが再度レンダリングされているかを視覚的に確認すべきです。
6. 使用すべきではない場合
メモ化は無料ではありません。Reactは古い値を保存し、dependency配列を保存し、毎回レンダリングしてそれらを比較する必要があります。コードもより読みにくくなります。以下の場合はスキップすべきです:
- 低コストの計算: いくつかの数値を追加し、文字列を連結し、数十個の要素をmapする。
- プリミティブ値:
useMemo(() => a + b, [a, b])は数値を返します。数値は既に値で比較されるため、memoは役に立ちません。 - DOMの要素や
memoでラップされていない子コンポーネントに渡すコールバック。 - 子コンポーネントが毎回変わるpropを受け取る場合。 例えば
<OrderRow order={{ ...o }} />やJSXのchildrenを渡す。JSXもレンダリングのたびに新しいオブジェクトなので、memoは常に異なることを認識します。 - memoの代わりに構造を変更できる場合。 stateを使用場所の近くに移動するか、重いコンポーネントを
childrenとして渡すことで、通常、余分なレンダリングをより徹底的に排除します。
注記:
useMemoに依存して正確性を確保しないでください。ReactはキャッシュCacheを削除する権利を有します。例えば、Suspenseの特定の状況下や将来的にです。memoなしでコードが正しく実行される必要があります。このhookはパフォーマンス最適化に過ぎません。同様に、useMemoの中にAPI呼び出しやlocalStorageへの書き込みなどの副作用を置かないでください。それらの場所はuseEffectまたはevent handlerです。
最も一般的なエラーはdependency漏れで、stale closureにつながります:
const handleSave = useCallback(() => {
saveOrder({ ...order, note });
}, [order]); // `note`が漏れている
handleSaveは常に最初に作成されたときのnote値を使用します。eslint-plugin-react-hooksのreact-hooks/exhaustive-depsルールをオンにし、その警告を無視しないでください。
7. React Compilerの変更点
React Compiler は、2025年後半から安定しており、buildの時点でコンポーネントを分析し、手動で作成することより詳細なレベルで自動的にメモ化を挿入します。Vite または Babel プロジェクトで試すには:
npm install -D babel-plugin-react-compiler
Compilerが有効になると、useMemo、useCallback、またはmemoをほぼ書く必要がありません。古いhookは引き続き機能します。effectのdependencyを正確に制御する必要がある場合に便利です。Compilerはコンポーネントが純粋で、propsまたはstateを直接変更していないなど、Reactのルールに準拠しているコードのみ最適化できます。したがって、古いコードを読んだり、Compilerがコンポーネントをスキップした場合に問題をデバッグする場合でも、この記事の2つのhookを理解することが重要です。
8. 結論
useMemoとuseCallbackは同じ問題を解決します:レンダリング間で参照を安定させることです。これらは3つの状況で実際に役立つ価値があります:
- 計算が測定可能でコストがかかる。
- 値または関数が
React.memoでラップされたコンポーネントに渡される。 - 値または関数が
useEffectまたは別のhookのdependency内にあり、custom hookから返される関数を含みます。
これら以外の場合は、コードをシンプルに保ってください。Profilerで測定し、テストで検証し、React Compilerをオンにしてこの作業をツールに任せることを検討してください。