「Reactが遅い」と感じたとき、たいていの原因は不要な再レンダリングだ。コンポーネントが必要以上に何度も再実行されて、UIの更新に時間がかかっている。
僕自身、リストが数百件あるページでスクロールするたびに引っかかりを感じて、React DevToolsを開いてみたら全コンポーネントが毎フレーム再レンダリングされていた、という経験がある。
この記事では、再レンダリングが起きる仕組みを理解したうえで、memo・useMemo・useCallback を使って不要な再レンダリングを防ぐ実践的な方法を解説する。「なんとなく memo を貼っておけばいい」ではなく、「なぜこの場面で必要なのか」を理解して使えるようになるはずだ。
再レンダリングはなぜ起きるのか?
Reactの再レンダリングには3つのトリガーがある。
- 自身のstateが変わった(
setStateが呼ばれた) - 親コンポーネントが再レンダリングされた
- useContextの値が変わった
2番目が特に重要だ。親が再レンダリングされると、子コンポーネントは全て再レンダリングされる(デフォルトの挙動)。
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>+1</button>
{/* count が変わるたびに、ChildA と ChildB も再レンダリングされる */}
<ChildA />
<ChildB />
</div>
);
}
ChildA と ChildB は count を props で受け取っていないのに、Parent の state が変わるだけで再レンダリングされる。このコストが積み重なるとパフォーマンス問題になる。
再レンダリングはそれ自体は悪ではない
重要な前置き:再レンダリング自体は悪ではない。Reactは再レンダリングが速くなるよう設計されていて、実際の DOM 操作は差分だけに最小化されている。数十個のシンプルなコンポーネントが再レンダリングされても、体感速度に影響はほぼない。
最適化が必要なのは:
- 重い計算を含むコンポーネント
- 数百件以上のリストアイテム
- 60fps を保ちたいアニメーション周り
「まず計測してから最適化」が鉄則だ。
React DevToolsで再レンダリングを可視化する
最適化の前に、どのコンポーネントが不要に再レンダリングされているかを確認する。
React DevTools(ブラウザ拡張)のProfilerタブを使う。
- React DevToolsをインストールする
- Profilerタブを開く
- 歯車アイコン(Settings)→「Highlight updates when components render」をオンにする
- アプリを操作すると、再レンダリングされたコンポーネントがハイライトされる
色の意味:
- 青 — 1回のレンダリング(少ない)
- 緑 — 数回のレンダリング
- 黄 — 多めのレンダリング
- 赤 — 非常に多いレンダリング(要注意)
React.memoで不要な再レンダリングを防ぐ
React.memo を使うと、propsが変わっていない場合にコンポーネントの再レンダリングをスキップできる。
❌ 最適化前
function Parent() {
const [count, setCount] = useState(0);
const [name, setName] = useState("太郎");
return (
<div>
<button onClick={() => setCount(c => c + 1)}>count: {count}</button>
<input value={name} onChange={e => setName(e.target.value)} />
{/* count が変わるだけで NameDisplay が再レンダリングされる */}
<NameDisplay name={name} />
</div>
);
}
function NameDisplay({ name }: { name: string }) {
console.log("NameDisplay rendered"); // 毎回呼ばれる
return <p>こんにちは、{name}さん</p>;
}
✅ React.memoで最適化
// React.memoでwrap。nameが変わったときだけ再レンダリングされる
const NameDisplay = memo(function NameDisplay({ name }: { name: string }) {
console.log("NameDisplay rendered"); // nameが変わったときだけ呼ばれる
return <p>こんにちは、{name}さん</p>;
});
memo はpropsを Object.is で比較する。プリミティブ値(文字列、数値、真偽値)なら正しく比較できる。
memoが効かないケース
function Parent() {
const [count, setCount] = useState(0);
// ❌ オブジェクトはレンダリングのたびに新しい参照が生成される
const user = { name: "太郎", age: 20 };
// ❌ 関数もレンダリングのたびに新しい参照が生成される
const handleClick = () => console.log("clicked");
return (
<div>
<button onClick={() => setCount(c => c + 1)}>+1</button>
{/* user と handleClick の参照が毎回変わるので、memo が効かない */}
<UserCard user={user} onClick={handleClick} />
</div>
);
}
const UserCard = memo(function UserCard({
user,
onClick,
}: {
user: { name: string; age: number };
onClick: () => void;
}) {
console.log("UserCard rendered"); // 毎回呼ばれてしまう
return (
<div onClick={onClick}>
{user.name}({user.age}歳)
</div>
);
});
user と handleClick は毎レンダリングで新しい参照が生成されるので、memo は「propsが変わった」と判断して再レンダリングを実行してしまう。これを解決するのが useMemo と useCallback だ。
useMemoでオブジェクト参照を安定させる
useMemo は計算結果をメモ化して、依存値が変わらない限り同じ参照を返す。
function Parent() {
const [count, setCount] = useState(0);
const [name, setName] = useState("太郎");
const [age, setAge] = useState(20);
// ✅ name か age が変わったときだけ新しいオブジェクトを生成
const user = useMemo(() => ({ name, age }), [name, age]);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>count: {count}</button>
{/* count が変わっても user の参照は変わらない → UserCard の再レンダリングをスキップ */}
<UserCard user={user} />
</div>
);
}
const UserCard = memo(function UserCard({
user,
}: {
user: { name: string; age: number };
}) {
console.log("UserCard rendered");
return <div>{user.name}({user.age}歳)</div>;
});
useMemoで重い計算をキャッシュする
参照安定化だけでなく、計算コストの高い処理をキャッシュするためにも使う。
function SearchResults({ items, query }: { items: Item[]; query: string }) {
// ❌ 毎レンダリングでフィルタリングが実行される
// const filtered = items.filter(item => item.name.includes(query));
// ✅ items か query が変わったときだけフィルタリングを実行
const filtered = useMemo(
() => items.filter(item => item.name.toLowerCase().includes(query.toLowerCase())),
[items, query]
);
return (
<ul>
{filtered.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
items が1000件あって、複雑なフィルタリングや並べ替えを行う場合は useMemo が効果的だ。一方、10件程度のシンプルな処理なら useMemo のオーバーヘッドの方が大きいこともある。
useCallbackで関数参照を安定させる
useCallback は関数をメモ化して、依存値が変わらない限り同じ関数参照を返す。
function Parent() {
const [count, setCount] = useState(0);
const [name, setName] = useState("太郎");
// ✅ name が変わったときだけ新しい関数を生成
const handleUserClick = useCallback(() => {
console.log(`${name}がクリックされた`);
}, [name]);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>count: {count}</button>
{/* count が変わっても handleUserClick の参照は変わらない */}
<UserCard onClick={handleUserClick} />
</div>
);
}
const UserCard = memo(function UserCard({ onClick }: { onClick: () => void }) {
console.log("UserCard rendered");
return <button onClick={onClick}>クリック</button>;
});
useMemoとuseCallbackの関係
useCallback(fn, deps) は useMemo(() => fn, deps) と等価だ。関数を返すための useMemo のショートハンドと考えると覚えやすい。
// この2つは同じ
const fn1 = useCallback(() => doSomething(a, b), [a, b]);
const fn2 = useMemo(() => () => doSomething(a, b), [a, b]);
実践:重いリストの最適化
数百件のリストで各アイテムに操作ボタンがある場面を例に、最適化の全体像を見てみよう。
❌ 最適化前
function TodoList() {
const [todos, setTodos] = useState(initialTodos); // 500件
const [filter, setFilter] = useState("all");
// フィルタリング: 毎レンダリングで実行
const filtered = todos.filter(todo => {
if (filter === "done") return todo.done;
if (filter === "pending") return !todo.done;
return true;
});
// 関数: 毎レンダリングで新しい参照が生成
const handleToggle = (id: string) => {
setTodos(prev => prev.map(t => t.id === id ? { ...t, done: !t.done } : t));
};
const handleDelete = (id: string) => {
setTodos(prev => prev.filter(t => t.id !== id));
};
return (
<div>
<FilterButtons filter={filter} onChange={setFilter} />
<ul>
{filtered.map(todo => (
// TodoItem は filter が変わるたびに全件再レンダリングされる
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle}
onDelete={handleDelete}
/>
))}
</ul>
</div>
);
}
✅ 最適化後
function TodoList() {
const [todos, setTodos] = useState(initialTodos); // 500件
const [filter, setFilter] = useState("all");
// ✅ filter か todos が変わったときだけフィルタリングを実行
const filtered = useMemo(
() => todos.filter(todo => {
if (filter === "done") return todo.done;
if (filter === "pending") return !todo.done;
return true;
}),
[todos, filter]
);
// ✅ 関数参照を安定させる(todos は関数内で使わないのでdepsに入れない)
const handleToggle = useCallback((id: string) => {
setTodos(prev => prev.map(t => t.id === id ? { ...t, done: !t.done } : t));
}, []); // 関数型更新なので todos を deps に入れなくていい
const handleDelete = useCallback((id: string) => {
setTodos(prev => prev.filter(t => t.id !== id));
}, []);
return (
<div>
<FilterButtons filter={filter} onChange={setFilter} />
<ul>
{filtered.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle} // 参照が安定している
onDelete={handleDelete} // 参照が安定している
/>
))}
</ul>
</div>
);
}
// ✅ memo でwrap。todo・onToggle・onDelete が変わらなければスキップ
const TodoItem = memo(function TodoItem({
todo,
onToggle,
onDelete,
}: {
todo: Todo;
onToggle: (id: string) => void;
onDelete: (id: string) => void;
}) {
return (
<li>
<span style={{ textDecoration: todo.done ? "line-through" : "none" }}>
{todo.text}
</span>
<button onClick={() => onToggle(todo.id)}>完了</button>
<button onClick={() => onDelete(todo.id)}>削除</button>
</li>
);
});
最適化のポイント:
useMemoでフィルタリングをキャッシュ — 500件のフィルタ処理を毎レンダリングから解放useCallbackでハンドラ参照を安定化 — 関数型更新prev =>を使えばtodosをdepsに入れなくていいmemoで TodoItem をラップ — 関係ない変更では再レンダリングをスキップ
コンテキストによる不要な再レンダリングを防ぐ
useContext を使うと、コンテキストの値が変わったときにそのコンテキストを使っている全コンポーネントが再レンダリングされる。これが意図しない再レンダリングの原因になりやすい。
// ❌ 一つのコンテキストに全てを詰め込む
const AppContext = createContext({
user: null,
theme: "dark",
notifications: [],
// ...
});
// notifications が変わるだけで user しか使っていないコンポーネントも再レンダリングされる
// ✅ 用途別にコンテキストを分割する
const UserContext = createContext(null);
const ThemeContext = createContext("dark");
const NotificationContext = createContext([]);
// UserContext しか使っていないコンポーネントは
// notifications が変わっても再レンダリングされない
コンテキストはなるべく「変化の頻度が似た値ごと」に分けると、余計な再レンダリングを避けられる。
まとめ
再レンダリングの最適化は「計測 → 特定 → 対処」の順番で進める。
| ツール | 役割 | 使いどころ |
|---|---|---|
React.memo | propsが変わらなければ再レンダリングをスキップ | 重いコンポーネント、不要な再レンダリングが確認できたもの |
useMemo | 計算結果 or オブジェクト参照をメモ化 | 重い計算、memoコンポーネントに渡すオブジェクト |
useCallback | 関数参照をメモ化 | memoコンポーネントに渡す関数、useEffectの依存関数 |
「とりあえず memo を貼る」は逆効果になることもある。まず React DevTools Profiler で実際に不要な再レンダリングが起きているかを確認して、原因が特定できてから対処するのが正しい順番だ。
最適化の優先順位:
- 計測して問題を確認する
- コンポーネントの設計(コロケーション)で解決できないかを考える
memo→useCallback→useMemoの順で対処する