【React】太らせないコンポーネント設計の考え方

JavaScript

こんにちは、かつコーチです。

前回まで4回にわたって「エラー解決編」をお届けしてきました。

今回からは「設計・アーキテクチャ編」に入ります。

1本目のテーマは、Reactを書いていると誰もが一度は経験する「気づいたら1つのコンポーネントが数百行になっていた」問題です。

コンポーネントが太ってしまう原因と、太らせないための設計の考え方を整理していきましょう。

コンポーネントが太る原因

「とりあえずここに書く」の積み重ね

コンポーネントが肥大化する一番の原因は、機能追加のたびに「とりあえず今あるコンポーネントに書き足す」を繰り返してしまうことです。

最初は50行程度のシンプルなフォームだったものが、バリデーション、API通信、モーダル表示、確認ダイアログ……と機能が増えるたびに追記され、気づけば500行を超えている、というのはよくある話です。

1つのコンポーネントが「表示」「状態管理」「API通信」「バリデーション」など複数の役割を同時に担ってしまっている状態を、単一責任の原則に反していると表現します。

太ったコンポーネントの何が問題か

コンポーネントが太ると、次のような問題が起きやすくなります。

  • どこを直すと何に影響するのか把握しづらくなる
  • 同じロジックを別の画面で使い回したくても、切り出しづらい
  • テストを書こうとしても、テスト対象の範囲が広すぎて書きにくい
  • レビューする側も、変更の影響範囲を追うのに時間がかかる

「動いているから今は問題ない」状態でも、変更のたびに時間がかかるようになり、じわじわとチーム全体の開発速度を下げていきます。

太らせないための3つの分割軸

分割軸1:表示とロジックを分ける(カスタムフック)

まず意識したいのが、「画面に何を表示するか」と「どういうデータ・ロジックで動いているか」を分けることです。

Reactでは、ロジック部分をカスタムフックとして切り出すことで、この分離を実現できます。

❌ Before:表示とロジックが1つのコンポーネントに同居している

import { useState, useEffect } from "react";

function UserProfileCard({ userId }: { userId: string }) {
  const [user, setUser] = useState<{ name: string; email: string } | null>(null);
  const [isEditing, setIsEditing] = useState(false);
  const [draftName, setDraftName] = useState("");

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => {
        setUser(data);
        setDraftName(data.name);
      });
  }, [userId]);

  const handleSave = async () => {
    const res = await fetch(`/api/users/${userId}`, {
      method: "PUT",
      body: JSON.stringify({ name: draftName }),
    });
    const updated = await res.json();
    setUser(updated);
    setIsEditing(false);
  };

  if (!user) return <p>読み込み中...</p>;

  return (
    <div>
      {isEditing ? (
        <div>
          <input value={draftName} onChange={(e) => setDraftName(e.target.value)} />
          <button onClick={handleSave}>保存</button>
        </div>
      ) : (
        <div>
          <p>{user.name}</p>
          <p>{user.email}</p>
          <button onClick={() => setIsEditing(true)}>編集</button>
        </div>
      )}
    </div>
  );
}

このコンポーネントは、「データ取得」「編集状態の管理」「保存処理」「表示の出し分け」をすべて1つの関数の中で担っています。

✅ After:ロジックをカスタムフックに切り出す

import { useState, useEffect } from "react";

type User = { name: string; email: string };

function useUserProfile(userId: string) {
  const [user, setUser] = useState<User | null>(null);
  const [isEditing, setIsEditing] = useState(false);
  const [draftName, setDraftName] = useState("");

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data: User) => {
        setUser(data);
        setDraftName(data.name);
      });
  }, [userId]);

  const save = async () => {
    const res = await fetch(`/api/users/${userId}`, {
      method: "PUT",
      body: JSON.stringify({ name: draftName }),
    });
    const updated: User = await res.json();
    setUser(updated);
    setIsEditing(false);
  };

  return { user, isEditing, draftName, setDraftName, setIsEditing, save };
}

function UserProfileCard({ userId }: { userId: string }) {
  const { user, isEditing, draftName, setDraftName, setIsEditing, save } = useUserProfile(userId);

  if (!user) return <p>読み込み中...</p>;

  return isEditing ? (
    <div>
      <input value={draftName} onChange={(e) => setDraftName(e.target.value)} />
      <button onClick={save}>保存</button>
    </div>
  ) : (
    <div>
      <p>{user.name}</p>
      <p>{user.email}</p>
      <button onClick={() => setIsEditing(true)}>編集</button>
    </div>
  );
}

useUserProfileにロジックをまとめたことで、UserProfileCardは「受け取った値をどう表示するか」だけに集中できるようになりました。

このロジックが他の画面でも必要になった場合も、useUserProfileをそのまま呼び出すだけで再利用できます。

分割軸2:意味のある単位でコンポーネントを切る

見た目の上でも、「1つの意味的なまとまり」を1つのコンポーネントとして切り出すことを意識します。

先ほどの例であれば、「編集フォーム部分」と「表示部分」をそれぞれ別コンポーネントに分けることもできます。

function UserProfileView({ user, onEdit }: { user: User; onEdit: () => void }) {
  return (
    <div>
      <p>{user.name}</p>
      <p>{user.email}</p>
      <button onClick={onEdit}>編集</button>
    </div>
  );
}

function UserProfileEditForm({
  draftName,
  onChangeName,
  onSave,
}: {
  draftName: string;
  onChangeName: (value: string) => void;
  onSave: () => void;
}) {
  return (
    <div>
      <input value={draftName} onChange={(e) => onChangeName(e.target.value)} />
      <button onClick={onSave}>保存</button>
    </div>
  );
}

判断基準としては、「このかたまりに名前をつけるとしたら、意味の通る名前がつけられるか」を目安にするとわかりやすいです。

「名前が付けにくい」「何をするコンポーネントか一言で説明しづらい」場合は、複数の役割が混ざっているサインです。

分割軸3:Propsの数が増えすぎたら見直す

コンポーネントに渡すpropsの数が多くなってきたことも、太りすぎのサインの1つです。

目安として、propsが7〜8個を超えてきたら、関連するpropsをオブジェクトとしてまとめられないか、あるいはコンポーネント自体を分割できないかを検討します。

// propsが多すぎるサイン
function OrderCard({
  orderId,
  customerName,
  customerEmail,
  itemName,
  itemPrice,
  itemQuantity,
  status,
  onCancel,
}: OrderCardProps) {
  // ...
}

// customer / itemをまとまりのある型として渡す
type Customer = { name: string; email: string };
type OrderItem = { name: string; price: number; quantity: number };

function OrderCard({
  orderId,
  customer,
  item,
  status,
  onCancel,
}: {
  orderId: string;
  customer: Customer;
  item: OrderItem;
  status: string;
  onCancel: () => void;
}) {
  // ...
}

関連する情報をオブジェクトとしてまとめることで、propsの数が減るだけでなく、「顧客情報」「商品情報」という意味のまとまりがコードにも表れます。

かつコーチが実際につまずいた「500行コンポーネント」

分割を先延ばしにし続けた結果

私が実際に痛い目を見たのは、あるフォーム画面のコンポーネントです。

最初は入力項目が5個程度のシンプルなフォームでしたが、「バリデーションを追加」「確認画面を追加」「下書き保存機能を追加」と機能が増えるたびに、既存のコンポーネントにコードを足し続けてしまいました。

半年後には、useStateが15個、useEffectが6個並ぶ、500行を超えるコンポーネントになっていました。

そこに新しい仕様変更が入ったとき、「このuseStateをいじると、どの表示に影響するのか」を把握するだけで半日近くかかってしまい、ようやく分割の必要性を痛感しました。

「まだ大丈夫」を疑うタイミング

このとき学んだのは、「まだ大丈夫」と思っているうちに手を打たないと、分割コストは雪だるま式に増えていくということです。

私は今、次のようなタイミングを「分割を検討するサイン」として意識しています。

  • コンポーネントのスクロールバーが画面に対して長くなってきたと感じたとき
  • useStateuseEffectが5個を超えてきたとき
  • 「このコンポーネント、結局何をするものだっけ」と自分でも一瞬迷ったとき

完璧な設計を最初から目指す必要はありませんが、機能を追加するたびに「これは今のコンポーネントに足すべきか、切り出すべきか」を一呼吸おいて考える習慣が、太らせないコンポーネント設計の一番の近道だと感じています。

まとめ

この記事のポイント

  • コンポーネントが太る原因は、機能追加のたびに既存コンポーネントへ書き足し続けてしまうこと
  • 「表示」と「ロジック」を分けるには、カスタムフックへの切り出しが有効
  • 意味のあるまとまりで見た目のコンポーネントも分割し、名前を付けにくい部分は役割が混ざっているサイン
  • propsが7〜8個を超えたら、関連する値をオブジェクトにまとめるか分割を検討する
  • 「まだ大丈夫」と思っているうちに分割を先延ばしにすると、後からのコストが雪だるま式に増える

次に読むべき記事

次回は「Reactプロジェクトのディレクトリ構成のベストプラクティス」です。

コンポーネント単位の設計だけでなく、プロジェクト全体のファイル配置についても解説していきます。

→ 次の記事:Reactプロジェクトのディレクトリ構成のベストプラクティス

タイトルとURLをコピーしました