Reactの実行モデルと前提
Reactの実行モデルと前提
Section titled “Reactの実行モデルと前提”Reactの実行モデルと、実務で事故を防ぐための前提条件を詳しく解説します。
実行モデルとリソースの物理的制約
Section titled “実行モデルとリソースの物理的制約”Reactは、クライアントサイド(ブラウザ)とサーバーサイド(Next.jsのServer Components等)の両方で実行されます。ただし、従来のReactアプリケーションは主にクライアントサイドで実行されるため、ブラウザのリソース制約を考慮する必要があります。
注意: Next.js(Server Components)が標準となった現在、Reactは**フルスタックなWebアプリケーションを構築するための基盤(アーキテクチャ)**へと進化しました。Server Componentsでは、サーバーサイドで実行されるため、ブラウザのリソース制約の影響を受けません。
【まさかり】Server Componentsのリソース制約: サーバーサイドで実行されるということは、**「リソース制約の主語が『ユーザーのブラウザ』から『自社のサーバー』に移るだけ」**です。サーバー側で大量のデータをフェッチしてメモリに載せれば、サーバーのメモリが枯渇し、全ユーザーに影響が出る(マルチテナント的な事故)可能性があります。ブラウザなら一人のクラッシュで済みますが、サーバー側はより慎重なリソース管理が必要です。
主な物理的制約
Section titled “主な物理的制約”ブラウザ環境:
-
ブラウザメモリ(ヒープメモリ)
- モバイルデバイス: 通常512MB〜2GB
- デスクトップ: 通常2GB〜4GB
- メモリリークは数時間後にブラウザがクラッシュする
-
DOMノード数
- ブラウザの制限: 通常10,000〜100,000ノード
- 大量のDOMノードはレンダリングパフォーマンスを低下させる
- 解決策: ウィンドウイング(Windowing)/ 仮想リスト(Virtualization)を使用
-
イベントリスナー
- 削除されないイベントリスナーはメモリリークの原因
- クロージャーで参照が保持される
-
クロージャによるメモリリーク(React特有)
useEffect内でsetIntervalなどを使い、クリーンアップ関数(return () => ...)を忘れると、コンポーネントが消えてもメモリ上のデータが参照され続ける- これが真のメモリリークの温床(GCが効かない)
-
再レンダリング
- 不要な再レンダリングはパフォーマンスを低下させる
- メモリ使用量が増加する
- 仮想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 - ユーザー体験の大幅な低下Reactの実行モデル
Section titled “Reactの実行モデル”【まさかり】「ライフサイクル」から「同期(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>;}重要な特徴:
- 宣言的UI: UIをデータ(状態)の投影として扱えるプログラミングモデル
- 再レンダリング: 状態が変更されると再レンダリング
- 同期(Synchronization):
useEffectはPropsやStateの変化を外部システムに同期させるための脱出ハッチ - フック: 関数コンポーネントで状態管理と同期を管理
- 仮想DOM: 実際のDOMの代わりに仮想DOMを使用(UIの冪等性を実現するための手段)
【まさかり】仮想DOMの真の役割: 仮想DOMそのものが速いわけではありません。SvelteやSolidJSなど「仮想DOMを使わない(No VDOM)」ライブラリの方が高速なケースも多いです。Reactの真の強みは**「UIの冪等性(Idempotency)」**です。仮想DOMは速さのためではなく、開発者が「以前のDOMがどうだったか」を気にせず、「今のデータに基づくとUIはどうあるべきか」だけを宣言すれば、Reactが「最小限の差分」を計算して適用してくれるという「開発者体験」と「バグの抑制」に本質があります。
クライアントサイドでの実行
Section titled “クライアントサイドでの実行”制約:
// ❌ 悪い例: クライアントサイドで問題のあるコード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>;}実行環境による特性
Section titled “実行環境による特性”| 環境 | 特徴 | 主なリスク |
|---|---|---|
| ブラウザ | クライアントサイド実行 | メモリリーク、DOMノード数の増加、イベントリスナーのリーク、不要な再レンダリング |
Reactの実行モデルと前提のポイント:
- リソースの物理的制約: ブラウザメモリ・DOMノード数・イベントリスナー・クロージャによるメモリリーク・再レンダリングの制約を考慮
- 同期(Synchronization):
useEffectはPropsやStateの変化を外部システムに同期させるための脱出ハッチ - 仮想DOMの真の役割: UIの冪等性を実現するための手段(速さのためではなく、開発者体験とバグの抑制)
- 大量のDOM問題: ウィンドウイング(Windowing)/ 仮想リスト(Virtualization)で解決
- クロージャによるメモリリーク:
useEffectのクリーンアップ関数を必ず実装 - Server Componentsのリソース制約: サーバー側でもリソース管理は重要(マルチテナント的な事故を防ぐ)
重要な原則: 性能ではなく制約を前提に設計する。リソースの垂れ流しは数時間後にブラウザがクラッシュする。