Skip to content

Reactの実行モデルと前提

Reactの実行モデルと、実務で事故を防ぐための前提条件を詳しく解説します。

実行モデルとリソースの物理的制約

Section titled “実行モデルとリソースの物理的制約”

Reactは、クライアントサイド(ブラウザ)とサーバーサイド(Next.jsのServer Components等)の両方で実行されます。ただし、従来のReactアプリケーションは主にクライアントサイドで実行されるため、ブラウザのリソース制約を考慮する必要があります。

注意: Next.js(Server Components)が標準となった現在、Reactは**フルスタックなWebアプリケーションを構築するための基盤(アーキテクチャ)**へと進化しました。Server Componentsでは、サーバーサイドで実行されるため、ブラウザのリソース制約の影響を受けません。

【まさかり】Server Componentsのリソース制約: サーバーサイドで実行されるということは、**「リソース制約の主語が『ユーザーのブラウザ』から『自社のサーバー』に移るだけ」**です。サーバー側で大量のデータをフェッチしてメモリに載せれば、サーバーのメモリが枯渇し、全ユーザーに影響が出る(マルチテナント的な事故)可能性があります。ブラウザなら一人のクラッシュで済みますが、サーバー側はより慎重なリソース管理が必要です。

ブラウザ環境:

  1. ブラウザメモリ(ヒープメモリ)

    • モバイルデバイス: 通常512MB〜2GB
    • デスクトップ: 通常2GB〜4GB
    • メモリリークは数時間後にブラウザがクラッシュする
  2. DOMノード数

    • ブラウザの制限: 通常10,000〜100,000ノード
    • 大量のDOMノードはレンダリングパフォーマンスを低下させる
    • 解決策: ウィンドウイング(Windowing)/ 仮想リスト(Virtualization)を使用
  3. イベントリスナー

    • 削除されないイベントリスナーはメモリリークの原因
    • クロージャーで参照が保持される
  4. クロージャによるメモリリーク(React特有)

    • useEffect内でsetIntervalなどを使い、クリーンアップ関数(return () => ...)を忘れると、コンポーネントが消えてもメモリ上のデータが参照され続ける
    • これが真のメモリリークの温床(GCが効かない)
  5. 再レンダリング

    • 不要な再レンダリングはパフォーマンスを低下させる
    • メモリ使用量が増加する
    • 仮想DOMの差分計算にもコストがかかる

実際の事故例:

10:00:00 - アプリケーション起動(メモリ使用量: 50MB)
10:00:01 - コンポーネント1レンダリング(メモリ使用量: 100MB)
10:00:02 - コンポーネント2レンダリング(メモリ使用量: 150MB)
...
10:30:00 - コンポーネント100レンダリング(メモリ使用量: 2GB)
10:30:01 - ブラウザがクラッシュ
10:30:02 - ユーザー体験の大幅な低下

【まさかり】「ライフサイクル」から「同期(Synchronization)」へ

Section titled “【まさかり】「ライフサイクル」から「同期(Synchronization)」へ”

現代のReact(Hooks以降)では、これらを「ライフサイクル」と呼ぶよりも、**「同期(Synchronization)」**と呼ぶのが公式の考え方です。

❌ 古い考え方(クラスコンポーネント):

Reactコンポーネントのライフサイクル
├─ マウント(Mount)
│ ├─ constructor
│ ├─ render
│ └─ componentDidMount
├─ 更新(Update)
│ ├─ render
│ └─ componentDidUpdate
└─ アンマウント(Unmount)
└─ componentWillUnmount

✅ 現代の考え方(Hooks):

// useEffectは「マウント時に実行される関数」ではなく、
// 「PropsやStateの変化を外部システム(DOMやAPI)に同期させるための脱出ハッチ」
function Component({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
// PropsやStateの変化を外部システム(API)に同期
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]); // userIdが変わったら再同期
return <div>{user?.name}</div>;
}

重要な特徴:

  1. 宣言的UI: UIをデータ(状態)の投影として扱えるプログラミングモデル
  2. 再レンダリング: 状態が変更されると再レンダリング
  3. 同期(Synchronization): useEffectはPropsやStateの変化を外部システムに同期させるための脱出ハッチ
  4. フック: 関数コンポーネントで状態管理と同期を管理
  5. 仮想DOM: 実際のDOMの代わりに仮想DOMを使用(UIの冪等性を実現するための手段)

【まさかり】仮想DOMの真の役割: 仮想DOMそのものが速いわけではありません。SvelteやSolidJSなど「仮想DOMを使わない(No VDOM)」ライブラリの方が高速なケースも多いです。Reactの真の強みは**「UIの冪等性(Idempotency)」**です。仮想DOMは速さのためではなく、開発者が「以前のDOMがどうだったか」を気にせず、「今のデータに基づくとUIはどうあるべきか」だけを宣言すれば、Reactが「最小限の差分」を計算して適用してくれるという「開発者体験」と「バグの抑制」に本質があります。

制約:

// ❌ 悪い例: クライアントサイドで問題のあるコード
function Component() {
const [data, setData] = useState<any[]>([]);
useEffect(() => {
// 問題: 大量のデータをメモリに保持
fetch('/api/large-data')
.then(res => res.json())
.then(json => setData(json));
}, []);
return (
<div>
{data.map(item => (
<div key={item.id}>{item.text}</div>
))}
</div>
);
}

問題点:

  • メモリ制限: ブラウザのメモリ制限により、大量のデータを保持できない
  • DOMノード数の増加: 大量のDOMノードが作成され、レンダリングパフォーマンスが低下
  • 再レンダリング: 状態が変更されるたびに再レンダリングが発生

【まさかり】大量のDOM問題への解決策: ウィンドウイング(Windowing)/ 仮想リスト(Virtualization)

Section titled “【まさかり】大量のDOM問題への解決策: ウィンドウイング(Windowing)/ 仮想リスト(Virtualization)”

10万ノードのDOMを避けるために、**「ウィンドウイング(Windowing)」または「仮想リスト(Virtualization)」**を使用します。画面に見えている分だけをDOM化する技術です。

❌ 問題のあるコード: 全データをDOM化

function ProductList({ products }: { products: Product[] }) {
// 問題: 10,000件のデータをすべてDOM化すると、10,000個のDOMノードが作成される
return (
<div>
{products.map(product => (
<div key={product.id}>{product.name}</div>
))}
</div>
);
}
// 問題点:
// - 10,000件のデータで10,000個のDOMノードが作成される
// - レンダリングが重くなる
// - メモリ使用量が増加

✅ 解決策: 仮想リスト(react-window / tanstack-virtual)

import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
function VirtualProductList({ products }: { products: Product[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: products.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // 各アイテムの高さ(推定)
overscan: 5, // 表示範囲の前後に5個ずつ余分にレンダリング(スクロール時のちらつき防止)
});
return (
<div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{products[virtualItem.index].name}
</div>
))}
</div>
</div>
);
}
// 利点:
// - 表示範囲のアイテムのみをDOM化(通常10〜20個程度)
// - 10,000件のデータでもDOMノード数は10〜20個に制限される
// - レンダリングパフォーマンスが大幅に向上
// - メモリ使用量が削減

推奨ライブラリ:

  • @tanstack/react-virtual: モダンで柔軟な仮想リストライブラリ(推奨)
  • react-window: シンプルで軽量な仮想リストライブラリ

使用例: react-window

import { FixedSizeList } from 'react-window';
function ProductList({ products }: { products: Product[] }) {
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
<div style={style}>
{products[index].name}
</div>
);
return (
<FixedSizeList
height={500}
itemCount={products.length}
itemSize={50}
width="100%"
>
{Row}
</FixedSizeList>
);
}

【まさかり】クロージャによるメモリリーク(React特有)

Section titled “【まさかり】クロージャによるメモリリーク(React特有)”

Reactでは、useEffect内でsetIntervalなどを使い、クリーンアップ関数(return () => ...)を忘れると、コンポーネントが消えてもメモリ上のデータが参照され続け、GC(ガベージコレクション)されません。これが真のメモリリークの温床です。

❌ 問題のあるコード: クリーンアップを忘れる

function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
// 問題: クリーンアップ関数を忘れている
const interval = setInterval(() => {
setCount(c => c + 1);
// コンポーネントがアンマウントされても、このクロージャが参照され続ける
// GCが効かない → メモリリーク
}, 1000);
// ❌ return () => clearInterval(interval); がない
}, []);
return <div>{count}</div>;
}

問題点:

  • コンポーネントがアンマウントされても、setIntervalが実行され続ける
  • setCountへの参照が保持され、クロージャがメモリに残り続ける
  • GCが効かず、メモリリークが発生

✅ 解決策: クリーンアップ関数を必ず実装

function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCount(c => c + 1);
}, 1000);
// ✅ クリーンアップ関数: コンポーネントがアンマウントされたときに実行
return () => {
clearInterval(interval);
// これにより、メモリリークを防ぐ
};
}, []);
return <div>{count}</div>;
}

実践例: AbortControllerとの組み合わせ

function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const abortController = new AbortController();
fetch(`/api/users/${userId}`, { signal: abortController.signal })
.then(res => res.json())
.then(data => setUser(data))
.catch(error => {
if (error.name !== 'AbortError') {
console.error('Failed to fetch user:', error);
}
});
// ✅ クリーンアップ: コンポーネントがアンマウントされるか、userIdが変わったときに
// 未完了のリクエストをキャンセル
return () => {
abortController.abort();
};
}, [userId]);
return <div>{user?.name}</div>;
}
環境特徴主なリスク
ブラウザクライアントサイド実行メモリリーク、DOMノード数の増加、イベントリスナーのリーク、不要な再レンダリング

Reactの実行モデルと前提のポイント:

  • リソースの物理的制約: ブラウザメモリ・DOMノード数・イベントリスナー・クロージャによるメモリリーク・再レンダリングの制約を考慮
  • 同期(Synchronization): useEffectはPropsやStateの変化を外部システムに同期させるための脱出ハッチ
  • 仮想DOMの真の役割: UIの冪等性を実現するための手段(速さのためではなく、開発者体験とバグの抑制)
  • 大量のDOM問題: ウィンドウイング(Windowing)/ 仮想リスト(Virtualization)で解決
  • クロージャによるメモリリーク: useEffectのクリーンアップ関数を必ず実装
  • Server Componentsのリソース制約: サーバー側でもリソース管理は重要(マルチテナント的な事故を防ぐ)

重要な原則: 性能ではなく制約を前提に設計する。リソースの垂れ流しは数時間後にブラウザがクラッシュする。