📚

初学者のためのReactコンポーネント設計 - 表示と動作の分離から状態更新の流れまで

2025/03/08に公開

1. はじめに

この記事で学べること

  • Reactコンポーネント内の2種類のロジック(レンダリングとイベントハンドラ)の違い
  • それぞれのロジックの役割と適切な使用方法
  • Reactの実践的な開発フローと状態管理の基本
  • TypeScript を使用した実装例

参考資料

https://ja.react.dev/learn/synchronizing-with-effects

https://ja.react.dev/learn/render-and-commit

2. Reactコンポーネント内の2種類のロジックについて理解する

Reactコンポーネントは、主に以下2種類の重要な定義があります。
Raactでは、この2つの定義を理解して「見た目」と「動作」を作り上げることが重要です。

  1. 見た目:レンダリングロジック
    • コンポーネントの構造と見た目を定義
    • JSXを返却する部分(returnステートメント内)
  2. 動作:イベントハンドラ
    • ユーザーの操作に対する動作を定義
    • ボタンクリックや入力変更などへの反応

この記事では、この2つの重要な定義に関して、料理の例と具体的なコードを用いてわかりやすく解説していきます。

2.1 レンダリングロジック

まずは、レンダリングロジックについて解説します。
レンダリングは、以下のステップに分かれており「料理」を提供する過程に例えることができます。

「料理」を提供する過程

Reactコンポーネントのライフサイクルと状態遷移

上の図は、Reactコンポーネントのライフサイクルと状態遷移を示しています。コンポーネントがマウントされてから、ユーザーのアクションによるイベント発生、状態更新、再レンダリングという循環的なフローが分かります。

上記、実装例として、以下、レストランコンポーネントを例に解説します。

実装例 - レストランコンポーネント

以下の8ステップに分解してレストランコンポーネントを実装した例です。

  1. マウント - 準備開始
  2. レンダリング - レシピ確認と調理
  3. DOM反映 - 料理提供
  4. 待機状態 - お客様が食事中
  5. イベント発生 - 追加注文
  6. 状態更新 - 追加調理
  7. 再レンダリングとDOM再反映 - 追加料理の完成と提供
  8. アンマウント - 食事終了と片付け
function Restaurant() {
  // メニュー項目を管理するstate
  const [menuItems, setMenuItems] = useState(["パスタ", "ピザ", "サラダ"]);
  
  // 注文と追加注文を管理するstate
  const [orders, setOrders] = useState([]);
  
  // 食事中かどうかを管理するstate
  const [isDining, setIsDining] = useState(false);
  
  // ステップ1: マウント - 準備開始
  useEffect(() => {
    console.log("レストランがオープンしました(食材の仕込み完了)");
    // 実際のアプリでは、APIからメニュー取得などを行うことも
    return () => {
      console.log("レストランが閉店しました(アンマウント)");
    };
  }, []);
  
  // ステップ5&6: 追加注文と追加調理 - イベント発生と状態更新
  const placeOrder = (item) => {
    // ステップ5: イベント発生 - 追加注文
    console.log(`お客様から「${item}」の注文がありました`);
    
    // ステップ6: 状態更新 - 追加調理
    setOrders(prevOrders => [...prevOrders, item]); // 注文追加
    setIsDining(true); // 食事中状態に変更
  };
  
  // ステップ8: アンマウント - 食事終了と片付け
  const finishDining = () => {
    console.log("お会計が完了しました");
    setOrders([]);
    setIsDining(false);
  };

  // ステップ2: レンダリング - レシピ確認と調理
  // この関数の戻り値(return部分)が、画面に表示される内容
  return (
    <div className="restaurant">
      <h1>レストランへようこそ!</h1>
      
      {/* メニューの表示 */}
      <div className="menu">
        <h2>本日のメニュー</h2>
        <ul>
          {menuItems.map((item, index) => (
            <li key={index}>
              {item} 
              <button onClick={() => placeOrder(item)}>
                {orders.includes(item) ? "追加注文" : "注文する"}
              </button>
            </li>
          ))}
        </ul>
      </div>
      
      {/* ステップ3: DOM反映 - 料理提供 */}
      {orders.length > 0 && (
        <div className="orders">
          <h2>提供中のお料理</h2>
          <ul>
            {/* ステップ7: 再レンダリングとDOM再反映 - 追加料理の完成と提供 */}
            {orders.map((order, index) => (
              <li key={index}>
                ご注文の「{order}」が完成しました!
                <small>(純粋関数によるレンダリング結果)</small>
              </li>
            ))}
          </ul>
          
          {/* ステップ4: 待機状態 - お客様が食事中 */}
          {isDining && (
            <div className="dining-options">
              <p>ごゆっくりお召し上がりください。追加のご注文も承ります。</p>
              <button onClick={finishDining}>お会計をお願いします</button>
            </div>
          )}
        </div>
      )}
    </div>
  );
}
Reactのライフサイクル 料理のプロセス 説明
1. マウント 準備開始 ・レストランがオープンすると、useEffect で初期化処理を実行
・料理店での食材の仕込みや準備作業に相当
2. レンダリング レシピ確認と調理 ・シェフ(React)は、メニューに基づいて料理を準備
return文の中身が、実際に提供される料理の「レシピ」に相当
3. DOM反映 料理提供 ・調理された料理がお客様のテーブルに運れる
・画面上に実際にUIが表示される段階
4. 待機状態 お客様が食事中 ・お客様が食事を楽しんでいる間、シェフは、次の注文を待つ
・Reactが、ユーザーの次の操作を待っている状態
5. イベント発生 追加注文 ・お客様が「追加注文」をリクエストすると、追加注文が発生
placeOrder 関数がトリガーされ、注文情報がReact に伝えられる
6. 状態更新 追加調理 ・新しい注文を受けて、シェフが追加の料理を準備
setOrders による状態更新が行われ、再レンダリングがトリガーされる
7. 再レンダリングとDOM再反映 追加料理の完成と提供 ・追加注文の料理が完成し、お客様のテーブルに運ばれる
・更新された状態に基づいてUIが再描画され、変更が画面に反映される
8. アンマウント 食事終了と片付け ・お客様がお会計を済ませ、席を立つ段階
・コンポーネントがクリーンアップされ、リソースが解放される

レンダリングのコードは「純粋」であることが重要

  • 重要なのは、この料理の過程(レンダリング)が、純粋であることです。
  • 純粋な関数とは、同じ入力を与えると常に同じ出力を返し、副作用(関数の外部の状態を変更すること)を持たない関数のことです。
  • 料理で言えば、同じ材料と同じレシピを使えば、常に同じ料理ができあがることです。

レンダリングのコードが純粋であることの条件

  1. 同じ入力(props と state)に対して常に同じ出力(UI)を返却
  2. 関数の外部の状態を変更しない(副作用がない)

レンダリングのコードが純粋であることで、以下のような利点があります。

  1. 予測可能性
    • 同じ注文なら常に同じ料理が出てくるので、お客様も安心
  2. テストのしやすさ
    • 料理の品質チェックが簡単に
  3. パフォーマンスの最適化
    • 無駄な調理作業を省けるので、効率的に料理を提供可能

上の図は、ユーザーのアクションから始まり、イベントハンドラの実行、状態更新、そしてReactのレンダリングプロセスを経て、再びUIが更新されるまでの循環的な流れを示しています。この循環がReactアプリケーションの基本的な動作原理です。

良い例と悪い例

良い例(純粋なレンダリングのコード)

function PureMenu({ dish, price }) {
  return (
    <div>
      <h2>{dish}</h2>
      <p>価格: {price}</p>
    </div>
  );
}

この例では、与えられたdishpriceだけを使ってUIを作成しています。
同じ入力に対して常に同じ出力が得られます。

悪い例(純粋でないレンダリングのコード)

function ImpureMenu({ dish }) {
  // 外部のAPIを呼び出している(副作用がある)
  const price = fetchPriceFromAPI(dish);
  
  // 現在時刻を使用している(同じ入力でも異なる出力になる)
  const orderTime = new Date().toLocaleTimeString();

  return (
    <div>
      <h2>{dish}</h2>
      <p>価格: {price}</p>
      <p>注文時刻: {orderTime}</p>
    </div>
  );
}
  • この例では、外部APIの呼び出しや現在時刻の使用により、同じ入力でも異なる出力が生成される可能性があります。これは回避すべきです。
  • 純粋なレンダリングのコードを書くことで、コンポーネントの動作が予測可能になり、Reactの性能最適化機能を最大限に活用できます。
  • これは効率的で信頼性の高いアプリケーションを作るための重要な原則です。

2.2 イベントハンドラ

イベントハンドラは、ユーザーの操作(クリックや入力など)に反応して何かを「実行する」部分です。
画面の表示を変更したり、データを保存したりするような動作を担当します。

  • 特徴
    • コンポーネント内の関数として定義
    • ユーザーのアクションに応じて呼び出し
    • 状態を変更したり、外部とやり取りしたりする「副作用」を含むことが可能
    • プログラムの状態を変更する役割を保有
  • よく使用されるイベント
    • onClick
      • 要素がクリックされたときに発火
    • onChange
      • 入力フィールドの値が変更されたときに発火
    • onSubmit
      • フォームが送信されたときに発火

イベントハンドラの定義方法

  1. インラインで定義
<button onClick={() => console.log('クリックされました')}>
  クリック
</button>
  1. 関数として定義
const handleClick = () => {
  console.log('クリックされました');
};

return <button onClick={handleClick}>クリック</button>;

例:

function UserForm() {
  const [name, setName] = useState('');

  const handleSubmit = async (event) => {
    event.preventDefault();
    try {
      await saveUser({ name });
      alert('ユーザーを保存しました!');
      setName('');  // フォームをリセット
    } catch (error) {
      alert('エラーが発生しました');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="ユーザー名"
      />
      <button type="submit">保存</button>
    </form>
  );
}

2.3 レンダリングとイベントハンドラの循環

Reactの重要な特徴は、状態更新に基づく自動的な再レンダリングです。これにより、以下のような循環が生まれます

  1. ユーザーがUIとインタラクションする(ボタンクリックなど)
  2. イベントハンドラが実行される
  3. イベントハンドラが状態(state)を更新する
  4. 状態の更新によって再レンダリングがトリガーされる
  5. 更新された状態に基づいて新しいUIが生成される
  6. 変更されたUIが画面に反映される

この循環がReactの宣言的UIの基盤となっています。開発者は「どのように画面を更新するか」ではなく、「各状態において画面がどう見えるべきか」を定義します。

2.4 React18の新機能とレンダリング

React18からは、Concurrent Renderingという新しいレンダリングモデルが導入されました。これには以下のような特徴があります

  • Automatic Batching: 複数の状態更新を一つのレンダリングにバッチ処理
  • Suspense: データ取得などの非同期処理の結果を待つ間の表示を定義
  • Transitions: 緊急でない状態更新を低優先度としてマーク

これらの機能は、レンダリングとイベントハンドラの分離をさらに強化し、より効率的なアプリケーションの構築を可能にします。

Automatic Batching の詳細

Automatic Batchingは、複数の状態更新を一度のレンダリングサイクルにまとめる機能です。これにより、不要な再レンダリングを減らしてパフォーマンスが向上します。

React 18以前では、Reactのイベントハンドラ内でのみバッチング処理が行われていましたが、React 18からはPromiseやsetTimeoutなどの非同期処理内でも自動的にバッチング処理が行われるようになりました。

// React 18でのAutomatic Batchingの動作

function UserProfile() {
  const [name, setName] = useState('');
  const [age, setAge] = useState(0);
  const [avatar, setAvatar] = useState('default.png');
  
  const [renderCount, setRenderCount] = useState(0);
  
  // 再レンダリングのたびにカウントアップ
  useEffect(() => {
    setRenderCount(prev => prev + 1);
  }, [name, age, avatar]);
  
  // React 17以前: 非同期処理内では別々にレンダリング(計3回)
  const updateProfileLegacy = () => {
    // 例えば非同期API呼び出し後の処理
    setTimeout(() => {
      setName('山田太郎');  // 1回目のレンダリングが発生
      setAge(30);          // 2回目のレンダリングが発生
      setAvatar('taro.png'); // 3回目のレンダリングが発生
    }, 100);
  };
  
  // React 18: 非同期処理内でも自動的にバッチング処理(1回のみレンダリング)
  const updateProfileReact18 = () => {
    // 例えば非同期API呼び出し後の処理
    setTimeout(() => {
      setName('鈴木花子');   // バッチング処理され
      setAge(25);          // バッチング処理され
      setAvatar('hanako.png'); // これらは1回のレンダリングにまとめられる
    }, 100);
  };
  
  // バッチングを意図的に回避する場合(どうしても即時反映させたい場合)
  const updateWithoutBatching = () => {
    setTimeout(() => {
      // flushSyncを使うとバッチングを回避できる
      ReactDOM.flushSync(() => {
        setName('佐藤次郎');  // 即時レンダリング発生
      });
      ReactDOM.flushSync(() => {
        setAge(40);          // 即時レンダリング発生
      });
      setAvatar('jiro.png');  // 通常のバッチング
    }, 100);
  };
  
  return (
    <div>
      <h1>ユーザープロファイル</h1>
      <p>名前: {name}</p>
      <p>年齢: {age}</p>
      <img src={avatar} alt="プロフィール画像" />
      <p>レンダリング回数: {renderCount}</p>
      
      <button onClick={updateProfileLegacy}>
        React 17スタイル更新(非同期・複数回レンダリング)
      </button>
      
      <button onClick={updateProfileReact18}>
        React 18スタイル更新(非同期・自動バッチング)
      </button>
      
      <button onClick={updateWithoutBatching}>
        バッチングを回避する更新
      </button>
    </div>
  );
}

Automatic Batchingのメリット

  1. パフォーマンスの向上

    • 複数の状態更新を1回のレンダリングにまとめることで、無駄な計算や DOM 操作を削減
    • 特に大規模なコンポーネントツリーでは大きな効果がある
  2. コードの一貫性

    • 同期処理も非同期処理も同じように動作するので、予測しやすく、デバッグしやすい
  3. より宣言的なコード

    • 「いつレンダリングされるか」ではなく「何を表示するか」に集中できる

注意点

  • flushSyncはパフォーマンスに悪影響を与える可能性があるため、本当に必要な場合にのみ使用すべきです
  • 複数の状態更新がまとめられることで、状態の依存関係が複雑な場合には予期しない動作を招くことがあります

2.5 useEffectと2種類のロジックの関係

useEffectは、Reactの第3のロジックタイプとも言えるもので、「副作用」を処理するためのフックです。

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  // レンダリングコード自体には副作用を含めず、useEffectで処理
  useEffect(() => {
    // 副作用(APIからデータ取得)
    fetchUser(userId).then(data => {
      setUser(data);
    });
  }, [userId]); // 依存配列

  // レンダリングロジック(純粋関数)
  return (
    <div>
      {user ? (
        <h1>{user.name}のプロフィール</h1>
      ) : (
        <p>ローディング中...</p>
      )}
    </div>
  );
}

useEffectは以下の場合に使用します

  1. 外部システムとの同期(APIリクエスト、サブスクリプションなど)
  2. ブラウザAPIの使用(タイトル変更、ローカルストレージなど)
  3. 非同期処理後の状態更新

useEffectは、レンダリングロジックの純粋性を保ちながら、必要な副作用を実行するための橋渡しとなります。

2.6 デバッグのヒント

Reactアプリケーションでのデバッグには以下のツールと技術が役立ちます

  1. React DevTools

    • コンポーネント階層の確認
    • propsとstateの値の確認
    • レンダリングの回数と理由の特定
  2. レンダリング問題のデバッグ

    • console.logを使用して、コンポーネントが再レンダリングされる回数を確認
    • React DevToolsの「Profiler」タブを使用してパフォーマンスをモニター
  3. イベントハンドラのデバッグ

    • console.logで値や実行フローを確認
    • ブレークポイントを設定して実行を一時停止
    • try/catchブロックでエラーを捕捉
function DebugExample() {
  const [count, setCount] = useState(0);
  
  console.log('Component rendered, count:', count); // レンダリングをトラック
  
  const handleClick = () => {
    console.log('Before update, count:', count); // 更新前の値
    setCount(count + 1);
    console.log('After update call, count:', count); // 注意: ここではまだ更新されていない
  };
  
  return (
    <button onClick={handleClick}>Increment: {count}</button>
  );
}

2.7 まとめ

  • レンダリングロジックとイベントハンドラの2つを理解することで、Reactコンポーネントの構造が明確になり、開発効率が上がります。
    • レンダリングロジックは、何を表示するかを決定(純粋関数であるべき)
    • イベントハンドラは、何をするかを決定(副作用を含むことができる)
  • これらのロジックが循環することで、Reactの宣言的なUIが実現される

3. ユーザーリストのコード例

以下は、ユーザーリストを表示し、ユーザーの追加と削除が可能な簡単な表を実装するコード例です。

3.1 小さなコンポーネントから始める

まずは、一つのユーザー行を表示するシンプルなコンポーネントから始めます

// 一人のユーザーを表示するシンプルなコンポーネント
function UserRow({ user, onDelete }) {
  return (
    <tr>
      <td>{user.id}</td>
      <td>{user.name}</td>
      <td>{user.email}</td>
      <td>
        <button onClick={() => onDelete(user.id)}>削除</button>
      </td>
    </tr>
  );
}

3.2 ユーザーリストコンポーネント

次に、ユーザーのリストを表示するコンポーネントを作成します

// ユーザーテーブルコンポーネント
function UserTable({ users, onDeleteUser }) {
  return (
    <table>
      <thead>
        <tr>
          <th>ID</th>
          <th>名前</th>
          <th>メールアドレス</th>
          <th>操作</th>
        </tr>
      </thead>
      <tbody>
        {users.map(user => (
          <UserRow 
            key={user.id}
            user={user}
            onDelete={onDeleteUser}
          />
        ))}
      </tbody>
    </table>
  );
}

3.3 ユーザー追加フォーム

ユーザーを追加するためのフォームコンポーネント

// ユーザー追加フォームコンポーネント
function UserForm({ onAddUser }) {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    onAddUser({ name, email });
    setName('');
    setEmail('');
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="名前"
        required
      />
      <input
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="メールアドレス"
        type="email"
        required
      />
      <button type="submit">追加</button>
    </form>
  );
}

3.4 メインのAppコンポーネント

最後に、これらのコンポーネントを組み合わせたメインコンポーネント

// TypeScriptの型定義(シンプルに保ちます)
// User型: ユーザー情報を表現
// { id: 数値, name: 文字列, email: 文字列 }

// メインのAppコンポーネント
function App() {
  const [users, setUsers] = useState([
    { id: 1, name: '山田太郎', email: 'taro@example.com' },
    { id: 2, name: '鈴木花子', email: 'hanako@example.com' },
  ]);

  // ユーザー追加のハンドラ
  const handleAddUser = useCallback((newUser) => {
    setUsers(prevUsers => {
      // 空配列の場合も考慮してIDを生成
      const newId = prevUsers.length > 0 
        ? Math.max(...prevUsers.map(u => u.id)) + 1
        : 1;
      
      return [...prevUsers, { ...newUser, id: newId }];
    });
  }, []);

  // ユーザー削除のハンドラ
  const handleDeleteUser = useCallback((id) => {
    setUsers(prevUsers => prevUsers.filter(user => user.id !== id));
  }, []);

  // useCallbackについての注釈
  // useCallbackは関数をメモ化(キャッシュ)します。
  // 主に2つの目的で使用されます:
  // 1. memoで最適化された子コンポーネントへ渡す関数の場合
  // 2. 依存配列に関数を含める場合(依存関係ループを防ぐ)

  return (
    <div>
      <h1>ユーザー管理</h1>
      <UserForm onAddUser={handleAddUser} />
      <UserTable users={users} onDeleteUser={handleDeleteUser} />
    </div>
  );
}

3.5 コードの解説

このコード例では、レンダリングコードとイベントハンドラを明確に分離しています。

  • レンダリングコード

    • UserRow, UserTable, UserForm, および App コンポーネントの return 文の中のJSX
    • これらは、現在の props と state に基づいてUIを描画する純粋な関数
  • イベントハンドラ

    • handleAddUser, handleDeleteUser, handleSubmit, および入力フィールドの onChange ハンドラ
    • これらは、ユーザーのアクションに応じて状態を更新する関数

この設計により、UIの描画ロジックとユーザーインタラクションの処理ロジックが分離され、コードの可読性と保守性が向上します。

4. まとめ

この記事では、Reactコンポーネント内の2種類のロジック(レンダリングコードとイベントハンドラ)について紹介しました。

レンダリングロジック イベントハンドラ
純粋関数である 副作用を持つことができる
同じ入力には同じ出力 状態を変更できる
副作用を持たない 非同期処理可能
予測可能 APIとの通信
表示内容を定義 動作を定義
  • これらの2種類のロジックの循環により、Reactの宣言的なUIパターンが実現されます。

  • React 18の新機能(Automatic Batching、Suspenseなど)は、このモデルをさらに強化します。

  • useEffectは、レンダリングの純粋性を保ちながら必要な副作用を実行するための橋渡しとなります。

レンダリングコードとイベントハンドラを適切に分離し、レンダリングの純粋性を維持することで、予測可能でパフォーマンスの高いReactアプリケーションを構築しましょう!

5. 参考資料

Discussion