React
React 完全ガイド:モダンUI構築ライブラリの全貌
1. はじめに
1.1 Reactとは何か
React(リアクト)は、Meta(旧Facebook)によって開発・メンテナンスされているオープンソースのJavaScriptライブラリである。ユーザーインターフェース(UI)を構築するために設計されており、特にシングルページアプリケーション(SPA)やインタラクティブなWebアプリケーションの開発において広く採用されている。
Reactは2013年にFacebookによってオープンソースとして公開された。当初はFacebookのニュースフィードの開発に使用され、その後Instagram、WhatsApp、Netflix、Airbnbなど世界中の大規模サービスで採用されるに至った。2024年現在、npmの週間ダウンロード数は2,000万回を超え、フロントエンド開発における事実上の標準ライブラリとなっている。
1.2 Reactが解決する課題
従来のDOM操作ベースのWeb開発では、以下のような課題が存在していた。
- DOM操作の複雑さ: jQueryなどのライブラリを使用しても、大規模アプリケーションではDOM操作が複雑化し、バグが生じやすかった
- 状態管理の困難さ: アプリケーションの状態とUIの同期を手動で行う必要があり、状態が増えるほど管理が困難になった
- 再利用性の低さ: UIパーツの再利用が体系的にサポートされておらず、コードの重複が発生しやすかった
- パフォーマンスの課題: 頻繁なDOM操作はブラウザのリフロー・リペイントを引き起こし、パフォーマンスの低下を招いた
Reactはこれらの課題に対して、宣言的UI、コンポーネントベースアーキテクチャ、仮想DOMという3つの核となるアイデアで解決策を提供した。
1.3 Reactの位置づけ
Reactは「ライブラリ」であり、「フレームワーク」ではない。この区別は重要である。Angular やVue.jsのようなフルスタックフレームワークとは異なり、Reactはビュー層のみに焦点を当てている。ルーティング、状態管理、HTTPクライアントなどの機能は含まれておらず、必要に応じてサードパーティライブラリを組み合わせて使用する。
ただし、近年ではNext.jsやRemixといったReactベースのフレームワークが登場し、Reactチーム自身もこれらのフレームワーク経由での利用を推奨するようになっている。React公式ドキュメントでは、新規プロジェクトの開始時にNext.jsなどのフレームワークの使用を推奨している。
// Reactの最もシンプルな例
import { createRoot } from 'react-dom/client';
function App() {
return <h1>Hello, React!</h1>;
}
const root = createRoot(document.getElementById('root'));
root.render(<App />);
1.4 Reactのバージョン履歴と進化
Reactは2013年のv0.3.0から始まり、大きな進化を遂げてきた。
| バージョン | リリース年 | 主な変更点 |
|---|---|---|
| 0.3.0 | 2013年 | 初回オープンソースリリース |
| 15.x | 2016年 | SVGサポート改善、エラーメッセージ改善 |
| 16.0 | 2017年 | Fiber アーキテクチャ導入、Error Boundaries、Portals |
| 16.3 | 2018年 | 新しいContext API、createRef、forwardRef |
| 16.8 | 2019年 | Hooks 導入(useState、useEffect等) |
| 17.0 | 2020年 | イベントデリゲーション変更、段階的アップグレード対応 |
| 18.0 | 2022年 | Concurrent Features、自動バッチング、useTransition |
| 19.0 | 2024年 | React Compiler、Server Components安定版、Actions |
特にReact 16.8で導入されたHooksは、クラスコンポーネント中心の開発スタイルを関数コンポーネント中心へと大きく転換させた歴史的な変更である。
2. コアコンセプト
Reactを理解する上で最も重要な4つのコアコンセプトを詳細に解説する。
2.1 宣言的UI(Declarative UI)
Reactは宣言的なプログラミングパラダイムを採用している。開発者は「どのようにUIを操作するか」ではなく、「UIがどのような状態であるべきか」を記述する。
命令的アプローチ(従来のDOM操作):
// 命令的: DOMを直接操作する
const button = document.createElement('button');
button.textContent = 'クリック数: 0';
button.addEventListener('click', () => {
const currentCount = parseInt(button.textContent.split(': ')[1]);
button.textContent = `クリック数: ${currentCount + 1}`;
});
document.body.appendChild(button);
宣言的アプローチ(React):
// 宣言的: 状態に基づいてUIを宣言する
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
クリック数: {count}
</button>
);
}
宣言的アプローチの利点は以下の通りである。
- 可読性: UIの最終的な姿が一目でわかる
- 予測可能性: 同じ状態からは常に同じUIが生成される
- デバッグの容易さ: 状態を確認すればUIの状態も把握できる
- テスタビリティ: 状態を注入してUIの出力を検証できる
2.2 コンポーネント指向(Component-Based Architecture)
Reactでは、UIを独立した再利用可能なコンポーネントに分割する。各コンポーネントは自身の状態とロジックをカプセル化し、他のコンポーネントと組み合わせて複雑なUIを構築する。
// 小さなコンポーネントを組み合わせてページを構築する
function Avatar({ src, alt }) {
return <img className="avatar" src={src} alt={alt} />;
}
function UserInfo({ user }) {
return (
<div className="user-info">
<Avatar src={user.avatarUrl} alt={user.name} />
<span className="user-name">{user.name}</span>
</div>
);
}
function Comment({ comment }) {
return (
<div className="comment">
<UserInfo user={comment.author} />
<p className="comment-text">{comment.text}</p>
<span className="comment-date">
{formatDate(comment.date)}
</span>
</div>
);
}
function CommentList({ comments }) {
return (
<div className="comment-list">
{comments.map(comment => (
<Comment key={comment.id} comment={comment} />
))}
</div>
);
}
コンポーネント設計の原則として以下が挙げられる。
- 単一責任の原則: 各コンポーネントは1つの責務のみを持つ
- 再利用性: 異なるコンテキストで使い回せるように設計する
- 合成可能性: 小さなコンポーネントを組み合わせて大きなコンポーネントを作る
- テスト容易性: 独立してテスト可能なサイズに保つ
2.3 単方向データフロー(Unidirectional Data Flow)
Reactでは、データは親コンポーネントから子コンポーネントへと一方向に流れる。このパターンは「トップダウンデータフロー」または「単方向データフロー」と呼ばれる。
[親コンポーネント]
│
│ props(データを渡す)
▼
[子コンポーネント]
│
│ props(データを渡す)
▼
[孫コンポーネント]
// 親から子へのデータフロー
function ParentComponent() {
const [items, setItems] = useState([
{ id: 1, name: 'りんご', price: 150 },
{ id: 2, name: 'バナナ', price: 100 },
{ id: 3, name: 'みかん', price: 80 },
]);
const handleDelete = (id) => {
setItems(items.filter(item => item.id !== id));
};
return (
<div>
<h1>商品リスト</h1>
<ItemList items={items} onDelete={handleDelete} />
</div>
);
}
function ItemList({ items, onDelete }) {
return (
<ul>
{items.map(item => (
<ItemRow key={item.id} item={item} onDelete={onDelete} />
))}
</ul>
);
}
function ItemRow({ item, onDelete }) {
return (
<li>
{item.name} - ¥{item.price}
<button onClick={() => onDelete(item.id)}>削除</button>
</li>
);
}
子コンポーネントが親の状態を変更したい場合は、親からコールバック関数をpropsとして受け取り、それを呼び出す。これにより、データの流れが予測可能になり、デバッグが容易になる。
2.4 仮想DOM(Virtual DOM)
仮想DOM(Virtual DOM)は、Reactのパフォーマンス最適化の核心となる概念である。実際のDOMの軽量なJavaScriptオブジェクト表現であり、Reactはこの仮想DOMを使用してUIの更新を効率化する。
仮想DOMの動作フロー:
- 初回レンダリング: Reactは仮想DOMツリーを作成し、それを基に実際のDOMを構築する
- 状態変更時: 新しい仮想DOMツリーを生成する
- 差分計算(Reconciliation): 前回の仮想DOMツリーと新しい仮想DOMツリーを比較し、差分を算出する
- 最小限のDOM更新: 差分のみを実際のDOMに反映する
[状態変更]
│
▼
[新しい仮想DOM生成]
│
▼
[前回の仮想DOMと比較(Diffing)]
│
▼
[差分を実際のDOMに適用(Patching)]
仮想DOMの差分アルゴリズム(Reconciliation Algorithm)は、以下のヒューリスティクスに基づいている。
- 異なるタイプの要素は異なるツリーを生成する:
<div>が<span>に変わった場合、サブツリー全体を再構築する - key属性によりリスト内の要素を識別する:
keyを使用して、リスト内の要素の追加・削除・並べ替えを効率的に処理する - 同じタイプの要素は属性のみ更新する:
<div className="old">が<div className="new">に変わった場合、属性のみを更新する
// key属性による効率的なリスト更新
function TodoList({ todos }) {
return (
<ul>
{todos.map(todo => (
// keyによりReactはどの要素が変更されたかを正確に把握する
<li key={todo.id}>
<span>{todo.text}</span>
<span>{todo.completed ? '完了' : '未完了'}</span>
</li>
))}
</ul>
);
}
React 16以降では、Fiberアーキテクチャが導入され、レンダリング処理を中断・再開できるようになった。これにより、優先度の高い更新を先に処理し、ユーザー体験を向上させることが可能になった。この仕組みがReact 18で本格的に活用されるコンカレント機能の基盤となっている。
3. JSX
3.1 JSXとは何か
JSX(JavaScript XML)は、JavaScript内にHTMLライクな構文を記述できるようにするReactの構文拡張である。JSXはReact要素を生成するための糖衣構文であり、最終的にはJavaScript関数呼び出しに変換される。
// JSX構文
const element = <h1 className="title">こんにちは、世界!</h1>;
// 上記は以下のJavaScriptに変換される(React 17以降の新しいJSX Transform)
import { jsx as _jsx } from 'react/jsx-runtime';
const element = _jsx('h1', { className: 'title', children: 'こんにちは、世界!' });
// React 16以前の古い変換方式
const element = React.createElement('h1', { className: 'title' }, 'こんにちは、世界!');
JSXはブラウザが直接理解できないため、Babel、SWC、TypeScript Compilerなどのトランスパイラによって標準的なJavaScriptに変換される必要がある。
3.2 JSXの基本ルール
JSXにはいくつかの重要なルールがある。
ルール1: 単一のルート要素を返す
コンポーネントは単一のルート要素を返す必要がある。複数の要素を返す場合は、親要素で囲むか、Fragmentを使用する。
// エラー: 複数のルート要素
function Bad() {
return (
<h1>タイトル</h1>
<p>本文</p>
);
}
// 正しい: div で囲む
function GoodWithDiv() {
return (
<div>
<h1>タイトル</h1>
<p>本文</p>
</div>
);
}
// 推奨: Fragment を使用(余分なDOMノードを生成しない)
function GoodWithFragment() {
return (
<>
<h1>タイトル</h1>
<p>本文</p>
</>
);
}
// key が必要な場合は明示的なFragmentを使用
import { Fragment } from 'react';
function GoodWithExplicitFragment({ items }) {
return items.map(item => (
<Fragment key={item.id}>
<dt>{item.term}</dt>
<dd>{item.description}</dd>
</Fragment>
));
}
ルール2: すべてのタグを閉じる
JSXではすべてのタグを閉じる必要がある。HTMLでは省略可能な閉じタグも、JSXでは必須である。
// HTML では許容されるが、JSXではエラー
<img src="photo.jpg">
<br>
<input type="text">
// JSXでは自己閉じタグを使用する
<img src="photo.jpg" />
<br />
<input type="text" />
ルール3: キャメルケースの属性名
JSXの属性名はキャメルケース(camelCase)を使用する。HTMLの属性名との主な違いは以下の通りである。
// HTML属性 → JSX属性
// class → className
// for → htmlFor
// tabindex → tabIndex
// onclick → onClick
// onchange → onChange
// style="..." → style={{...}}
function FormExample() {
return (
<form>
<label htmlFor="email">メールアドレス:</label>
<input
id="email"
type="email"
className="form-input"
tabIndex={1}
autoComplete="email"
onChange={(e) => console.log(e.target.value)}
style={{ marginTop: '10px', fontSize: '16px' }}
/>
</form>
);
}
3.3 式の埋め込み
JSX内では波括弧 {} を使用してJavaScript式を埋め込むことができる。
function UserGreeting({ user }) {
const currentHour = new Date().getHours();
const greeting = currentHour < 12 ? 'おはようございます' :
currentHour < 18 ? 'こんにちは' : 'こんばんは';
return (
<div>
{/* 変数の埋め込み */}
<h1>{greeting}、{user.name}さん</h1>
{/* 式の計算 */}
<p>あなたは{new Date().getFullYear() - user.birthYear}歳です</p>
{/* 関数呼び出し */}
<p>アカウント作成日: {formatDate(user.createdAt)}</p>
{/* オブジェクトのプロパティアクセス */}
<p>メール: {user.contact.email}</p>
{/* テンプレートリテラル */}
<img src={`/avatars/${user.id}.jpg`} alt={`${user.name}のアバター`} />
{/* 配列メソッド */}
<p>スキル: {user.skills.join(', ')}</p>
</div>
);
}
注意点として、{} 内に配置できるのは式(expression)のみであり、文(statement)は配置できない。if 文、for 文、変数宣言などは直接使用できない。
3.4 条件分岐
JSX内で条件に基づいてレンダリングを切り替える方法は複数存在する。
function StatusDisplay({ status, message, items }) {
// パターン1: if文による事前計算
let statusIcon;
if (status === 'success') {
statusIcon = <span className="icon-success">✓</span>;
} else if (status === 'error') {
statusIcon = <span className="icon-error">✗</span>;
} else {
statusIcon = <span className="icon-pending">...</span>;
}
return (
<div>
{/* パターン1の結果を使用 */}
<div>{statusIcon} {message}</div>
{/* パターン2: 三項演算子 */}
<p>{status === 'success' ? '処理が完了しました' : '処理中です...'}</p>
{/* パターン3: 論理AND演算子(&&) */}
{status === 'error' && (
<div className="error-detail">
<p>エラーが発生しました。再試行してください。</p>
</div>
)}
{/* パターン4: 即時実行関数(IIFE)- 複雑なロジックが必要な場合 */}
{(() => {
switch (status) {
case 'success': return <span style={{ color: 'green' }}>成功</span>;
case 'error': return <span style={{ color: 'red' }}>失敗</span>;
case 'pending': return <span style={{ color: 'orange' }}>保留中</span>;
default: return <span>不明</span>;
}
})()}
{/* パターン5: nullを返して何も表示しない */}
{items.length === 0 ? null : (
<ul>
{items.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
)}
</div>
);
}
&&演算子の落とし穴:
// 危険: count が 0 の場合、「0」が画面に表示される
function BadExample({ count }) {
return <div>{count && <span>{count}件の通知</span>}</div>;
}
// 安全: 明示的にboolean変換する
function GoodExample({ count }) {
return <div>{count > 0 && <span>{count}件の通知</span>}</div>;
}
3.5 リストレンダリング
配列データをUIに変換する場合、Array.prototype.map() を使用する。
function ProductCatalog({ products }) {
if (products.length === 0) {
return <p>商品が見つかりませんでした。</p>;
}
return (
<div className="product-catalog">
<h2>商品一覧({products.length}件)</h2>
<div className="product-grid">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
);
}
function ProductCard({ product }) {
return (
<div className="product-card">
<img src={product.imageUrl} alt={product.name} />
<h3>{product.name}</h3>
<p className="price">¥{product.price.toLocaleString()}</p>
<p className="description">{product.description}</p>
{product.inStock ? (
<button className="buy-button">カートに追加</button>
) : (
<span className="out-of-stock">在庫切れ</span>
)}
</div>
);
}
key属性の重要性:
key は、Reactがリスト内の各要素を一意に識別するために使用する。適切な key を設定しないと、パフォーマンスの低下や予期しないバグが発生する。
// 悪い例: indexをkeyに使用(要素の追加・削除・並べ替え時に問題が発生)
{items.map((item, index) => (
<ListItem key={index} item={item} />
))}
// 良い例: 一意のIDをkeyに使用
{items.map(item => (
<ListItem key={item.id} item={item} />
))}
// 一意のIDがない場合は生成する(ただしレンダリングの外で)
const itemsWithIds = items.map((item, index) => ({
...item,
id: item.id || `${item.name}-${index}`, // フォールバック
}));
key に関するルールは以下の通りである。
- 兄弟要素間で一意であること(グローバルに一意である必要はない)
- 安定していること(レンダリング間で変わらないこと)
- 予測可能であること(
Math.random()などを使わないこと) - インデックスをkeyにしないこと(リストが静的で並べ替えが発生しない場合を除く)
4. コンポーネント
4.1 関数コンポーネント
React 16.8以降、関数コンポーネントはHooksの導入により、クラスコンポーネントの全機能を代替できるようになった。現在のReact開発では関数コンポーネントが標準である。
// 最もシンプルな関数コンポーネント
function Welcome() {
return <h1>ようこそ!</h1>;
}
// アロー関数による定義
const Welcome = () => <h1>ようこそ!</h1>;
// propsを受け取るコンポーネント
function Greeting({ name, age }) {
return (
<div>
<h1>こんにちは、{name}さん!</h1>
<p>年齢: {age}歳</p>
</div>
);
}
// デフォルト値の設定
function Button({
variant = 'primary',
size = 'medium',
disabled = false,
children
}) {
return (
<button
className={`btn btn-${variant} btn-${size}`}
disabled={disabled}
>
{children}
</button>
);
}
4.2 Props(プロパティ)
Propsはコンポーネント間でデータを受け渡すための仕組みである。Propsは読み取り専用であり、子コンポーネントがpropsを直接変更することはできない。
// propsの型定義(TypeScript)
interface UserCardProps {
name: string;
email: string;
avatar?: string; // オプショナルなprop
role: 'admin' | 'user' | 'guest';
onEdit?: (id: string) => void; // コールバック関数
}
function UserCard({ name, email, avatar, role, onEdit }: UserCardProps) {
return (
<div className="user-card">
<img
src={avatar || '/default-avatar.png'}
alt={`${name}のアバター`}
/>
<h3>{name}</h3>
<p>{email}</p>
<span className={`badge badge-${role}`}>{role}</span>
{onEdit && (
<button onClick={() => onEdit(email)}>編集</button>
)}
</div>
);
}
// スプレッド構文によるpropsの受け渡し
function App() {
const userProps = {
name: '田中太郎',
email: 'tanaka@example.com',
role: 'admin' as const,
};
return <UserCard {...userProps} />;
}
4.3 children
children は特別なpropで、コンポーネントの開始タグと終了タグの間に配置された要素を受け取る。
// レイアウトコンポーネント
function Card({ title, children, footer }) {
return (
<div className="card">
{title && <div className="card-header"><h3>{title}</h3></div>}
<div className="card-body">{children}</div>
{footer && <div className="card-footer">{footer}</div>}
</div>
);
}
// 使用例
function App() {
return (
<Card
title="ユーザー情報"
footer={<button>保存</button>}
>
<p>名前: 田中太郎</p>
<p>メール: tanaka@example.com</p>
</Card>
);
}
// Render Propsパターン(children as function)
function DataFetcher({ url, children }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(data => { setData(data); setLoading(false); })
.catch(err => { setError(err); setLoading(false); });
}, [url]);
return children({ data, loading, error });
}
// 使用例
function UserList() {
return (
<DataFetcher url="/api/users">
{({ data, loading, error }) => {
if (loading) return <p>読み込み中...</p>;
if (error) return <p>エラー: {error.message}</p>;
return (
<ul>
{data.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}}
</DataFetcher>
);
}
4.4 コンポーネント合成(Composition)
Reactでは継承よりも合成(Composition)が推奨される。コンポーネント合成により、柔軟で再利用可能なUIを構築できる。
// 特殊化パターン: 汎用コンポーネントから特殊なコンポーネントを作る
function Dialog({ title, message, children, onClose }) {
return (
<div className="dialog-overlay">
<div className="dialog">
<div className="dialog-header">
<h2>{title}</h2>
<button onClick={onClose} aria-label="閉じる">×</button>
</div>
<div className="dialog-body">
<p>{message}</p>
{children}
</div>
</div>
</div>
);
}
// 特殊化: 確認ダイアログ
function ConfirmDialog({ message, onConfirm, onCancel }) {
return (
<Dialog title="確認" message={message} onClose={onCancel}>
<div className="dialog-actions">
<button onClick={onCancel} className="btn-secondary">キャンセル</button>
<button onClick={onConfirm} className="btn-primary">確認</button>
</div>
</Dialog>
);
}
// 特殊化: エラーダイアログ
function ErrorDialog({ error, onRetry, onClose }) {
return (
<Dialog title="エラー" message={error.message} onClose={onClose}>
<pre className="error-stack">{error.stack}</pre>
<div className="dialog-actions">
<button onClick={onClose}>閉じる</button>
{onRetry && <button onClick={onRetry}>再試行</button>}
</div>
</Dialog>
);
}
4.5 命名規則
Reactコンポーネントには以下の命名規則が適用される。
// コンポーネント名はPascalCase(大文字始まり)
function UserProfile() { /* ... */ } // ○ 正しい
function userProfile() { /* ... */ } // × 小文字始まりはHTML要素と解釈される
// カスタムHooksは "use" で始まる
function useWindowSize() { /* ... */ } // ○ 正しい
function getWindowSize() { /* ... */ } // × Hookとして認識されない
// イベントハンドラは "handle" + イベント名
function Form() {
const handleSubmit = (e) => { /* ... */ }; // ○ コンポーネント内のハンドラ
const handleInputChange = (e) => { /* ... */ };
// ...
}
// propsとして渡すイベントハンドラは "on" + イベント名
<Form onSubmit={handleSubmit} />
<SearchBar onSearch={handleSearch} />
<UserCard onEdit={handleEdit} onDelete={handleDelete} />
// boolean型のpropsは "is" / "has" / "should" / "can" で始まる
<Modal isOpen={true} />
<Feature isEnabled={false} />
<Form hasErrors={true} />
<Button canSubmit={isValid} />
5. ステート管理
5.1 useState
useState はReactで最も基本的なHookであり、コンポーネントに状態(state)を追加する。
import { useState } from 'react';
// 基本的な使い方
function RegistrationForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [age, setAge] = useState(0);
const [agreeToTerms, setAgreeToTerms] = useState(false);
const [selectedPlan, setSelectedPlan] = useState('free');
const handleSubmit = (e) => {
e.preventDefault();
console.log({ name, email, age, agreeToTerms, selectedPlan });
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>名前:</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div>
<label>メール:</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<label>年齢:</label>
<input
type="number"
value={age}
onChange={(e) => setAge(Number(e.target.value))}
/>
</div>
<div>
<label>
<input
type="checkbox"
checked={agreeToTerms}
onChange={(e) => setAgreeToTerms(e.target.checked)}
/>
利用規約に同意する
</label>
</div>
<div>
<label>プラン:</label>
<select value={selectedPlan} onChange={(e) => setSelectedPlan(e.target.value)}>
<option value="free">無料プラン</option>
<option value="pro">プロプラン</option>
<option value="enterprise">エンタープライズ</option>
</select>
</div>
<button type="submit" disabled={!agreeToTerms}>登録</button>
</form>
);
}
5.2 状態の不変性(Immutability)
Reactの状態更新では、既存のオブジェクトや配列を直接変更(ミューテーション)してはならない。新しいオブジェクトまたは配列を作成して setState に渡す必要がある。
function TodoApp() {
const [todos, setTodos] = useState([
{ id: 1, text: '買い物に行く', completed: false },
{ id: 2, text: 'レポートを書く', completed: false },
]);
// ❌ 悪い例: 直接変更(Reactが変更を検知できない)
const addTodoBad = (text) => {
todos.push({ id: Date.now(), text, completed: false });
setTodos(todos); // 同じ参照なので再レンダリングされない
};
// ✅ 良い例: 新しい配列を作成
const addTodo = (text) => {
setTodos([...todos, { id: Date.now(), text, completed: false }]);
};
// ✅ 要素の更新
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
// ✅ 要素の削除
const removeTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
// ✅ 要素の挿入(特定位置に)
const insertTodoAt = (index, text) => {
const newTodo = { id: Date.now(), text, completed: false };
setTodos([
...todos.slice(0, index),
newTodo,
...todos.slice(index),
]);
};
return (
<div>
<h1>TODOリスト</h1>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<span
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
onClick={() => toggleTodo(todo.id)}
>
{todo.text}
</span>
<button onClick={() => removeTodo(todo.id)}>削除</button>
</li>
))}
</ul>
<button onClick={() => addTodo('新しいタスク')}>追加</button>
</div>
);
}
// ネストされたオブジェクトの不変更新
function ProfileEditor() {
const [profile, setProfile] = useState({
name: '田中太郎',
address: {
city: '東京',
prefecture: '東京都',
zipCode: '100-0001',
},
hobbies: ['読書', 'プログラミング'],
});
// ネストされたプロパティの更新
const updateCity = (newCity) => {
setProfile({
...profile,
address: {
...profile.address,
city: newCity,
},
});
};
// 配列プロパティへの追加
const addHobby = (hobby) => {
setProfile({
...profile,
hobbies: [...profile.hobbies, hobby],
});
};
return (
<div>
<p>名前: {profile.name}</p>
<p>都市: {profile.address.city}</p>
<p>趣味: {profile.hobbies.join(', ')}</p>
<button onClick={() => updateCity('大阪')}>都市を変更</button>
<button onClick={() => addHobby('料理')}>趣味を追加</button>
</div>
);
}
5.3 バッチ更新
React 18以降、すべての状態更新は自動的にバッチ処理される。これにより、複数の setState 呼び出しが1回の再レンダリングにまとめられる。
function BatchingExample() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
const [text, setText] = useState('');
console.log('レンダリング実行'); // 1回のクリックで1回だけ実行される
const handleClick = () => {
// React 18: これらは全てバッチ処理され、1回の再レンダリングになる
setCount(c => c + 1);
setFlag(f => !f);
setText('更新済み');
// レンダリングはここで1回だけ発生する
};
// React 18以前では、setTimeout内の更新はバッチされなかった
// React 18以降は、setTimeout内もバッチされる
const handleAsync = () => {
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// React 18: 1回の再レンダリング
// React 17以前: 2回の再レンダリング
}, 1000);
};
// バッチ処理を無効にしたい場合(稀なケース)
const handleFlush = () => {
import('react-dom').then(({ flushSync }) => {
flushSync(() => {
setCount(c => c + 1);
});
// この時点でDOMは更新済み
flushSync(() => {
setFlag(f => !f);
});
// この時点でDOMは再度更新済み
});
};
return (
<div>
<p>Count: {count}</p>
<p>Flag: {flag.toString()}</p>
<p>Text: {text}</p>
<button onClick={handleClick}>バッチ更新</button>
<button onClick={handleAsync}>非同期バッチ更新</button>
</div>
);
}
5.4 関数型更新
前の状態に基づいて新しい状態を計算する場合は、関数型更新を使用する。
function Counter() {
const [count, setCount] = useState(0);
// ❌ 同じイベント内で複数回呼ぶと意図通りに動かない
const incrementThreeBad = () => {
setCount(count + 1); // count は 0
setCount(count + 1); // count はまだ 0(同じレンダリング内の値)
setCount(count + 1); // count はまだ 0
// 結果: count は 1 になる(3ではない)
};
// ✅ 関数型更新を使用
const incrementThreeGood = () => {
setCount(prev => prev + 1); // 0 → 1
setCount(prev => prev + 1); // 1 → 2
setCount(prev => prev + 1); // 2 → 3
// 結果: count は 3 になる
};
return (
<div>
<p>カウント: {count}</p>
<button onClick={incrementThreeGood}>+3</button>
</div>
);
}
5.5 状態のリフトアップ(Lifting State Up)
複数のコンポーネントが同じ状態を共有する必要がある場合、その状態を最も近い共通の親コンポーネントに「リフトアップ」する。
// 温度変換アプリの例
function TemperatureConverter() {
// 状態を親コンポーネントで管理(リフトアップ)
const [temperature, setTemperature] = useState('');
const [scale, setScale] = useState('celsius');
const handleCelsiusChange = (value) => {
setTemperature(value);
setScale('celsius');
};
const handleFahrenheitChange = (value) => {
setTemperature(value);
setScale('fahrenheit');
};
const celsius = scale === 'fahrenheit'
? tryConvert(temperature, toCelsius)
: temperature;
const fahrenheit = scale === 'celsius'
? tryConvert(temperature, toFahrenheit)
: temperature;
return (
<div>
<h1>温度変換</h1>
<TemperatureInput
scale="celsius"
label="摂氏 (°C)"
temperature={celsius}
onTemperatureChange={handleCelsiusChange}
/>
<TemperatureInput
scale="fahrenheit"
label="華氏 (°F)"
temperature={fahrenheit}
onTemperatureChange={handleFahrenheitChange}
/>
{temperature !== '' && (
<BoilingVerdict celsius={parseFloat(celsius)} />
)}
</div>
);
}
function TemperatureInput({ scale, label, temperature, onTemperatureChange }) {
return (
<fieldset>
<legend>{label}</legend>
<input
type="number"
value={temperature}
onChange={(e) => onTemperatureChange(e.target.value)}
placeholder={`温度を${label}で入力`}
/>
</fieldset>
);
}
function BoilingVerdict({ celsius }) {
if (celsius >= 100) {
return <p className="boiling">水は沸騰します!</p>;
}
return <p>水はまだ沸騰しません。</p>;
}
function toCelsius(fahrenheit) {
return (fahrenheit - 32) * 5 / 9;
}
function toFahrenheit(celsius) {
return (celsius * 9 / 5) + 32;
}
function tryConvert(temperature, convert) {
const input = parseFloat(temperature);
if (Number.isNaN(input)) return '';
const output = convert(input);
return Math.round(output * 1000) / 1000 + '';
}
6. Hooks詳細
React Hooksは、関数コンポーネントにステート管理やライフサイクル機能を追加するための仕組みである。Hooksには厳格な使用ルールがある。
Hooksのルール:
- トップレベルでのみ呼び出す: ループ、条件分岐、ネストされた関数内でHooksを呼び出してはならない
- React関数内でのみ呼び出す: 通常のJavaScript関数からHooksを呼び出してはならない(カスタムHookは可)
// ❌ 悪い例: 条件分岐内でHookを使用
function BadComponent({ isLoggedIn }) {
if (isLoggedIn) {
const [user, setUser] = useState(null); // エラー!
}
}
// ✅ 良い例: トップレベルで使用
function GoodComponent({ isLoggedIn }) {
const [user, setUser] = useState(null); // OK
if (!isLoggedIn) {
return <LoginForm />;
}
return <UserDashboard user={user} />;
}
6.1 State Hooks
useState
基本的な状態管理Hook。前のセクションで詳細に説明済み。
// 遅延初期化: 初期値の計算コストが高い場合
function ExpensiveInitialization() {
// ❌ 毎回のレンダリングで実行される
const [data, setData] = useState(expensiveComputation());
// ✅ 初回レンダリング時のみ実行される
const [data, setData] = useState(() => expensiveComputation());
return <div>{data}</div>;
}
useReducer
複雑な状態ロジックを管理するためのHook。useState の代替であり、Reduxライクなパターンを提供する。
import { useReducer } from 'react';
// アクション型の定義
const ACTIONS = {
ADD_TODO: 'add_todo',
TOGGLE_TODO: 'toggle_todo',
DELETE_TODO: 'delete_todo',
EDIT_TODO: 'edit_todo',
CLEAR_COMPLETED: 'clear_completed',
SET_FILTER: 'set_filter',
};
// 初期状態
const initialState = {
todos: [],
filter: 'all', // 'all' | 'active' | 'completed'
nextId: 1,
};
// Reducer関数
function todoReducer(state, action) {
switch (action.type) {
case ACTIONS.ADD_TODO:
return {
...state,
todos: [
...state.todos,
{ id: state.nextId, text: action.payload, completed: false },
],
nextId: state.nextId + 1,
};
case ACTIONS.TOGGLE_TODO:
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
),
};
case ACTIONS.DELETE_TODO:
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.payload),
};
case ACTIONS.EDIT_TODO:
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload.id
? { ...todo, text: action.payload.text }
: todo
),
};
case ACTIONS.CLEAR_COMPLETED:
return {
...state,
todos: state.todos.filter(todo => !todo.completed),
};
case ACTIONS.SET_FILTER:
return {
...state,
filter: action.payload,
};
default:
throw new Error(`未知のアクション: ${action.type}`);
}
}
function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, initialState);
const [inputText, setInputText] = useState('');
const filteredTodos = state.todos.filter(todo => {
if (state.filter === 'active') return !todo.completed;
if (state.filter === 'completed') return todo.completed;
return true;
});
const handleSubmit = (e) => {
e.preventDefault();
if (inputText.trim()) {
dispatch({ type: ACTIONS.ADD_TODO, payload: inputText.trim() });
setInputText('');
}
};
const activeTodoCount = state.todos.filter(t => !t.completed).length;
return (
<div className="todo-app">
<h1>TODOアプリ</h1>
<form onSubmit={handleSubmit}>
<input
type="text"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="新しいタスクを入力..."
/>
<button type="submit">追加</button>
</form>
<div className="filters">
{['all', 'active', 'completed'].map(filter => (
<button
key={filter}
className={state.filter === filter ? 'active' : ''}
onClick={() => dispatch({ type: ACTIONS.SET_FILTER, payload: filter })}
>
{filter === 'all' ? 'すべて' : filter === 'active' ? '未完了' : '完了'}
</button>
))}
</div>
<ul>
{filteredTodos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => dispatch({ type: ACTIONS.TOGGLE_TODO, payload: todo.id })}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => dispatch({ type: ACTIONS.DELETE_TODO, payload: todo.id })}>
削除
</button>
</li>
))}
</ul>
<div className="footer">
<span>{activeTodoCount}件の未完了タスク</span>
<button onClick={() => dispatch({ type: ACTIONS.CLEAR_COMPLETED })}>
完了済みを削除
</button>
</div>
</div>
);
}
6.2 Context Hooks
useContext
Contextの値を読み取るためのHook。詳細はセクション9で解説する。
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext('light');
function useTheme() {
return useContext(ThemeContext);
}
function ThemedButton() {
const theme = useTheme();
return (
<button className={`btn-${theme}`}>
テーマ: {theme}
</button>
);
}
6.3 Ref Hooks
useRef
useRef は、レンダリング間で値を保持し、かつその変更が再レンダリングを引き起こさないための参照を提供する。
import { useRef, useState, useEffect } from 'react';
// DOM要素への参照
function TextInputWithFocusButton() {
const inputRef = useRef(null);
const handleClick = () => {
inputRef.current.focus();
inputRef.current.select();
};
return (
<div>
<input ref={inputRef} type="text" placeholder="ここに入力..." />
<button onClick={handleClick}>フォーカスする</button>
</div>
);
}
// 前の値の保持
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
function CounterWithPrevious() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>現在の値: {count}</p>
<p>前の値: {prevCount}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
// タイマーの管理
function Stopwatch() {
const [time, setTime] = useState(0);
const [isRunning, setIsRunning] = useState(false);
const intervalRef = useRef(null);
const start = () => {
if (!isRunning) {
setIsRunning(true);
intervalRef.current = setInterval(() => {
setTime(prev => prev + 10);
}, 10);
}
};
const stop = () => {
clearInterval(intervalRef.current);
setIsRunning(false);
};
const reset = () => {
clearInterval(intervalRef.current);
setIsRunning(false);
setTime(0);
};
useEffect(() => {
return () => clearInterval(intervalRef.current);
}, []);
const formatTime = (ms) => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
const centiseconds = Math.floor((ms % 1000) / 10);
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
};
return (
<div>
<h2>{formatTime(time)}</h2>
<button onClick={start} disabled={isRunning}>開始</button>
<button onClick={stop} disabled={!isRunning}>停止</button>
<button onClick={reset}>リセット</button>
</div>
);
}
useImperativeHandle
forwardRef と組み合わせて使用し、親コンポーネントに公開するインスタンスメソッドをカスタマイズする。
import { useRef, useImperativeHandle, forwardRef } from 'react';
const CustomInput = forwardRef(function CustomInput({ label, ...props }, ref) {
const inputRef = useRef(null);
useImperativeHandle(ref, () => ({
focus() {
inputRef.current.focus();
},
scrollIntoView() {
inputRef.current.scrollIntoView({ behavior: 'smooth' });
},
getValue() {
return inputRef.current.value;
},
clear() {
inputRef.current.value = '';
inputRef.current.focus();
},
}));
return (
<div className="custom-input">
<label>{label}</label>
<input ref={inputRef} {...props} />
</div>
);
});
function Form() {
const nameRef = useRef(null);
const emailRef = useRef(null);
const handleSubmit = () => {
const name = nameRef.current.getValue();
const email = emailRef.current.getValue();
if (!name) {
nameRef.current.focus();
return;
}
if (!email) {
emailRef.current.focus();
return;
}
console.log({ name, email });
};
return (
<form>
<CustomInput ref={nameRef} label="名前" type="text" />
<CustomInput ref={emailRef} label="メール" type="email" />
<button type="button" onClick={handleSubmit}>送信</button>
</form>
);
}
6.4 Effect Hooks
useEffect
副作用(サーバーとの通信、DOMの直接操作、タイマー設定など)を関数コンポーネントで実行するためのHook。
import { useEffect, useState } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// AbortControllerでクリーンアップ可能なフェッチ
const abortController = new AbortController();
async function fetchUser() {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/users/${userId}`, {
signal: abortController.signal,
});
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const data = await response.json();
setUser(data);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err);
}
} finally {
if (!abortController.signal.aborted) {
setLoading(false);
}
}
}
fetchUser();
// クリーンアップ関数
return () => {
abortController.abort();
};
}, [userId]); // userIdが変わるたびに再実行
if (loading) return <div className="spinner">読み込み中...</div>;
if (error) return <div className="error">エラー: {error.message}</div>;
if (!user) return null;
return (
<div className="user-profile">
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
// 依存配列のパターン
function EffectPatterns() {
// パターン1: マウント時のみ実行(空の依存配列)
useEffect(() => {
console.log('コンポーネントがマウントされました');
return () => {
console.log('コンポーネントがアンマウントされます');
};
}, []);
// パターン2: 特定の値が変わった時に実行
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
const debounceTimer = setTimeout(() => {
console.log('検索:', searchTerm);
}, 300);
return () => clearTimeout(debounceTimer);
}, [searchTerm]);
// パターン3: 毎回のレンダリング後に実行(依存配列なし - 通常は避ける)
useEffect(() => {
console.log('毎回のレンダリング後に実行');
});
return <div>Effect Patterns</div>;
}
// イベントリスナーの登録と解除
function WindowSizeTracker() {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handleResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
window.addEventListener('resize', handleResize);
// クリーンアップ: イベントリスナーの解除
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return (
<div>
<p>幅: {windowSize.width}px</p>
<p>高さ: {windowSize.height}px</p>
</div>
);
}
useLayoutEffect
useEffect と似ているが、DOMの変更が画面に描画される前に同期的に実行される。レイアウトの測定やDOM操作に使用する。
import { useLayoutEffect, useRef, useState } from 'react';
function Tooltip({ children, text }) {
const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 });
const [showTooltip, setShowTooltip] = useState(false);
const targetRef = useRef(null);
const tooltipRef = useRef(null);
// DOMの測定はuseLayoutEffectで行う(ちらつき防止)
useLayoutEffect(() => {
if (showTooltip && targetRef.current && tooltipRef.current) {
const targetRect = targetRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
setTooltipPosition({
top: targetRect.top - tooltipRect.height - 8,
left: targetRect.left + (targetRect.width - tooltipRect.width) / 2,
});
}
}, [showTooltip]);
return (
<>
<span
ref={targetRef}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
{children}
</span>
{showTooltip && (
<div
ref={tooltipRef}
className="tooltip"
style={{
position: 'fixed',
top: tooltipPosition.top,
left: tooltipPosition.left,
}}
>
{text}
</div>
)}
</>
);
}
useInsertionEffect
CSSインジェクション(CSS-in-JSライブラリ)のために設計されたHook。DOMの変更が行われる前に実行される。通常のアプリケーション開発では使用しない。
import { useInsertionEffect } from 'react';
// CSS-in-JSライブラリの内部で使用される
function useCSS(rule) {
useInsertionEffect(() => {
const style = document.createElement('style');
style.textContent = rule;
document.head.appendChild(style);
return () => {
document.head.removeChild(style);
};
}, [rule]);
}
6.5 Performance Hooks
useMemo
計算コストの高い値をメモ化し、依存関係が変わらない限り再計算を避ける。
import { useMemo, useState } from 'react';
function ProductList({ products, sortOrder, filterCategory }) {
// 高コストなフィルタリングとソートをメモ化
const processedProducts = useMemo(() => {
console.log('商品リストを再計算中...');
let result = products;
// フィルタリング
if (filterCategory !== 'all') {
result = result.filter(p => p.category === filterCategory);
}
// ソート
result = [...result].sort((a, b) => {
if (sortOrder === 'price-asc') return a.price - b.price;
if (sortOrder === 'price-desc') return b.price - a.price;
if (sortOrder === 'name') return a.name.localeCompare(b.name);
return 0;
});
return result;
}, [products, sortOrder, filterCategory]);
// 統計情報もメモ化
const stats = useMemo(() => ({
total: processedProducts.length,
averagePrice: processedProducts.reduce((sum, p) => sum + p.price, 0) / processedProducts.length || 0,
maxPrice: Math.max(...processedProducts.map(p => p.price), 0),
minPrice: Math.min(...processedProducts.map(p => p.price), Infinity),
}), [processedProducts]);
return (
<div>
<div className="stats">
<p>商品数: {stats.total}件</p>
<p>平均価格: ¥{stats.averagePrice.toLocaleString()}</p>
</div>
<div className="product-grid">
{processedProducts.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
);
}
useCallback
関数をメモ化し、不要な再生成を防ぐ。特に、メモ化された子コンポーネントにコールバックを渡す場合に有効。
import { useCallback, useState, memo } from 'react';
// memo化された子コンポーネント
const TodoItem = memo(function TodoItem({ todo, onToggle, onDelete }) {
console.log(`TodoItem ${todo.id} がレンダリングされました`);
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => onDelete(todo.id)}>削除</button>
</li>
);
});
function TodoList() {
const [todos, setTodos] = useState([]);
const [inputText, setInputText] = useState('');
// useCallbackで関数をメモ化
const handleToggle = useCallback((id) => {
setTodos(prev => prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
}, []);
const handleDelete = useCallback((id) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
}, []);
const handleAdd = useCallback(() => {
if (inputText.trim()) {
setTodos(prev => [...prev, {
id: Date.now(),
text: inputText.trim(),
completed: false,
}]);
setInputText('');
}
}, [inputText]);
return (
<div>
<div>
<input
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="新しいタスク..."
/>
<button onClick={handleAdd}>追加</button>
</div>
<ul>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle}
onDelete={handleDelete}
/>
))}
</ul>
</div>
);
}
useTransition
状態更新を「トランジション」としてマークし、UIの応答性を維持しながらバックグラウンドで更新を処理する。
import { useState, useTransition } from 'react';
function SearchWithTransition() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
const handleSearch = (e) => {
const value = e.target.value;
// 入力フィールドの更新は即座に行う(緊急更新)
setQuery(value);
// 検索結果の更新はトランジションとして行う(低優先度)
startTransition(() => {
// 重い処理(大量のデータのフィルタリングなど)
const filtered = allItems.filter(item =>
item.name.toLowerCase().includes(value.toLowerCase())
);
setResults(filtered);
});
};
return (
<div>
<input
type="text"
value={query}
onChange={handleSearch}
placeholder="検索..."
/>
{isPending && <div className="spinner">検索中...</div>}
<ul>
{results.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
useDeferredValue
値の更新を遅延させ、UIの応答性を維持する。useTransition と似ているが、値そのものを遅延させる点が異なる。
import { useState, useDeferredValue, useMemo } from 'react';
function SearchResults({ query }) {
// queryの更新を遅延させる
const deferredQuery = useDeferredValue(query);
// deferredQueryに基づいてフィルタリング(古い値で表示を維持)
const results = useMemo(() => {
return allItems.filter(item =>
item.name.toLowerCase().includes(deferredQuery.toLowerCase())
);
}, [deferredQuery]);
// 遅延中かどうかを判定
const isStale = query !== deferredQuery;
return (
<div style={{ opacity: isStale ? 0.5 : 1, transition: 'opacity 0.2s' }}>
<p>{results.length}件の結果</p>
<ul>
{results.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
function SearchApp() {
const [query, setQuery] = useState('');
return (
<div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="検索..."
/>
<SearchResults query={query} />
</div>
);
}
6.6 Other Hooks
useId
サーバーサイドレンダリング(SSR)とクライアントサイドレンダリングの両方で一貫性のある一意のIDを生成する。
import { useId } from 'react';
function FormField({ label, type = 'text', ...props }) {
const id = useId();
const errorId = `${id}-error`;
const helpId = `${id}-help`;
return (
<div className="form-field">
<label htmlFor={id}>{label}</label>
<input
id={id}
type={type}
aria-describedby={`${helpId} ${errorId}`}
{...props}
/>
<span id={helpId} className="help-text">
{props.helpText}
</span>
{props.error && (
<span id={errorId} className="error-text" role="alert">
{props.error}
</span>
)}
</div>
);
}
// 同じコンポーネントを複数配置しても、各インスタンスが一意のIDを持つ
function RegistrationForm() {
return (
<form>
<FormField label="名前" helpText="フルネームを入力してください" />
<FormField label="メール" type="email" helpText="有効なメールアドレス" />
<FormField label="パスワード" type="password" helpText="8文字以上" />
</form>
);
}
useSyncExternalStore
外部ストア(Redux、Zustandなど)をReactと統合するためのHook。
import { useSyncExternalStore } from 'react';
// シンプルな外部ストアの実装
function createStore(initialState) {
let state = initialState;
const listeners = new Set();
return {
getState() {
return state;
},
setState(newState) {
state = typeof newState === 'function' ? newState(state) : newState;
listeners.forEach(listener => listener());
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
const counterStore = createStore({ count: 0 });
function useCounterStore() {
return useSyncExternalStore(
counterStore.subscribe,
counterStore.getState,
counterStore.getState, // SSR用のスナップショット
);
}
function Counter() {
const state = useCounterStore();
return (
<button onClick={() => counterStore.setState(s => ({ count: s.count + 1 }))}>
カウント: {state.count}
</button>
);
}
// ブラウザAPIとの統合例: オンライン状態の監視
function useOnlineStatus() {
return useSyncExternalStore(
(callback) => {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
},
() => navigator.onLine,
() => true, // SSRでは常にオンラインと仮定
);
}
function OnlineIndicator() {
const isOnline = useOnlineStatus();
return (
<span className={isOnline ? 'online' : 'offline'}>
{isOnline ? 'オンライン' : 'オフライン'}
</span>
);
}
useActionState
React 19で導入されたHook。フォームアクションの状態管理を簡素化する。
import { useActionState } from 'react';
// サーバーアクションと連携するフォーム
async function submitForm(previousState, formData) {
const name = formData.get('name');
const email = formData.get('email');
try {
const response = await fetch('/api/register', {
method: 'POST',
body: JSON.stringify({ name, email }),
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
return { error: '登録に失敗しました。', success: false };
}
return { error: null, success: true, message: '登録が完了しました!' };
} catch (err) {
return { error: err.message, success: false };
}
}
function RegistrationForm() {
const [state, formAction, isPending] = useActionState(submitForm, {
error: null,
success: false,
});
return (
<form action={formAction}>
<div>
<label htmlFor="name">名前:</label>
<input id="name" name="name" type="text" required />
</div>
<div>
<label htmlFor="email">メール:</label>
<input id="email" name="email" type="email" required />
</div>
{state.error && <p className="error">{state.error}</p>}
{state.success && <p className="success">{state.message}</p>}
<button type="submit" disabled={isPending}>
{isPending ? '送信中...' : '登録'}
</button>
</form>
);
}
useDebugValue
カスタムHookのデバッグ情報をReact DevToolsに表示する。
import { useDebugValue, useState, useEffect } from 'react';
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
// React DevToolsに表示されるラベル
useDebugValue(isOnline ? 'オンライン' : 'オフライン');
return isOnline;
}
// 遅延フォーマッティング(重い書式設定のコスト削減)
function useFormattedDate(date) {
useDebugValue(date, (d) => d.toLocaleDateString('ja-JP'));
return date;
}
7. イベントハンドリング
7.1 基本的なイベントハンドリング
ReactのイベントハンドリングはHTMLのイベント処理に似ているが、いくつかの重要な違いがある。
function EventHandlingBasics() {
// 基本的なクリックイベント
const handleClick = () => {
alert('ボタンがクリックされました!');
};
// イベントオブジェクトの使用
const handleInputChange = (e) => {
console.log('入力値:', e.target.value);
console.log('イベントタイプ:', e.type);
};
// フォーム送信(デフォルト動作の防止)
const handleSubmit = (e) => {
e.preventDefault(); // ページリロードを防止
const formData = new FormData(e.target);
console.log('フォームデータ:', Object.fromEntries(formData));
};
return (
<div>
{/* onClick: クリックイベント */}
<button onClick={handleClick}>クリック</button>
{/* onChange: 値変更イベント */}
<input type="text" onChange={handleInputChange} />
{/* onSubmit: フォーム送信イベント */}
<form onSubmit={handleSubmit}>
<input name="username" type="text" />
<button type="submit">送信</button>
</form>
{/* onMouseEnter / onMouseLeave: マウスホバー */}
<div
onMouseEnter={() => console.log('マウスが入った')}
onMouseLeave={() => console.log('マウスが出た')}
>
ホバーしてください
</div>
{/* onKeyDown / onKeyUp: キーボードイベント */}
<input
type="text"
onKeyDown={(e) => {
if (e.key === 'Enter') {
console.log('Enterが押されました');
}
}}
/>
{/* onFocus / onBlur: フォーカスイベント */}
<input
type="text"
onFocus={() => console.log('フォーカスを得た')}
onBlur={() => console.log('フォーカスを失った')}
/>
</div>
);
}
7.2 合成イベント(SyntheticEvent)
Reactは独自のイベントシステム(合成イベント)を提供し、ブラウザ間の差異を吸収する。
function SyntheticEventExample() {
const handleClick = (e) => {
// e は SyntheticEvent オブジェクト
console.log('type:', e.type); // 'click'
console.log('target:', e.target); // クリックされたDOM要素
console.log('currentTarget:', e.currentTarget); // ハンドラが付与されたDOM要素
console.log('timeStamp:', e.timeStamp); // イベントのタイムスタンプ
// ネイティブイベントへのアクセス(稀に必要)
console.log('nativeEvent:', e.nativeEvent);
// イベント伝播の制御
e.stopPropagation(); // バブリングを停止
e.preventDefault(); // デフォルト動作を防止
};
return <button onClick={handleClick}>クリック</button>;
}
7.3 イベントハンドラのパターン
function EventHandlerPatterns() {
const [items, setItems] = useState([]);
// パターン1: インライン関数(シンプルな場合)
// 問題: 毎回のレンダリングで新しい関数が生成される
return <button onClick={() => console.log('clicked')}>クリック</button>;
// パターン2: 名前付き関数(推奨)
const handleClick = () => {
console.log('clicked');
};
return <button onClick={handleClick}>クリック</button>;
// パターン3: パラメータを渡す必要がある場合
const handleItemClick = (itemId) => {
console.log('Item clicked:', itemId);
};
return (
<ul>
{items.map(item => (
<li key={item.id}>
{/* アロー関数でラップしてパラメータを渡す */}
<button onClick={() => handleItemClick(item.id)}>
{item.name}
</button>
</li>
))}
</ul>
);
// パターン4: data属性を使用(大量の要素がある場合のパフォーマンス最適化)
const handleItemClickOptimized = (e) => {
const itemId = e.currentTarget.dataset.itemId;
console.log('Item clicked:', itemId);
};
return (
<ul>
{items.map(item => (
<li key={item.id}>
<button data-item-id={item.id} onClick={handleItemClickOptimized}>
{item.name}
</button>
</li>
))}
</ul>
);
}
// イベントデリゲーション的なパターン
function EventDelegation() {
const handleListClick = (e) => {
const button = e.target.closest('button[data-action]');
if (!button) return;
const action = button.dataset.action;
const itemId = button.dataset.itemId;
switch (action) {
case 'edit':
console.log('編集:', itemId);
break;
case 'delete':
console.log('削除:', itemId);
break;
}
};
return (
<ul onClick={handleListClick}>
{items.map(item => (
<li key={item.id}>
<span>{item.name}</span>
<button data-action="edit" data-item-id={item.id}>編集</button>
<button data-action="delete" data-item-id={item.id}>削除</button>
</li>
))}
</ul>
);
}
8. 条件付きレンダリングとリスト
8.1 条件付きレンダリングの実践パターン
// パターン集: 実践的な条件付きレンダリング
function Dashboard({ user, notifications, isLoading, error }) {
// 早期リターン: エラー状態
if (error) {
return (
<div className="error-page">
<h1>エラーが発生しました</h1>
<p>{error.message}</p>
<button onClick={() => window.location.reload()}>再読み込み</button>
</div>
);
}
// 早期リターン: ローディング状態
if (isLoading) {
return <LoadingSpinner message="ダッシュボードを読み込んでいます..." />;
}
// 早期リターン: 未認証
if (!user) {
return <Navigate to="/login" />;
}
return (
<div className="dashboard">
{/* 三項演算子: 2つの状態を切り替え */}
<header>
{user.isPremium ? (
<PremiumBadge plan={user.plan} />
) : (
<UpgradePrompt />
)}
</header>
{/* 論理AND: 条件付き表示 */}
{notifications.length > 0 && (
<NotificationBar count={notifications.length} />
)}
{/* 複数条件の分岐 */}
{(() => {
switch (user.role) {
case 'admin':
return <AdminPanel />;
case 'editor':
return <EditorPanel />;
case 'viewer':
return <ViewerPanel />;
default:
return <GuestPanel />;
}
})()}
{/* オブジェクトマッピングによる条件分岐 */}
{
{
admin: <AdminPanel />,
editor: <EditorPanel />,
viewer: <ViewerPanel />,
}[user.role] || <GuestPanel />
}
{/* null条件でレンダリングスキップ */}
{user.bio && <UserBio text={user.bio} />}
{/* 条件付きスタイリング */}
<div className={`status ${user.isActive ? 'active' : 'inactive'}`}>
{user.isActive ? 'アクティブ' : '非アクティブ'}
</div>
</div>
);
}
8.2 リストの高度なパターン
// グループ化されたリスト
function GroupedList({ items }) {
// カテゴリでグループ化
const grouped = useMemo(() => {
return items.reduce((groups, item) => {
const category = item.category;
if (!groups[category]) {
groups[category] = [];
}
groups[category].push(item);
return groups;
}, {});
}, [items]);
return (
<div className="grouped-list">
{Object.entries(grouped).map(([category, categoryItems]) => (
<section key={category}>
<h2>{category}</h2>
<ul>
{categoryItems.map(item => (
<li key={item.id}>{item.name} - ¥{item.price}</li>
))}
</ul>
</section>
))}
</div>
);
}
// 仮想スクロール(大量データ対応)
function VirtualizedList({ items, itemHeight = 50, containerHeight = 400 }) {
const [scrollTop, setScrollTop] = useState(0);
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(
startIndex + Math.ceil(containerHeight / itemHeight) + 1,
items.length
);
const visibleItems = items.slice(startIndex, endIndex);
const totalHeight = items.length * itemHeight;
return (
<div
style={{ height: containerHeight, overflow: 'auto' }}
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
>
<div style={{ height: totalHeight, position: 'relative' }}>
{visibleItems.map((item, index) => (
<div
key={item.id}
style={{
position: 'absolute',
top: (startIndex + index) * itemHeight,
height: itemHeight,
width: '100%',
}}
>
{item.name}
</div>
))}
</div>
</div>
);
}
// ページネーション付きリスト
function PaginatedList({ items, itemsPerPage = 10 }) {
const [currentPage, setCurrentPage] = useState(1);
const totalPages = Math.ceil(items.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const currentItems = items.slice(startIndex, startIndex + itemsPerPage);
return (
<div>
<ul>
{currentItems.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
<nav className="pagination">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
前へ
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(page => (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={currentPage === page ? 'active' : ''}
>
{page}
</button>
))}
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
>
次へ
</button>
</nav>
<p>
{items.length}件中 {startIndex + 1}-{Math.min(startIndex + itemsPerPage, items.length)}件を表示
</p>
</div>
);
}
8.3 key属性の深掘り
// key属性の役割を理解するための例
function KeyDemo() {
const [isReversed, setIsReversed] = useState(false);
const items = ['りんご', 'バナナ', 'みかん'];
const displayItems = isReversed ? [...items].reverse() : items;
return (
<div>
<button onClick={() => setIsReversed(!isReversed)}>
{isReversed ? '元に戻す' : '逆順にする'}
</button>
{/* ❌ indexをkeyに使用: 逆順にしても入力値がそのまま残る */}
<h3>indexをkeyに使用(問題あり):</h3>
{displayItems.map((item, index) => (
<div key={index}>
<span>{item}: </span>
<input type="text" placeholder={`${item}のメモ`} />
</div>
))}
{/* ✅ 安定したkeyを使用: 正しく要素が追跡される */}
<h3>安定したkeyを使用(正しい):</h3>
{displayItems.map(item => (
<div key={item}>
<span>{item}: </span>
<input type="text" placeholder={`${item}のメモ`} />
</div>
))}
</div>
);
}
// keyを使ったコンポーネントのリセット
function ResettableForm({ userId }) {
// keyを変えることでコンポーネントを完全にリセットする
return <ProfileForm key={userId} userId={userId} />;
}
function ProfileForm({ userId }) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
// userIdが変わると、keyが変わるため
// このコンポーネントはアンマウント→再マウントされ、stateがリセットされる
return (
<form>
<input value={name} onChange={e => setName(e.target.value)} />
<input value={email} onChange={e => setEmail(e.target.value)} />
</form>
);
}
9. コンテキスト(Context)
9.1 Context APIの概要
Contextは、コンポーネントツリー全体にデータを「テレポート」させる仕組みである。propsを中間コンポーネントに逐一渡す「propsドリリング」の問題を解決する。
import { createContext, useContext, useState, useCallback } from 'react';
// ステップ1: Contextの作成
const ThemeContext = createContext({
theme: 'light',
toggleTheme: () => {},
});
// ステップ2: Providerコンポーネントの作成
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = useCallback(() => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
}, []);
const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
// ステップ3: カスタムHookの作成(推奨)
function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme は ThemeProvider 内で使用してください');
}
return context;
}
// 使用例
function Header() {
const { theme, toggleTheme } = useTheme();
return (
<header className={`header-${theme}`}>
<h1>マイアプリ</h1>
<button onClick={toggleTheme}>
{theme === 'light' ? '🌙 ダークモード' : '☀️ ライトモード'}
</button>
</header>
);
}
function App() {
return (
<ThemeProvider>
<Header />
<MainContent />
<Footer />
</ThemeProvider>
);
}
9.2 認証コンテキストの実装例
import { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
// 認証コンテキスト
const AuthContext = createContext(null);
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// 初回マウント時にセッションを確認
useEffect(() => {
async function checkSession() {
try {
const response = await fetch('/api/auth/session');
if (response.ok) {
const userData = await response.json();
setUser(userData);
}
} catch (error) {
console.error('セッション確認エラー:', error);
} finally {
setLoading(false);
}
}
checkSession();
}, []);
const login = useCallback(async (email, password) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error('ログインに失敗しました');
}
const userData = await response.json();
setUser(userData);
return userData;
}, []);
const logout = useCallback(async () => {
await fetch('/api/auth/logout', { method: 'POST' });
setUser(null);
}, []);
const value = useMemo(() => ({
user,
loading,
login,
logout,
isAuthenticated: !!user,
}), [user, loading, login, logout]);
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth は AuthProvider 内で使用してください');
}
return context;
}
// 認証ガード(保護ルート)
function ProtectedRoute({ children }) {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return <LoadingSpinner />;
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return children;
}
// ログインフォーム
function LoginForm() {
const { login } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
try {
await login(email, password);
} catch (err) {
setError(err.message);
}
};
return (
<form onSubmit={handleSubmit}>
{error && <div className="error">{error}</div>}
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="メールアドレス"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="パスワード"
/>
<button type="submit">ログイン</button>
</form>
);
}
9.3 複数Contextの組み合わせ
// 複数のProviderをまとめるコンポーネント
function AppProviders({ children }) {
return (
<ThemeProvider>
<AuthProvider>
<LocaleProvider>
<NotificationProvider>
{children}
</NotificationProvider>
</LocaleProvider>
</AuthProvider>
</ThemeProvider>
);
}
// 使用時
function App() {
return (
<AppProviders>
<Router>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/dashboard" element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
} />
</Routes>
</Router>
</AppProviders>
);
}
10. エラーバウンダリ(Error Boundaries)
10.1 エラーバウンダリとは
エラーバウンダリは、子コンポーネントツリーで発生したJavaScriptエラーをキャッチし、アプリケーション全体のクラッシュを防止するための仕組みである。現時点ではクラスコンポーネントとしてのみ実装可能である。
import { Component } from 'react';
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
// エラー発生時に状態を更新(レンダリング中に呼ばれる)
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
// エラー情報のログ記録(コミット後に呼ばれる)
componentDidCatch(error, errorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
// エラー報告サービスに送信
logErrorToService(error, errorInfo.componentStack);
this.setState({ errorInfo });
}
handleReset = () => {
this.setState({ hasError: false, error: null, errorInfo: null });
};
render() {
if (this.state.hasError) {
// カスタムフォールバックUIが提供されている場合
if (this.props.fallback) {
return this.props.fallback({
error: this.state.error,
resetErrorBoundary: this.handleReset,
});
}
// デフォルトのフォールバックUI
return (
<div className="error-boundary">
<h2>予期しないエラーが発生しました</h2>
<p>{this.state.error?.message}</p>
{process.env.NODE_ENV === 'development' && (
<details>
<summary>エラーの詳細</summary>
<pre>{this.state.error?.stack}</pre>
<pre>{this.state.errorInfo?.componentStack}</pre>
</details>
)}
<button onClick={this.handleReset}>再試行</button>
</div>
);
}
return this.props.children;
}
}
// 使用例
function App() {
return (
<ErrorBoundary
fallback={({ error, resetErrorBoundary }) => (
<div className="custom-error">
<h1>問題が発生しました</h1>
<p>{error.message}</p>
<button onClick={resetErrorBoundary}>もう一度試す</button>
</div>
)}
>
<Header />
<ErrorBoundary fallback={<p>サイドバーの読み込みに失敗しました</p>}>
<Sidebar />
</ErrorBoundary>
<ErrorBoundary fallback={<p>コンテンツの表示に失敗しました</p>}>
<MainContent />
</ErrorBoundary>
</ErrorBoundary>
);
}
10.2 エラーバウンダリの制限
エラーバウンダリは以下のエラーをキャッチしない点に注意が必要である。
// ❌ キャッチされないエラーの種類:
// 1. イベントハンドラ内のエラー
function ButtonWithError() {
const handleClick = () => {
// このエラーはErrorBoundaryでキャッチされない
throw new Error('クリック時のエラー');
};
return <button onClick={handleClick}>クリック</button>;
}
// → イベントハンドラ内のエラーはtry/catchで処理する
function SafeButton() {
const handleClick = () => {
try {
riskyOperation();
} catch (error) {
console.error('操作に失敗:', error);
// エラー状態を設定するなどの処理
}
};
return <button onClick={handleClick}>安全なクリック</button>;
}
// 2. 非同期コード(setTimeout, Promise等)
// → async/awaitのtry/catchで処理する
// 3. サーバーサイドレンダリング(SSR)のエラー
// → サーバー側のエラーハンドリングで処理する
// 4. ErrorBoundary自身のエラー
// → 親のErrorBoundaryで処理する
11. Suspense
11.1 React.lazyとSuspense
Suspense は、子コンポーネントの読み込みが完了するまでフォールバックUIを表示する仕組みである。
import { Suspense, lazy, useState } from 'react';
// React.lazyで動的インポート
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
const Analytics = lazy(() => import('./Analytics'));
// 名前付きエクスポートの場合
const Charts = lazy(() =>
import('./Charts').then(module => ({ default: module.Charts }))
);
function App() {
const [currentTab, setCurrentTab] = useState('dashboard');
return (
<div>
<nav>
<button onClick={() => setCurrentTab('dashboard')}>ダッシュボード</button>
<button onClick={() => setCurrentTab('settings')}>設定</button>
<button onClick={() => setCurrentTab('analytics')}>分析</button>
</nav>
{/* Suspenseでフォールバックを提供 */}
<Suspense fallback={<LoadingSkeleton />}>
{currentTab === 'dashboard' && <Dashboard />}
{currentTab === 'settings' && <Settings />}
{currentTab === 'analytics' && <Analytics />}
</Suspense>
</div>
);
}
// リッチなローディングスケルトン
function LoadingSkeleton() {
return (
<div className="skeleton-container">
<div className="skeleton skeleton-header" />
<div className="skeleton skeleton-text" />
<div className="skeleton skeleton-text" />
<div className="skeleton skeleton-text short" />
</div>
);
}
11.2 ネストされたSuspense
function ProfilePage() {
return (
<Suspense fallback={<h1>プロフィールを読み込み中...</h1>}>
<ProfileHeader />
<Suspense fallback={<h2>投稿を読み込み中...</h2>}>
<ProfilePosts />
</Suspense>
<Suspense fallback={<h2>フレンド一覧を読み込み中...</h2>}>
<ProfileFriends />
</Suspense>
</Suspense>
);
}
11.3 データフェッチとSuspense
React 19では、Suspenseとデータフェッチの統合がより洗練されている。use APIを使用することで、PromiseをSuspenseと連携させることができる。
import { Suspense, use } from 'react';
// use APIでPromiseを直接使用
function UserProfile({ userPromise }) {
const user = use(userPromise);
return (
<div className="user-profile">
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.bio}</p>
</div>
);
}
function UserPage({ userId }) {
// Promiseをコンポーネントの外で作成
const userPromise = fetchUser(userId);
return (
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
// fetchユーティリティ(キャッシュ付き)
const cache = new Map();
function fetchUser(userId) {
if (!cache.has(userId)) {
cache.set(userId, fetch(`/api/users/${userId}`).then(r => r.json()));
}
return cache.get(userId);
}
12. サーバーコンポーネント(React Server Components)
12.1 RSCの概要
React Server Components(RSC)は、サーバー上でレンダリングされるコンポーネントである。クライアントにJavaScriptバンドルを送信する必要がなく、パフォーマンスとユーザー体験を大幅に向上させる。
Server Componentの特徴:
- サーバー上でのみ実行される
- クライアントにJavaScriptが送信されない
- データベースやファイルシステムに直接アクセスできる
- useState、useEffectなどのクライアント側Hooksは使用できない
- イベントハンドラ(onClick等)は使用できない
// app/page.tsx - Server Component(デフォルト)
import { db } from '@/lib/database';
async function BlogPage() {
// サーバー上でデータベースに直接アクセス
const posts = await db.post.findMany({
orderBy: { createdAt: 'desc' },
include: { author: true },
});
return (
<main>
<h1>ブログ</h1>
<div className="post-list">
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
<span>著者: {post.author.name}</span>
<time>{new Date(post.createdAt).toLocaleDateString('ja-JP')}</time>
{/* Client Componentを含めることができる */}
<LikeButton postId={post.id} initialLikes={post.likes} />
</article>
))}
</div>
</main>
);
}
export default BlogPage;
12.2 'use client' ディレクティブ
'use client' ディレクティブは、ファイルをClient Componentとしてマークする。これにより、useState、useEffect、イベントハンドラなどのクライアント側機能が使用可能になる。
// components/LikeButton.tsx - Client Component
'use client';
import { useState, useTransition } from 'react';
interface LikeButtonProps {
postId: string;
initialLikes: number;
}
function LikeButton({ postId, initialLikes }: LikeButtonProps) {
const [likes, setLikes] = useState(initialLikes);
const [liked, setLiked] = useState(false);
const [isPending, startTransition] = useTransition();
const handleLike = () => {
const newLiked = !liked;
setLiked(newLiked);
setLikes(prev => newLiked ? prev + 1 : prev - 1);
startTransition(async () => {
await fetch(`/api/posts/${postId}/like`, {
method: newLiked ? 'POST' : 'DELETE',
});
});
};
return (
<button
onClick={handleLike}
disabled={isPending}
className={liked ? 'liked' : ''}
>
{liked ? '❤️' : '🤍'} {likes}
</button>
);
}
export default LikeButton;
12.3 'use server' ディレクティブ(Server Actions)
'use server' ディレクティブは、Server Actionsを定義する。Client ComponentからServer上の関数を直接呼び出すことができる。
// app/actions.ts
'use server';
import { db } from '@/lib/database';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// バリデーション
if (!title || title.length < 3) {
return { error: 'タイトルは3文字以上で入力してください' };
}
// データベースに保存
await db.post.create({
data: {
title,
content,
authorId: await getCurrentUserId(),
},
});
// キャッシュの再検証
revalidatePath('/blog');
return { success: true };
}
export async function deletePost(postId: string) {
await db.post.delete({ where: { id: postId } });
revalidatePath('/blog');
}
// components/CreatePostForm.tsx
'use client';
import { useActionState } from 'react';
import { createPost } from '@/app/actions';
function CreatePostForm() {
const [state, formAction, isPending] = useActionState(createPost, null);
return (
<form action={formAction}>
<div>
<label htmlFor="title">タイトル</label>
<input id="title" name="title" type="text" required />
</div>
<div>
<label htmlFor="content">本文</label>
<textarea id="content" name="content" rows={10} required />
</div>
{state?.error && <p className="error">{state.error}</p>}
{state?.success && <p className="success">投稿が作成されました!</p>}
<button type="submit" disabled={isPending}>
{isPending ? '投稿中...' : '投稿する'}
</button>
</form>
);
}
12.4 Server ComponentとClient Componentの使い分け
| 要件 | Server Component | Client Component |
|---|---|---|
| データベースアクセス | ✅ | ❌(API経由) |
| ファイルシステムアクセス | ✅ | ❌ |
| 環境変数(シークレット) | ✅ | ❌(NEXT_PUBLIC_のみ) |
| useState/useEffect | ❌ | ✅ |
| イベントハンドラ | ❌ | ✅ |
| ブラウザAPI | ❌ | ✅ |
| Refs | ❌ | ✅ |
| バンドルサイズへの影響 | なし | あり |
推奨パターン:
- Server Componentをデフォルトとして使用
- インタラクティビティが必要な部分のみClient Componentにする
- Client Componentはできるだけ葉(リーフ)に近い位置に配置する
- Server ComponentからClient Componentにpropsでデータを渡す
13. コンカレント機能(Concurrent Features)
13.1 コンカレントレンダリングの概念
React 18で導入されたコンカレント機能は、レンダリング処理を中断可能にし、ユーザーインタラクションの応答性を向上させる。従来の同期レンダリングでは、一度レンダリングが開始されると完了するまでメインスレッドがブロックされていたが、コンカレントレンダリングではレンダリングを中断して優先度の高い更新を先に処理できる。
同期レンダリング(従来):
[更新開始] ==============================> [更新完了]
↑ ユーザー入力もブロックされる
コンカレントレンダリング:
[更新開始] ====> [中断] → [ユーザー入力処理] → [再開] ====> [更新完了]
↑ 優先度の高い処理を割り込ませる
13.2 useTransitionの実践
useTransition を使用して、重い状態更新を低優先度としてマークする。
import { useState, useTransition, Suspense } from 'react';
function TabContainer() {
const [tab, setTab] = useState('about');
const [isPending, startTransition] = useTransition();
function selectTab(nextTab) {
// タブの切り替えをトランジションとして扱う
startTransition(() => {
setTab(nextTab);
});
}
return (
<div>
<nav>
<TabButton
isActive={tab === 'about'}
onClick={() => selectTab('about')}
>
概要
</TabButton>
<TabButton
isActive={tab === 'posts'}
onClick={() => selectTab('posts')}
>
投稿(重い処理)
</TabButton>
<TabButton
isActive={tab === 'contact'}
onClick={() => selectTab('contact')}
>
お問い合わせ
</TabButton>
</nav>
{/* isPendingでトランジション中の視覚フィードバック */}
<div style={{ opacity: isPending ? 0.7 : 1, transition: 'opacity 0.2s' }}>
{tab === 'about' && <AboutTab />}
{tab === 'posts' && <PostsTab />}
{tab === 'contact' && <ContactTab />}
</div>
</div>
);
}
// 重いコンポーネント(数千個の要素をレンダリング)
function PostsTab() {
const items = [];
for (let i = 0; i < 5000; i++) {
items.push(<SlowItem key={i} text={`投稿 #${i + 1}`} />);
}
return <ul className="posts">{items}</ul>;
}
function SlowItem({ text }) {
// 意図的に遅い処理をシミュレート
const startTime = performance.now();
while (performance.now() - startTime < 1) {
// 人工的な遅延
}
return <li className="post-item">{text}</li>;
}
13.3 useDeferredValueの実践
import { useState, useDeferredValue, memo } from 'react';
function SearchApp() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<div>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="検索語句を入力..."
/>
<div style={{
opacity: isStale ? 0.5 : 1,
transition: 'opacity 0.2s',
}}>
<SearchResults query={deferredQuery} />
</div>
</div>
);
}
// メモ化することで、deferredQueryが変わらない限り再レンダリングされない
const SearchResults = memo(function SearchResults({ query }) {
// 大量のデータを検索する重い処理
const results = searchDatabase(query);
return (
<div>
<p>{results.length}件の結果</p>
{results.map(result => (
<SearchResultItem key={result.id} result={result} />
))}
</div>
);
});
13.4 Suspenseとの統合
コンカレント機能はSuspenseと密接に統合されている。
import { Suspense, useState, useTransition } from 'react';
function Router() {
const [page, setPage] = useState('/');
const [isPending, startTransition] = useTransition();
function navigate(url) {
startTransition(() => {
setPage(url);
});
}
let content;
switch (page) {
case '/':
content = <HomePage navigate={navigate} />;
break;
case '/about':
content = <AboutPage />;
break;
case '/blog':
content = (
<Suspense fallback={<BlogSkeleton />}>
<BlogPage />
</Suspense>
);
break;
}
return (
<div>
<nav>
<a href="#" onClick={(e) => { e.preventDefault(); navigate('/'); }}>
ホーム
</a>
<a href="#" onClick={(e) => { e.preventDefault(); navigate('/about'); }}>
概要
</a>
<a href="#" onClick={(e) => { e.preventDefault(); navigate('/blog'); }}>
ブログ
</a>
</nav>
{/* トランジション中は前のページを表示し続ける */}
<main style={{ opacity: isPending ? 0.7 : 1 }}>
{isPending && <div className="navigation-spinner" />}
{content}
</main>
</div>
);
}
14. パフォーマンス最適化
14.1 React.memo
React.memo は、コンポーネントをメモ化し、propsが変わらない限り再レンダリングをスキップする高階コンポーネント(HOC)である。
import { memo, useState } from 'react';
// メモ化されたコンポーネント
const ExpensiveChart = memo(function ExpensiveChart({ data, config }) {
console.log('ExpensiveChart がレンダリングされました');
// 重いレンダリング処理
return (
<div className="chart">
{/* チャートのレンダリング */}
{data.map((point, i) => (
<div
key={i}
className="chart-bar"
style={{ height: `${point.value}%`, backgroundColor: config.color }}
/>
))}
</div>
);
});
// カスタム比較関数を使用
const OptimizedList = memo(
function OptimizedList({ items, onItemClick }) {
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onItemClick(item.id)}>
{item.name}
</li>
))}
</ul>
);
},
// カスタム比較関数: trueを返すと再レンダリングをスキップ
(prevProps, nextProps) => {
return (
prevProps.items.length === nextProps.items.length &&
prevProps.items.every((item, index) => item.id === nextProps.items[index].id)
);
}
);
14.2 コード分割(Code Splitting)
import { lazy, Suspense } from 'react';
// ルートベースのコード分割
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
// プリロード(ユーザーが遷移する前にロードを開始)
const preloadDashboard = () => import('./pages/Dashboard');
function App() {
return (
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</Suspense>
);
}
// 条件付きコード分割
function AdminPanel({ isAdmin }) {
if (!isAdmin) return null;
const AdminModule = lazy(() => import('./AdminModule'));
return (
<Suspense fallback={<div>管理パネルを読み込み中...</div>}>
<AdminModule />
</Suspense>
);
}
14.3 React Profiler
React Profilerを使用してレンダリングパフォーマンスを測定する。
import { Profiler } from 'react';
function onRenderCallback(
id, // Profilerツリーの "id"
phase, // "mount" | "update" | "nested-update"
actualDuration, // 実際のレンダリング時間(ms)
baseDuration, // メモ化なしの場合の推定レンダリング時間(ms)
startTime, // レンダリング開始時刻
commitTime, // DOMコミット時刻
) {
// パフォーマンスデータを分析サービスに送信
console.log(`[Profiler] ${id}:`, {
phase,
actualDuration: `${actualDuration.toFixed(2)}ms`,
baseDuration: `${baseDuration.toFixed(2)}ms`,
});
// 遅いレンダリングを警告
if (actualDuration > 16) { // 60fpsの1フレーム = 約16ms
console.warn(`${id} のレンダリングが遅い: ${actualDuration.toFixed(2)}ms`);
}
}
function App() {
return (
<Profiler id="App" onRender={onRenderCallback}>
<Header />
<Profiler id="MainContent" onRender={onRenderCallback}>
<MainContent />
</Profiler>
<Profiler id="Sidebar" onRender={onRenderCallback}>
<Sidebar />
</Profiler>
</Profiler>
);
}
14.4 パフォーマンス最適化のチェックリスト
// 1. 不要な再レンダリングの防止
// - React.memo で純粋コンポーネントをメモ化
// - useMemo で計算結果をメモ化
// - useCallback でコールバック関数をメモ化
// 2. 状態管理の最適化
// - 状態をできるだけ使用するコンポーネントの近くに配置
// - 大きな状態オブジェクトを分割する
function OptimizedState() {
// ❌ 1つの大きな状態オブジェクト
const [state, setState] = useState({
user: null,
posts: [],
comments: [],
settings: {},
});
// ✅ 分割された状態
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [comments, setComments] = useState([]);
const [settings, setSettings] = useState({});
}
// 3. 遅延読み込みとコード分割
// - React.lazy で大きなコンポーネントを遅延読み込み
// - ルートごとにコード分割を実施
// 4. リストの最適化
// - 適切なkey属性の使用
// - 仮想スクロールの検討(大量データの場合)
// - windowing ライブラリ(react-window等)の使用
// 5. 画像の最適化
function OptimizedImage({ src, alt }) {
return (
<img
src={src}
alt={alt}
loading="lazy" // 遅延読み込み
decoding="async" // 非同期デコード
width={300} // レイアウトシフト防止
height={200}
/>
);
}
15. React Compiler
15.1 React Compilerとは
React Compiler(旧称: React Forget)は、React 19で導入された実験的なビルド時最適化ツールである。手動での useMemo、useCallback、React.memo の使用を不要にし、自動的にメモ化を適用する。
// React Compiler 導入前: 手動メモ化が必要
import { useState, useMemo, useCallback, memo } from 'react';
const TodoItem = memo(function TodoItem({ todo, onToggle }) {
return (
<li onClick={() => onToggle(todo.id)}>
{todo.text}
</li>
);
});
function TodoList({ todos }) {
const [filter, setFilter] = useState('all');
const filteredTodos = useMemo(
() => todos.filter(t => filter === 'all' || t.status === filter),
[todos, filter]
);
const handleToggle = useCallback((id) => {
// toggle logic
}, []);
return (
<ul>
{filteredTodos.map(todo => (
<TodoItem key={todo.id} todo={todo} onToggle={handleToggle} />
))}
</ul>
);
}
// React Compiler 導入後: 自動メモ化される
// memo、useMemo、useCallback を書く必要がない
function TodoItem({ todo, onToggle }) {
return (
<li onClick={() => onToggle(todo.id)}>
{todo.text}
</li>
);
}
function TodoList({ todos }) {
const [filter, setFilter] = useState('all');
// Compilerが自動的にメモ化を適用
const filteredTodos = todos.filter(
t => filter === 'all' || t.status === filter
);
const handleToggle = (id) => {
// toggle logic
};
return (
<ul>
{filteredTodos.map(todo => (
<TodoItem key={todo.id} todo={todo} onToggle={handleToggle} />
))}
</ul>
);
}
15.2 React Compilerの設定
// babel.config.js
module.exports = {
plugins: [
['babel-plugin-react-compiler', {
// コンパイラの設定オプション
runtimeModule: 'react/compiler-runtime',
}],
],
};
// next.config.js (Next.js)
module.exports = {
experimental: {
reactCompiler: true,
},
};
// vite.config.js (Vite)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react({
babel: {
plugins: ['babel-plugin-react-compiler'],
},
}),
],
});
15.3 React Compilerの前提条件
React Compilerが正しく動作するには、コンポーネントが「Reactのルール」に従っている必要がある。
// ✅ Compilerが最適化できるコード
function PureComponent({ data }) {
// 純粋な計算(副作用なし)
const processed = data.map(item => ({
...item,
displayName: `${item.firstName} ${item.lastName}`,
}));
return (
<ul>
{processed.map(item => (
<li key={item.id}>{item.displayName}</li>
))}
</ul>
);
}
// ❌ Compilerが最適化できないコード
function ImpureComponent({ data }) {
// 副作用がレンダリング中に発生
document.title = `${data.length} items`; // ❌ 副作用
// 外部のミュータブル変数を読み取る
const currentTime = Date.now(); // ❌ 非決定的
// propsを直接変更する
data.sort((a, b) => a.name.localeCompare(b.name)); // ❌ ミューテーション
return <div>{data.length}</div>;
}
16. 状態管理パターン
16.1 ローカルステート
最もシンプルな状態管理。コンポーネント内で完結する状態に使用する。
function SearchBar() {
const [query, setQuery] = useState('');
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className={`search-bar ${isExpanded ? 'expanded' : ''}`}>
<button onClick={() => setIsExpanded(!isExpanded)}>🔍</button>
{isExpanded && (
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="検索..."
autoFocus
/>
)}
</div>
);
}
16.2 Context + useReducer パターン
中規模のアプリケーションに適した状態管理パターン。外部ライブラリを必要としない。
import { createContext, useContext, useReducer, useMemo } from 'react';
// ステート型の定義
const initialState = {
cart: [],
totalItems: 0,
totalPrice: 0,
};
// アクション定義
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': {
const existingIndex = state.cart.findIndex(
item => item.id === action.payload.id
);
let newCart;
if (existingIndex >= 0) {
newCart = state.cart.map((item, index) =>
index === existingIndex
? { ...item, quantity: item.quantity + 1 }
: item
);
} else {
newCart = [...state.cart, { ...action.payload, quantity: 1 }];
}
return {
...state,
cart: newCart,
totalItems: newCart.reduce((sum, item) => sum + item.quantity, 0),
totalPrice: newCart.reduce((sum, item) => sum + item.price * item.quantity, 0),
};
}
case 'REMOVE_ITEM': {
const newCart = state.cart.filter(item => item.id !== action.payload);
return {
...state,
cart: newCart,
totalItems: newCart.reduce((sum, item) => sum + item.quantity, 0),
totalPrice: newCart.reduce((sum, item) => sum + item.price * item.quantity, 0),
};
}
case 'UPDATE_QUANTITY': {
const newCart = state.cart.map(item =>
item.id === action.payload.id
? { ...item, quantity: action.payload.quantity }
: item
).filter(item => item.quantity > 0);
return {
...state,
cart: newCart,
totalItems: newCart.reduce((sum, item) => sum + item.quantity, 0),
totalPrice: newCart.reduce((sum, item) => sum + item.price * item.quantity, 0),
};
}
case 'CLEAR_CART':
return initialState;
default:
return state;
}
}
// Context作成
const CartContext = createContext(null);
const CartDispatchContext = createContext(null);
// Provider
function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, initialState);
return (
<CartContext.Provider value={state}>
<CartDispatchContext.Provider value={dispatch}>
{children}
</CartDispatchContext.Provider>
</CartContext.Provider>
);
}
// カスタムHooks
function useCart() {
const context = useContext(CartContext);
if (!context) throw new Error('useCart は CartProvider 内で使用してください');
return context;
}
function useCartDispatch() {
const context = useContext(CartDispatchContext);
if (!context) throw new Error('useCartDispatch は CartProvider 内で使用してください');
return context;
}
// 使用例
function ProductCard({ product }) {
const dispatch = useCartDispatch();
return (
<div className="product-card">
<h3>{product.name}</h3>
<p>¥{product.price.toLocaleString()}</p>
<button onClick={() => dispatch({ type: 'ADD_ITEM', payload: product })}>
カートに追加
</button>
</div>
);
}
function CartSummary() {
const { cart, totalItems, totalPrice } = useCart();
const dispatch = useCartDispatch();
return (
<div className="cart-summary">
<h2>ショッピングカート</h2>
<p>{totalItems}点の商品</p>
{cart.map(item => (
<div key={item.id} className="cart-item">
<span>{item.name}</span>
<span>×{item.quantity}</span>
<span>¥{(item.price * item.quantity).toLocaleString()}</span>
<button onClick={() => dispatch({
type: 'UPDATE_QUANTITY',
payload: { id: item.id, quantity: item.quantity - 1 },
})}>
-
</button>
<button onClick={() => dispatch({
type: 'UPDATE_QUANTITY',
payload: { id: item.id, quantity: item.quantity + 1 },
})}>
+
</button>
<button onClick={() => dispatch({ type: 'REMOVE_ITEM', payload: item.id })}>
削除
</button>
</div>
))}
<div className="total">
<strong>合計: ¥{totalPrice.toLocaleString()}</strong>
</div>
<button onClick={() => dispatch({ type: 'CLEAR_CART' })}>
カートを空にする
</button>
</div>
);
}
16.3 外部状態管理ライブラリ
Zustand
軽量でシンプルな状態管理ライブラリ。ボイラープレートが少なく、Reactのパターンに自然にフィットする。
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
// ストアの作成
const useStore = create(
devtools(
persist(
(set, get) => ({
// ステート
todos: [],
filter: 'all',
// アクション
addTodo: (text) => set((state) => ({
todos: [...state.todos, {
id: Date.now(),
text,
completed: false,
}],
})),
toggleTodo: (id) => set((state) => ({
todos: state.todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
),
})),
removeTodo: (id) => set((state) => ({
todos: state.todos.filter(todo => todo.id !== id),
})),
setFilter: (filter) => set({ filter }),
// 派生データ(ゲッター)
getFilteredTodos: () => {
const { todos, filter } = get();
switch (filter) {
case 'active': return todos.filter(t => !t.completed);
case 'completed': return todos.filter(t => t.completed);
default: return todos;
}
},
}),
{
name: 'todo-storage', // localStorageのキー
}
)
)
);
// コンポーネントでの使用
function TodoApp() {
const todos = useStore(state => state.getFilteredTodos());
const addTodo = useStore(state => state.addTodo);
const toggleTodo = useStore(state => state.toggleTodo);
const removeTodo = useStore(state => state.removeTodo);
const filter = useStore(state => state.filter);
const setFilter = useStore(state => state.setFilter);
const [input, setInput] = useState('');
return (
<div>
<form onSubmit={(e) => {
e.preventDefault();
if (input.trim()) {
addTodo(input.trim());
setInput('');
}
}}>
<input value={input} onChange={e => setInput(e.target.value)} />
<button type="submit">追加</button>
</form>
<div>
{['all', 'active', 'completed'].map(f => (
<button
key={f}
className={filter === f ? 'active' : ''}
onClick={() => setFilter(f)}
>
{f === 'all' ? 'すべて' : f === 'active' ? '未完了' : '完了'}
</button>
))}
</div>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => removeTodo(todo.id)}>削除</button>
</li>
))}
</ul>
</div>
);
}
Redux Toolkit
大規模アプリケーション向けの予測可能な状態管理。Redux Toolkitにより、ボイラープレートが大幅に削減された。
import { configureStore, createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { Provider, useSelector, useDispatch } from 'react-redux';
// 非同期アクション
const fetchUsers = createAsyncThunk(
'users/fetchUsers',
async (_, { rejectWithValue }) => {
try {
const response = await fetch('/api/users');
if (!response.ok) throw new Error('取得に失敗しました');
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// Slice(Reducer + Actions)
const usersSlice = createSlice({
name: 'users',
initialState: {
items: [],
loading: false,
error: null,
},
reducers: {
addUser(state, action) {
// Immerにより、直接変更のように書ける
state.items.push(action.payload);
},
removeUser(state, action) {
state.items = state.items.filter(user => user.id !== action.payload);
},
},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = false;
state.error = action.payload;
});
},
});
// ストアの設定
const store = configureStore({
reducer: {
users: usersSlice.reducer,
},
});
// コンポーネントでの使用
function UserList() {
const { items, loading, error } = useSelector(state => state.users);
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchUsers());
}, [dispatch]);
if (loading) return <p>読み込み中...</p>;
if (error) return <p>エラー: {error}</p>;
return (
<ul>
{items.map(user => (
<li key={user.id}>
{user.name}
<button onClick={() => dispatch(usersSlice.actions.removeUser(user.id))}>
削除
</button>
</li>
))}
</ul>
);
}
function App() {
return (
<Provider store={store}>
<UserList />
</Provider>
);
}
16.4 状態管理の選択基準
状態の種類と適切な管理方法:
1. UIの一時的な状態(フォーム入力、モーダル開閉等)
→ useState
2. 複雑なローカル状態(複数の関連する状態変更)
→ useReducer
3. 少数のコンポーネントで共有する状態
→ 状態のリフトアップ + props
4. コンポーネントツリー全体で共有する状態(テーマ、認証等)
→ Context API
5. 頻繁に更新されるグローバル状態
→ Zustand, Jotai, Redux Toolkit
6. サーバーデータのキャッシュと同期
→ TanStack Query (React Query), SWR
7. URL状態
→ React Router, Next.js Router
17. テスト
17.1 React Testing Library
React Testing Libraryは、ユーザーの操作に近い方法でコンポーネントをテストするためのライブラリである。「実装の詳細」ではなく「ユーザーの視点」でテストを書くことを推奨している。
// Counter.jsx
import { useState } from 'react';
export function Counter({ initialCount = 0 }) {
const [count, setCount] = useState(initialCount);
return (
<div>
<p>カウント: {count}</p>
<button onClick={() => setCount(c => c + 1)}>増加</button>
<button onClick={() => setCount(c => c - 1)}>減少</button>
<button onClick={() => setCount(0)}>リセット</button>
</div>
);
}
// Counter.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
describe('Counter', () => {
test('初期値が表示される', () => {
render(<Counter initialCount={5} />);
expect(screen.getByText('カウント: 5')).toBeInTheDocument();
});
test('増加ボタンでカウントが1増える', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: '増加' }));
expect(screen.getByText('カウント: 1')).toBeInTheDocument();
});
test('減少ボタンでカウントが1減る', async () => {
const user = userEvent.setup();
render(<Counter initialCount={5} />);
await user.click(screen.getByRole('button', { name: '減少' }));
expect(screen.getByText('カウント: 4')).toBeInTheDocument();
});
test('リセットボタンでカウントが0になる', async () => {
const user = userEvent.setup();
render(<Counter initialCount={10} />);
await user.click(screen.getByRole('button', { name: 'リセット' }));
expect(screen.getByText('カウント: 0')).toBeInTheDocument();
});
});
17.2 非同期コンポーネントのテスト
// UserProfile.jsx
import { useState, useEffect } from 'react';
export function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function fetchUser() {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('ユーザーが見つかりません');
const data = await response.json();
setUser(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
fetchUser();
}, [userId]);
if (loading) return <p>読み込み中...</p>;
if (error) return <p role="alert">エラー: {error}</p>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
// UserProfile.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';
// fetchのモック
beforeEach(() => {
global.fetch = vi.fn();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('UserProfile', () => {
test('ローディング状態を表示する', () => {
global.fetch.mockImplementation(() => new Promise(() => {})); // 解決しないPromise
render(<UserProfile userId="1" />);
expect(screen.getByText('読み込み中...')).toBeInTheDocument();
});
test('ユーザー情報を表示する', async () => {
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ name: '田中太郎', email: 'tanaka@example.com' }),
});
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText('田中太郎')).toBeInTheDocument();
});
expect(screen.getByText('tanaka@example.com')).toBeInTheDocument();
});
test('エラーメッセージを表示する', async () => {
global.fetch.mockResolvedValueOnce({
ok: false,
});
render(<UserProfile userId="999" />);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('ユーザーが見つかりません');
});
});
});
17.3 カスタムHooksのテスト
// useLocalStorage.js
import { useState, useEffect } from 'react';
export function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch (error) {
console.error('LocalStorage write error:', error);
}
}, [key, storedValue]);
return [storedValue, setStoredValue];
}
// useLocalStorage.test.js
import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from './useLocalStorage';
describe('useLocalStorage', () => {
beforeEach(() => {
localStorage.clear();
});
test('初期値を返す', () => {
const { result } = renderHook(() => useLocalStorage('key', 'default'));
expect(result.current[0]).toBe('default');
});
test('値を更新するとlocalStorageに保存される', () => {
const { result } = renderHook(() => useLocalStorage('key', 'initial'));
act(() => {
result.current[1]('updated');
});
expect(result.current[0]).toBe('updated');
expect(JSON.parse(localStorage.getItem('key'))).toBe('updated');
});
test('localStorageに既存の値がある場合はそれを使用する', () => {
localStorage.setItem('key', JSON.stringify('existing'));
const { result } = renderHook(() => useLocalStorage('key', 'default'));
expect(result.current[0]).toBe('existing');
});
test('オブジェクトの保存と取得', () => {
const { result } = renderHook(() => useLocalStorage('user', null));
act(() => {
result.current[1]({ name: '田中', age: 30 });
});
expect(result.current[0]).toEqual({ name: '田中', age: 30 });
});
});
17.4 テストのベストプラクティス
// テストの構造: Arrange-Act-Assert (AAA) パターン
test('フォーム送信が成功する', async () => {
// Arrange(準備)
const handleSubmit = vi.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
// Act(実行)
await user.type(screen.getByLabelText('メールアドレス'), 'test@example.com');
await user.type(screen.getByLabelText('パスワード'), 'password123');
await user.click(screen.getByRole('button', { name: 'ログイン' }));
// Assert(検証)
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
// クエリの優先順位(推奨度が高い順)
// 1. getByRole - アクセシビリティロール
// 2. getByLabelText - フォーム要素のラベル
// 3. getByPlaceholderText - プレースホルダー
// 4. getByText - テキストコンテンツ
// 5. getByDisplayValue - フォームの現在値
// 6. getByAltText - alt属性
// 7. getByTitle - title属性
// 8. getByTestId - data-testid(最後の手段)
// アクセシビリティを意識したテスト
test('アクセシブルなフォーム', () => {
render(<RegistrationForm />);
// ロールで要素を取得
expect(screen.getByRole('heading', { name: 'アカウント登録' })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: '名前' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '登録する' })).toBeInTheDocument();
// エラーメッセージのアクセシビリティ
expect(screen.getByRole('alert')).toHaveTextContent('入力エラーがあります');
});
18. ベストプラクティス
18.1 コンポーネント設計の原則
// 1. 単一責任の原則: 1コンポーネント = 1責務
// ❌ 悪い例: 1つのコンポーネントに多くの責務
function UserPage() {
const [users, setUsers] = useState([]);
const [formData, setFormData] = useState({ name: '', email: '' });
const [editingUser, setEditingUser] = useState(null);
const [sortOrder, setSortOrder] = useState('asc');
const [filterText, setFilterText] = useState('');
// ...100行以上のロジック
}
// ✅ 良い例: 責務ごとに分割
function UserPage() {
return (
<div>
<UserSearchBar />
<UserForm />
<UserList />
</div>
);
}
// 2. Presentational / Container パターン(関心の分離)
// Container: ロジックとデータ取得
function UserListContainer() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUsers().then(data => {
setUsers(data);
setLoading(false);
});
}, []);
if (loading) return <LoadingSpinner />;
return <UserListView users={users} />;
}
// Presentational: 表示のみ
function UserListView({ users }) {
return (
<ul className="user-list">
{users.map(user => (
<li key={user.id} className="user-item">
<img src={user.avatar} alt={user.name} />
<span>{user.name}</span>
</li>
))}
</ul>
);
}
// 3. 早期リターンで条件分岐を簡潔に
function UserProfile({ user, loading, error }) {
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
if (!user) return <EmptyState message="ユーザーが見つかりません" />;
// メインのレンダリングロジック
return (
<div className="user-profile">
<h2>{user.name}</h2>
<p>{user.bio}</p>
</div>
);
}
18.2 カスタムHooksの設計
// 汎用的なデータフェッチHook
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const abortController = new AbortController();
async function fetchData() {
setLoading(true);
setError(null);
try {
const response = await fetch(url, {
...options,
signal: abortController.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
setData(result);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err);
}
} finally {
if (!abortController.signal.aborted) {
setLoading(false);
}
}
}
fetchData();
return () => abortController.abort();
}, [url]);
return { data, loading, error };
}
// 使用例
function UserList() {
const { data: users, loading, error } = useFetch('/api/users');
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return (
<ul>
{users.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
// デバウンスHook
function useDebounce(value, delay = 300) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// メディアクエリHook
function useMediaQuery(query) {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia(query);
setMatches(mediaQuery.matches);
const handler = (e) => setMatches(e.matches);
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [query]);
return matches;
}
// 使用例
function ResponsiveLayout({ children }) {
const isMobile = useMediaQuery('(max-width: 768px)');
const isTablet = useMediaQuery('(min-width: 769px) and (max-width: 1024px)');
return (
<div className={isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop'}>
{children}
</div>
);
}
// ローカルストレージ同期Hook
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback((value) => {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
localStorage.setItem(key, JSON.stringify(valueToStore));
}, [key, storedValue]);
return [storedValue, setValue];
}
// クリップボードHook
function useClipboard() {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef(null);
const copy = useCallback(async (text) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('クリップボードへのコピーに失敗:', err);
setCopied(false);
}
}, []);
useEffect(() => {
return () => clearTimeout(timeoutRef.current);
}, []);
return { copy, copied };
}
18.3 ディレクトリ構造
src/
├── app/ # ルーティングとページ(App Router使用時)
│ ├── layout.tsx # ルートレイアウト
│ ├── page.tsx # ホームページ
│ ├── (auth)/ # ルートグループ
│ │ ├── login/
│ │ │ └── page.tsx
│ │ └── register/
│ │ └── page.tsx
│ └── dashboard/
│ ├── layout.tsx
│ └── page.tsx
│
├── components/ # 共有コンポーネント
│ ├── ui/ # 基本UIコンポーネント
│ │ ├── Button/
│ │ │ ├── Button.tsx
│ │ │ ├── Button.test.tsx
│ │ │ └── index.ts
│ │ ├── Input/
│ │ ├── Modal/
│ │ └── Card/
│ ├── layout/ # レイアウトコンポーネント
│ │ ├── Header/
│ │ ├── Footer/
│ │ └── Sidebar/
│ └── features/ # 機能別コンポーネント
│ ├── auth/
│ │ ├── LoginForm.tsx
│ │ └── RegisterForm.tsx
│ └── blog/
│ ├── PostList.tsx
│ ├── PostCard.tsx
│ └── PostEditor.tsx
│
├── hooks/ # カスタムHooks
│ ├── useAuth.ts
│ ├── useFetch.ts
│ ├── useDebounce.ts
│ └── useLocalStorage.ts
│
├── lib/ # ユーティリティとヘルパー
│ ├── api.ts # API クライアント
│ ├── utils.ts # 汎用ユーティリティ
│ └── validations.ts # バリデーションスキーマ
│
├── contexts/ # Contextプロバイダー
│ ├── AuthContext.tsx
│ └── ThemeContext.tsx
│
├── types/ # TypeScript型定義
│ ├── user.ts
│ └── post.ts
│
└── styles/ # グローバルスタイル
├── globals.css
└── variables.css
18.4 コーディングガイドライン
// 1. 型安全性を確保する(TypeScript推奨)
interface ButtonProps {
variant: 'primary' | 'secondary' | 'danger';
size: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
onClick?: () => void;
children: React.ReactNode;
}
function Button({ variant, size, disabled, loading, onClick, children }: ButtonProps) {
return (
<button
className={`btn btn-${variant} btn-${size}`}
disabled={disabled || loading}
onClick={onClick}
>
{loading ? <Spinner size="sm" /> : children}
</button>
);
}
// 2. エラーハンドリングを適切に行う
function SafeDataDisplay({ fetchFn }) {
const [state, setState] = useState({ data: null, error: null, loading: true });
useEffect(() => {
let cancelled = false;
fetchFn()
.then(data => {
if (!cancelled) setState({ data, error: null, loading: false });
})
.catch(error => {
if (!cancelled) setState({ data: null, error, loading: false });
});
return () => { cancelled = true; };
}, [fetchFn]);
// 状態に応じた適切な表示
if (state.loading) return <Skeleton />;
if (state.error) return <ErrorFallback error={state.error} />;
if (!state.data) return <EmptyState />;
return <DataView data={state.data} />;
}
// 3. アクセシビリティを考慮する
function AccessibleForm() {
const [errors, setErrors] = useState({});
return (
<form role="form" aria-label="お問い合わせフォーム">
<div>
<label htmlFor="name">
名前 <span aria-hidden="true">*</span>
<span className="sr-only">(必須)</span>
</label>
<input
id="name"
type="text"
required
aria-required="true"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && (
<p id="name-error" role="alert" className="error">
{errors.name}
</p>
)}
</div>
<button type="submit">送信</button>
</form>
);
}
19. まとめ
19.1 Reactのエコシステム
Reactは単体のUIライブラリに留まらず、豊富なエコシステムによって支えられている。以下に主要なライブラリとツールをカテゴリ別に整理する。
フレームワーク
| フレームワーク | 特徴 | ユースケース |
|---|---|---|
| Next.js | SSR/SSG/ISR、App Router、Server Components | プロダクションWebアプリケーション |
| Remix | Web標準準拠、ネストされたルーティング | フルスタックWebアプリ |
| Gatsby | GraphQLベースの静的サイト生成 | ブログ、ドキュメントサイト |
| Expo | React Native開発の統合環境 | モバイルアプリ |
状態管理
| ライブラリ | アプローチ | 適用規模 |
|---|---|---|
| Zustand | シンプルなストア | 小〜中規模 |
| Jotai | アトミックな状態管理 | 小〜中規模 |
| Redux Toolkit | Flux アーキテクチャ | 中〜大規模 |
| Recoil | アトム + セレクタ | 中規模 |
| TanStack Query | サーバー状態管理 | API連携 |
| SWR | データフェッチ + キャッシュ | API連携 |
UIライブラリ
| ライブラリ | スタイル | 特徴 |
|---|---|---|
| shadcn/ui | Tailwind CSS | コピー&ペースト式、カスタマイズ容易 |
| Radix UI | ヘッドレス | アクセシブルなプリミティブ |
| Material UI | Material Design | 豊富なコンポーネント群 |
| Ant Design | Ant Design System | エンタープライズ向け |
| Chakra UI | 独自テーマシステム | アクセシビリティ重視 |
フォーム管理
| ライブラリ | 特徴 |
|---|---|
| React Hook Form | パフォーマンス重視、非制御コンポーネント |
| Formik | 宣言的フォーム管理 |
| Zod | TypeScriptファーストなバリデーション |
テスト
| ツール | 用途 |
|---|---|
| Vitest | ユニットテスト(高速) |
| React Testing Library | コンポーネントテスト |
| Playwright | E2Eテスト |
| Cypress | E2Eテスト |
| Storybook | ビジュアルテスト、コンポーネントカタログ |
開発ツール
| ツール | 用途 |
|---|---|
| Vite | 開発サーバー、ビルドツール |
| React DevTools | コンポーネントのデバッグ |
| ESLint + eslint-plugin-react | コード品質チェック |
| Prettier | コードフォーマット |
| TypeScript | 型安全性 |
19.2 Reactの最新動向と今後
React 19の主な機能
- React Compiler: 自動メモ化により、useMemo/useCallback/React.memoが不要に
- Server Components: サーバー上でレンダリングされるコンポーネントの正式サポート
- Actions: フォーム送信と状態更新を統合するパターン
- use() API: PromiseとContextを関数コンポーネント内で直接使用
- useActionState: フォームアクションの状態管理の簡素化
- useOptimistic: 楽観的UI更新のサポート
- Document Metadata:
<title>、<meta>タグのコンポーネント内での直接宣言
今後の方向性
Reactの今後の方向性として、以下の傾向が見られる。
- サーバーファースト: Server Components、Server Actions の強化により、サーバーとクライアントの境界をシームレスに扱えるようになる
- コンパイラ最適化: React Compilerにより、開発者が手動で行っていた最適化が自動化される
- フレームワーク統合: React単体よりも、Next.jsなどのフレームワーク経由での利用が推奨される傾向にある
- Web標準への回帰: フォームアクション、ストリーミングSSR など、Web標準のAPIを活用する方向に進んでいる
- パフォーマンスの継続的改善: Concurrent Features のさらなる活用と、レンダリングパフォーマンスの向上
19.3 学習ロードマップ
React開発者としてのスキルを段階的に身につけるためのロードマップを以下に示す。
レベル1(基礎):
├── HTML / CSS / JavaScript (ES6+)
├── JSX 構文
├── コンポーネント(関数コンポーネント)
├── Props と State(useState)
├── イベントハンドリング
├── 条件付きレンダリングとリスト
└── 基本的なフォーム処理
レベル2(中級):
├── useEffect(副作用の管理)
├── useRef、useReducer
├── Context API
├── カスタムHooks
├── React Router
├── エラーバウンダリ
├── React Testing Library
└── TypeScript との統合
レベル3(上級):
├── パフォーマンス最適化(useMemo、useCallback、React.memo)
├── Suspense と遅延読み込み
├── コンカレント機能(useTransition、useDeferredValue)
├── Server Components
├── 状態管理ライブラリ(Zustand / Redux Toolkit)
├── TanStack Query / SWR
├── React Compiler
└── Next.js / Remix
レベル4(エキスパート):
├── アーキテクチャ設計
├── 大規模アプリケーション設計パターン
├── パフォーマンスプロファイリングと最適化
├── アクセシビリティ(WCAG準拠)
├── マイクロフロントエンド
├── デザインシステム構築
└── コントリビューション(OSS活動)
19.4 結論
Reactは2013年のリリース以来、フロントエンド開発の世界を大きく変革してきた。コンポーネントベースのアーキテクチャ、宣言的UI、仮想DOMという3つの核となるコンセプトは、現代のWebフレームワーク設計に多大な影響を与えている。
React 18で導入されたコンカレント機能と、React 19で正式にサポートされたServer Components、React Compilerにより、Reactはさらに進化を続けている。特にServer Componentsは、クライアントとサーバーの境界を意識せずにアプリケーションを構築できるようにする革新的な機能であり、Webアプリケーション開発のパラダイムを大きく変える可能性を持っている。
Reactの強みは、ライブラリとしてのシンプルさと、エコシステムの豊富さの両立にある。開発者はプロジェクトの要件に応じて適切なツールを選択し、段階的にスケールさせることができる。この柔軟性こそが、Reactが長年にわたりフロントエンド開発の主要な選択肢であり続けている理由である。
React開発を始めるにあたっては、まず公式ドキュメント(https://react.dev)を読み、基本的なコンセプトを理解することを推奨する。公式ドキュメントは非常に充実しており、インタラクティブなチュートリアルも提供されている。その上で、実際にプロジェクトを構築しながら、段階的に高度な機能を習得していくことが最も効果的な学習方法である。
本記事は2024年12月時点のReact 19を基準に執筆されている。Reactは活発に開発が進められているため、最新の情報については公式ドキュメントを参照されたい。