こんにちは、かつコーチです。
前回まで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をいじると、どの表示に影響するのか」を把握するだけで半日近くかかってしまい、ようやく分割の必要性を痛感しました。
「まだ大丈夫」を疑うタイミング
このとき学んだのは、「まだ大丈夫」と思っているうちに手を打たないと、分割コストは雪だるま式に増えていくということです。
私は今、次のようなタイミングを「分割を検討するサイン」として意識しています。
- コンポーネントのスクロールバーが画面に対して長くなってきたと感じたとき
useStateやuseEffectが5個を超えてきたとき- 「このコンポーネント、結局何をするものだっけ」と自分でも一瞬迷ったとき
完璧な設計を最初から目指す必要はありませんが、機能を追加するたびに「これは今のコンポーネントに足すべきか、切り出すべきか」を一呼吸おいて考える習慣が、太らせないコンポーネント設計の一番の近道だと感じています。
まとめ
この記事のポイント
- コンポーネントが太る原因は、機能追加のたびに既存コンポーネントへ書き足し続けてしまうこと
- 「表示」と「ロジック」を分けるには、カスタムフックへの切り出しが有効
- 意味のあるまとまりで見た目のコンポーネントも分割し、名前を付けにくい部分は役割が混ざっているサイン
- propsが7〜8個を超えたら、関連する値をオブジェクトにまとめるか分割を検討する
- 「まだ大丈夫」と思っているうちに分割を先延ばしにすると、後からのコストが雪だるま式に増える
次に読むべき記事
次回は「Reactプロジェクトのディレクトリ構成のベストプラクティス」です。
コンポーネント単位の設計だけでなく、プロジェクト全体のファイル配置についても解説していきます。
→ 次の記事:Reactプロジェクトのディレクトリ構成のベストプラクティス