Reactコンポーネントは純関数であるべき。副作用はイベントハンドラーとuseEffectに集約する
React を学んでいると「コンポーネントは純関数であるべき」という原則をよく目にします。名前は難しそうですが、やっていることはシンプル。この記事では、純関数とは何か、なぜそうすべきなのか、そして副作用とどう向き合うかを整理しておきます。
純関数(Pure Function)とは
純関数は、プログラミング全般の概念です。
「同じ入力に対して、常に同じ出力を返し、外部の状態を変更しない関数」
例:純関数 vs 非純関数
純関数の例
// ✅ 同じ入力なら常に同じ出力
const add = (a: number, b: number) => a + b;
add(2, 3); // → 5
add(2, 3); // → 5(何度呼んでも同じ)
add(2, 3); // → 5
非純関数の例
// ❌ 外部の状態を変更している
let total = 0;
const addToTotal = (amount: number) => {
total += amount; // ← 外部の変数を変更
return total;
};
addToTotal(2); // → 2
addToTotal(3); // → 5(同じ入力なのに異なる出力!)
addToTotal(2); // → 7(予測不可能)
React コンポーネントが純関数であるべき理由
React のコンポーネントは、props を入力とし、UI(JSX)を出力する関数です。
コンポーネントが純関数なら、「同じ props を渡すと、常に同じ UI が返される」という 予測可能性 が生まれます。
純関数コンポーネント
type UserCardProps = {
name: string;
age: number;
};
const UserCard = ({ name, age }: UserCardProps) => {
return (
<div>
<h2>{name}</h2>
<p>年齢: {age}</p>
</div>
);
};
// 同じ props → 常に同じ UI
<UserCard name="Taro" age={25} />
<UserCard name="Taro" age={25} />
<UserCard name="Taro" age={25} />
非純関数コンポーネント(アンチパターン)
// ❌ 外部の状態に依存している
let renderCount = 0;
const BadComponent = ({ name }: { name: string }) => {
renderCount++; // ← 副作用:外部の状態を変更
return (
<div>
<h2>{name}</h2>
<p>描画回数: {renderCount}</p> {/* ← 予測不可能 */}
</div>
);
};
// 同じ props でも、毎回異なる UI が返される
<BadComponent name="Taro" /> // → 描画回数: 1
<BadComponent name="Taro" /> // → 描画回数: 2
<BadComponent name="Taro" /> // → 描画回数: 3
純関数コンポーネントの利点
1. React が最適化できる
React は「同じ props なら前回と同じ結果」と判定して、再レンダリングをスキップ(メモ化)できます。
// React.memo は純関数コンポーネント前提
const MemoizedUserCard = React.memo(UserCard);
// 同じ props なら、内部的に再レンダリングされない
<MemoizedUserCard name="Taro" age={25} />
<MemoizedUserCard name="Taro" age={25} /> // ← スキップ
2. テストが簡単
// 純関数なら、props を渡すだけでテストできる
test("UserCard は name を表示する", () => {
const { getByText } = render(
<UserCard name="Taro" age={25} />
);
expect(getByText("Taro")).toBeInTheDocument();
});
// 外部の状態を mock する必要がない
3. バグが少ない
- 同じ props なら常に同じ結果
- 予測可能な挙動
- 隔離されている
❌ コンポーネント内で避けるべき操作
| 操作 | 理由 |
|---|---|
| グローバル変数の変更 | 純関数ではなくなる |
| DOM の直接操作 | React の宣言的思想に反する |
| API の呼び出し | 副作用(後述) |
| props を変更 | 入力を変更してはいけない |
| localStorage への書き込み | 外部の状態を変更 |
例:props を変更してはいけない
// ❌ 間違い
const BadComponent = (props: { name: string }) => {
props.name = "changed"; // ← props は読み取り専用
return <div>{props.name}</div>;
};
// ✅ 正しい
const GoodComponent = ({ name }: { name: string }) => {
const [displayName, setDisplayName] = useState(name);
const changeName = () => {
setDisplayName("changed"); // ← 新しい状態を作る
};
return (
<div>
<p>{displayName}</p>
<button onClick={changeName}>変更</button>
</div>
);
};
✅ コンポーネント内で OK な操作
| 操作 | 理由 |
|---|---|
| props を読む | 入力を読むのは OK |
| useState で状態を管理 | React が管理してくれる |
| useEffect で副作用を実行 | React が実行タイミングを制御 |
| イベントハンドラーで処理 | ユーザーアクション時は OK |
副作用(Side Effects)とは
副作用は、コンポーネント外の状態を変更することです。
副作用の例
// ❌ 副作用:API を呼び出す
const BadUserList = ({ userId }: { userId: number }) => {
fetch(`/api/users/${userId}`); // ← コンポーネントが描画されるたびに呼ばれる
return <div>ユーザー情報</div>;
};
// ❌ 副作用:document.title を変更
const BadPageTitle = ({ title }: { title: string }) => {
document.title = title; // ← 描画のたびに変更される
return <h1>{title}</h1>;
};
// ❌ 副作用:グローバル状態を変更
let userCount = 0;
const BadCounter = () => {
userCount++; // ← 外部の状態を変更
return <div>{userCount}</div>;
};
副作用の正しい向き合い方
1. useEffect で副作用を管理
副作用が 必須 なら、useEffect フック で隔離します。
import { useEffect, useState } from 'react';
// ✅ API 呼び出しは useEffect で
const GoodUserList = ({ userId }: { userId: number }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// ここの処理は、コンポーネントのマウント時と userId 変更時のみ実行
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
setUser(data);
setLoading(false);
});
}, [userId]); // ← userId が変わったときだけ実行
if (loading) return <div>読み込み中...</div>;
return <div>{user?.name}</div>;
};
// ✅ document.title 変更も useEffect で
const GoodPageTitle = ({ title }: { title: string }) => {
useEffect(() => {
document.title = title;
}, [title]); // ← title が変わったときだけ変更
return <h1>{title}</h1>;
};
useEffect が副作用に最適な理由:
- React がいつ実行するかを制御(マウント時、依存配列の変更時)
- 二重実行を避けられる
- クリーンアップができる
2. イベントハンドラーで副作用を実行
ユーザーアクション(クリック、送信など)に伴う副作用は、イベントハンドラーで処理します。
// ✅ ボタンクリック → API 呼び出し
const LoginForm = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); // ← ブラウザのデフォルト動作をキャンセル
// API 呼び出し(ユーザーが明示的に「送信」を選んだ)
const response = await fetch("/api/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
if (response.ok) {
console.log("ログイン成功");
}
};
return (
<form onSubmit={handleSubmit}>
<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>
);
};
useEffect vs イベントハンドラー:使い分け
| 状況 | 使うべき場所 | 理由 |
|---|---|---|
| ページロード時にデータ取得 | useEffect |
マウント時に自動実行 |
| props の変更をキャッチ | useEffect |
依存配列で変更を検知 |
| ユーザーがボタンをクリック | イベントハンドラー | ユーザーの明示的アクション |
| フォーム送信 | イベントハンドラー | ユーザーが「送信」を選んだ |
| ウィンドウリサイズを監視 | useEffect | 環境の変化を自動追跡 |
実装パターン:メモアプリの例
コンポーネントは純関数に
type NoteProps = {
note: Note;
onEdit: (note: Note) => void;
onDelete: (id: string) => void;
};
// ✅ 純関数:同じ props なら常に同じ UI
const NoteCard = ({ note, onEdit, onDelete }: NoteProps) => {
const handleEdit = () => {
onEdit(note); // ← 親に処理を委譲
};
const handleDelete = () => {
onDelete(note.id); // ← 親に処理を委譲
};
return (
<div className="note-card">
<h3>{note.title}</h3>
<p>{note.content}</p>
<button onClick={handleEdit}>編集</button>
<button onClick={handleDelete}>削除</button>
</div>
);
};
親コンポーネントで副作用を管理
const Home = () => {
const [notes, setNotes] = useState<Note[]>([]);
// ✅ マウント時にデータ取得
useEffect(() => {
const fetchNotes = async () => {
const response = await noteRepository.getNotes();
setNotes(response.notes);
};
fetchNotes();
}, []);
// ✅ ユーザーが編集ボタンをクリック
const handleEdit = (note: Note) => {
// モーダルを開く、状態を更新する など
};
// ✅ ユーザーが削除ボタンをクリック
const handleDelete = async (id: string) => {
await noteRepository.deleteNote(id); // ← API 呼び出し
setNotes((prev) => prev.filter((n) => n.id !== id));
};
return (
<div>
{notes.map((note) => (
<NoteCard
key={note.id}
note={note}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
);
};
大事なポイント
| ポイント | 意味 |
|---|---|
| コンポーネント = 純関数 | 同じ props なら常に同じ UI |
| 副作用は隔離 | useEffect またはイベントハンドラーで管理 |
| 親に委譲 | 子コンポーネントは処理を親に任せる(onEdit など) |
| 予測可能性 | 同じ props なら同じ結果 → React が最適化できる |
まとめ
React の「純関数」という原則は、単なる概念ではなく、保守性とパフォーマンスの両方に影響する実装の指針です。
- コンポーネントは純関数に → 同じ props なら常に同じ UI
- 副作用を避ける → 外部の状態を変更しない
- 副作用が必須なら useEffect またはイベントハンドラーで → タイミングを制御
この原則を守ると、バグが少なく、テストしやすく、パフォーマンスの良いコンポーネントが作られます。