こんにちは。現場で日々、無限ループするuseEffectや、何の意味もない再レンダリングの山と格闘しているフロントエンドエンジニアの皆さん、お疲れ様です。
今回は、Reactのパフォーマンスチューニングにおいて「避けては通れない壁」である、`useState`と`React.memo`の連携について話をしよう。
中級へのステップアップによくある罠なんだが、「とりあえず子コンポーネントを`React.memo`で囲っておけば再レンダリングを防げる」と勘違いしているケースが後を絶たない。残念ながら、親の`useState`の機嫌次第で、`React.memo`はいとも簡単にその効力を失う。
ブラウザの裏側で何が起きているのか、そしてどうやってこのバグやパフォーマンス劣化を防ぐのか。現場のリアルな知見を交えて徹底的に解説していく。
—
なぜ `React.memo` だけでは不十分なのか?
まずは敵を知ることから始めよう。
Reactのレンダリングメカニズムの基本だが、親コンポーネントのステートが更新されると、原則としてその子孫コンポーネントもすべて再評価(再レンダリング)の対象になる。
ここで「うちの子は重いから、`React.memo`でメモ化して防ごう」と思うよね。これが第一段階の勘違いだ。
`React.memo`は、「受け取ったpropsが浅い比較(shallow compare)で前回と同じであれば、コンポーネント自体の実行をスキップする」という高等テクニックだ。しかし、親から渡しているpropsの中に「毎回新しく生成される参照型(関数やオブジェクト)」が含まれているとどうなるか?
JavaScriptのエンジン(V8など)のメモリ上では、関数やオブジェクトは作られるたびに異なるメモリアドレス(参照先)を持つ。
たとえ中身のデータや処理が全く同じであっても、親がレンダリングされるたびに新しい関数インスタンスを作って子に渡している場合、`React.memo`の比較フェーズで「おっ、前のpropsとアドレスが違うぞ。別物だ!」と判定され、無慈悲に再レンダリングが実行される。
これが、現場でよく見る「`React.memo`を貼ったのに全然パフォーマンスが改善しないミステリー」の正体だ。
—
解決の切り札:`useCallback` とのコンビネーション
この問題を解決するには、「親コンポーネントが再レンダリングされても、子に渡す関数やオブジェクトの参照を維持する」必要がある。
ここで登場するのが `useCallback` だ。
`useCallback` は、依存配列(dependencies)が変わらない限り、同じ関数のインスタンスをメモリ上にキャッシュし続ける。これにより、親が再レンダリングされても子に渡る関数の「住所(参照)」が変わらないため、`React.memo` が「お、前回と同じだな。スキップしよう」と判断できるようになるわけだ。
百聞は一見にしかず。実務でそのまま使える、泥臭くも美しいコードを見てみよう。
—
実践コード:無駄な再レンダリングを断つ実装パターン
以下のコードは、「タスク管理アプリ」のワンシーンを想定している。親がカウンター(数値のstate)を持っているが、子コンポーネント(タスクリスト)には何の関係もない。この状況で、カウンターをポチポチ押しても、子コンポーネントが再レンダリングされないように最適化してみよう。
import React, { useState, useCallback } from ‘react’;
// ==========================================
// 子コンポーネント:React.memoでラップ
// ==========================================
interface TaskItemProps {
task: string;
onDelete: (task: string) => void;
}
// React.memoを使い、propsが変化しない限り再レンダリングを完全にブロックする
const TaskItem = React.memo(({ task, onDelete }: TaskItemProps) => {
console.log(`[Renderer] TaskItem が再レンダリングされました: ${task}`);
return (
);
});
// デバッグ用の表示名(React DevToolsで追いやすくするプロの嗜み)
TaskItem.displayName = ‘TaskItem’;
// ==========================================
// 親コンポーネント
// ==========================================
export const TaskManager: React.FC = () => {
const [count, setCount] = useState
const [tasks, setTasks] = useState
// 【重要】
// useCallbackを使わずに関数を定義すると、countが更新されて親が再描画されるたびに
// この関数のアドレスが変わり、子コンポーネントのReact.memoが機能しなくなる。
const handleDelete = useCallback((targetTask: string) => {
setTasks((prevTasks) => prevTasks.filter((t) => t !== targetTask));
}, []); // タスクリストの更新には関数型アップデート(prevTasks)を使っているため、依存配列は空でOK
return (
タスク管理ダッシュボード
{/ 親のステート(無関係なカウンター) /}
現在のカウント: {count}
{/ 子コンポーネント群 /}
タスク一覧(メモ化済み)
{tasks.length === 0 ? (
タスクはありません。
) : (
tasks.map((task) => (
))
)}
);
};
このコードのポイント(シニアからの解説)
1. 関数型アップデートの活用
`setTasks((prevTasks) => …)` のように、状態更新の際にコールバック形式を取っている点に注目してほしい。これにより、`handleDelete` 内で `tasks` のstateを直接参照する必要がなくなるため、`useCallback` の依存配列を空 `[]` にできる。結果として、コンポーネントのライフサイクル全体を通じて関数の参照が完全に固定される。
2. `displayName` の付与
`React.memo` でラップされたコンポーネントは、React DevTools上で `Memo(TaskItem)` と表示されてしまい、デバッグ時にどのコンポーネントかわかりにくくなる。`TaskItem.displayName = ‘TaskItem’` を設定しておくのは、大規模開発におけるエンジニア間の優しさであり、プロの作法だ。
—
現場でありがちなアンチパターンと注意点
最後に、少し厳しい現実についても触れておこう。何事もやりすぎは毒になる。
- すべてのコンポーネントを `React.memo` で包むな
「とりあえず全部メモ化すれば速くなるはず」という思考停止は最悪のアンチパターンだ。`React.memo` も内部で「前回のpropsとの比較処理」というコスト(CPUの計算量)を払っている。極端に軽いコンポーネントや、毎回のレンダリングで確実にpropsが変わるコンポーネントにこれを適用すると、比較コストのほうが上回って、逆にパフォーマンスが劣化する。
- オブジェクトのインライン生成に気をつけろ
`useCallback` で関数をメモ化しても、親から `style={{ margin: 10 }}` のようにJSX内でオブジェクトをインラインで生成して渡していれば、結局それは「毎回のレンダリングで新しいオブジェクトの生成」を意味する。オブジェクトを渡す場合は `useMemo` を組み合わせる必要がある。
—
まとめ
`useState` による状態更新と `React.memo`・`useCallback` の連携は、Reactアプリケーションがスケールしてきたときに必ず直面するボトルネックを解消するための強力な武器だ。
- 親のstate変更が子に波及する仕組みを理解する。
- 子に渡す関数は `useCallback` で防衛し、可能な限り依存配列をスリムにする。
- 「本当に最適化が必要な箇所(重いリスト描画や複雑なUI)」を見極めて適材適所で使う。
この基本原則さえ身体に染み込ませておけば、どれだけ複雑なアプリケーションを任されても、パフォーマンスで頭を抱えることはグッと減るはずだ。さあ、エディタを開いて、君のコードベースの無駄な再レンダリングを狩りに行こう!

コメント