· yuta

Reactコンポーネントは純関数であるべき。副作用はイベントハンドラーとuseEffectに集約する

#react#typescript#frontend

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. バグが少ない

❌ コンポーネント内で避けるべき操作

操作 理由
グローバル変数の変更 純関数ではなくなる
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 が副作用に最適な理由:

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 の「純関数」という原則は、単なる概念ではなく、保守性とパフォーマンスの両方に影響する実装の指針です。

この原則を守ると、バグが少なく、テストしやすく、パフォーマンスの良いコンポーネントが作られます。