「このコンポーネント、デザイン同じだから共通化しよう」。この判断が地獄の始まりだった。
見た目が似ているコンポーネントを1つにまとめると、最初は気持ちいい。DRYを守っている感覚があります。でも要件が変わるたびにpropsが増え、内部でif分岐が増え、最終的に誰も触りたくないコンポーネントが出来上がります。
Sandi Metzの言葉を借りれば、「重複は、間違った抽象化よりはるかに安い」。Alex Kondovも「同じように見えるコードが、同じように変化するとは限らない」と指摘しています。
実務で踏んだ4つのパターンを紹介します。どれも「見た目が同じ」という理由だけで共通化して、後から痛い目を見たもの。
パターン1: フォームフィールド
タイトル、必須バッジ、テキストエリア。この3点セットが画面のあちこちに登場します。「全部同じ構造だし、共通コンポーネントにしよう」と考えるのは自然な流れ。
やりがちな実装
// ❌ 見た目が同じだからまるごと共通化
<LabeledTextarea
title="プロフィール"
required
value={bio}
onChange={setBio}
maxLength={200}
showCounter
autoResize
/>
<LabeledTextarea
title="備考"
required={false}
value={note}
onChange={setNote}
rows={3}
placeholder="任意入力"
/>
<LabeledTextarea
title="AIプロンプト"
required
value={prompt}
onChange={setPrompt}
maxLength={2000}
showCounter
autoResize
monospace // ここだけ等幅フォント
syntaxHighlight // ここだけハイライト
onCtrlEnter={run} // ここだけショートカット
/>
3つ目が追加された瞬間、LabeledTextareaのprops型が崩壊し始めます。monospace、syntaxHighlight、onCtrlEnterは「AIプロンプト」でしか使いません。でも型定義には全部optional propsとして並びます。
正しい分離
// ✅ 共通なのはラベル部分だけ → そこだけ共通化
<FormField title="プロフィール" required>
<Textarea value={bio} onChange={setBio} maxLength={200} showCounter autoResize />
</FormField>
<FormField title="備考">
<Textarea value={note} onChange={setNote} rows={3} placeholder="任意入力" />
</FormField>
<FormField title="AIプロンプト" required>
<CodeEditor value={prompt} onChange={setPrompt} onCtrlEnter={run} />
</FormField>
「タイトル + 必須バッジ」というUIの共通点だけをFormFieldで抽出します。中身は利用側が直接配置します。3つ目はTextareaですらなくCodeEditorだった。見た目の一致に引っ張られて、振る舞いの違いを無視していました。
パターン2: 確認ダイアログ vs フォームダイアログ
「タイトル + 本文 + ボタン」。ダイアログの基本構造はどれも同じに見えます。
やりがちな実装
// ❌ ダイアログは全部同じ構造だから...
type DialogMode = 'confirm' | 'form' | 'info';
<AppDialog
mode="confirm"
title="削除しますか?"
message="この操作は取り消せません"
onConfirm={handleDelete}
onCancel={close}
/>
<AppDialog
mode="form"
title="プロフィール編集"
onSubmit={handleSubmit}
onCancel={close}
formFields={[
{ name: 'displayName', label: '表示名', required: true },
{ name: 'bio', label: '自己紹介' },
]}
initialValues={user}
validationSchema={schema} // confirm には不要
isSubmitting={isPending} // confirm には不要
/>
modeによって内部でif分岐。確認ダイアログには不要なformFields、validationSchema、isSubmittingがoptional propsとして型に存在します。半年後にはmode: 'wizard'が追加されて、もはや手がつけられなくなります。
正しい分離
// ✅ 共通のシェルだけ共通化、中身は別コンポーネント
<DialogShell title="削除しますか?" onClose={close}>
<p>この操作は取り消せません</p>
<DialogActions>
<Button variant="ghost" onClick={close}>キャンセル</Button>
<Button variant="danger" onClick={handleDelete}>削除</Button>
</DialogActions>
</DialogShell>
<DialogShell title="プロフィール編集" onClose={close}>
<ProfileForm
initialValues={user}
onSubmit={handleSubmit}
onCancel={close}
/>
</DialogShell>
DialogShellはオーバーレイ、タイトル、閉じるボタンだけを担当します。確認の振る舞いとフォームの振る舞いは完全に別のコンポーネントです。
Kent C. Doddsが広めたAHA(Avoid Hasty Abstractions)の考え方そのもの。「2回見たから共通化しよう」ではなく、振る舞いが本当に同じかどうかを確認してから。
Props-based vs Ownership Model|コンポーネントライブラリ設計思想の選び方開発パターン3: カード(記事 / 商品 / ユーザー)
「画像 + タイトル + 説明文」。カードのレイアウトが同じだから共通化する。これが最も多く見かけるパターンかもしれません。
やりがちな実装
// ❌ レイアウトが同じだから1つのCardコンポーネントに
<Card
type="article"
image={article.thumbnail}
title={article.title}
description={article.excerpt}
meta={article.publishedAt}
onClick={() => router.push(`/articles/${article.slug}`)}
/>
<Card
type="product"
image={product.image}
title={product.name}
description={product.description}
meta={`¥${product.price.toLocaleString()}`}
badge={product.isNew ? '新着' : undefined} // article には不要
rating={product.rating} // article には不要
onAddToCart={() => addToCart(product.id)} // article には不要
onClick={() => router.push(`/products/${product.id}`)}
/>
<Card
type="user"
image={user.avatar}
title={user.displayName}
description={user.bio}
meta={`${user.followerCount} フォロワー`}
isOnline={user.isOnline} // 他には不要
onFollow={() => follow(user.id)} // 他には不要
onClick={() => router.push(`/users/${user.id}`)}
/>
typeで分岐、badge、rating、onAddToCart、isOnline、onFollow…。全部optional。Card内部はif (type === 'product')の嵐。
Dan Abramovが「Goodbye, Clean Code」で書いた通り。「コードの見た目にばかり気を取られて、コードがどう変化するかを考えていなかった」。記事カード、商品カード、ユーザーカードの要件は別々に変化します。商品カードにセール表示が追加されても、記事カードには関係ありません。
正しい分離
// ✅ レイアウトの骨格だけ共通化
<CardLayout>
<CardImage src={article.thumbnail} alt={article.title} />
<CardBody>
<CardTitle>{article.title}</CardTitle>
<CardDescription>{article.excerpt}</CardDescription>
<CardMeta>{article.publishedAt}</CardMeta>
</CardBody>
</CardLayout>
もしくは、もっとシンプルに。
// ✅ 各ドメインで独立したコンポーネントを作る
function ArticleCard({ article }: { article: Article }) {
return (
<Link href={`/articles/${article.slug}`} className="flex gap-4 p-4 ...">
<img src={article.thumbnail} alt={article.title} className="w-24 h-24 ..." />
<div>
<h3 className="font-bold">{article.title}</h3>
<p className="text-sm text-gray-600">{article.excerpt}</p>
<time className="text-xs text-gray-400">{article.publishedAt}</time>
</div>
</Link>
);
}
function ProductCard({ product }: { product: Product }) {
return (
<div className="flex gap-4 p-4 ...">
<img src={product.image} alt={product.name} className="w-24 h-24 ..." />
<div>
<h3 className="font-bold">{product.name}</h3>
<p className="text-sm text-gray-600">{product.description}</p>
<span className="font-bold text-lg">¥{product.price.toLocaleString()}</span>
<Button onClick={() => addToCart(product.id)}>カートに追加</Button>
</div>
</div>
);
}
Tailwindのクラスが多少重複しても、それぞれが独立して変更できる方が価値があります。共通のスタイルが気になるなら、CardLayoutのようなレイアウトプリミティブを用意すればいい。「画像 + タイトル + 説明文」は見た目の共通点であって、振る舞いの共通点ではありません。
パターン4: 検索UI(検索 / フィルタ / コマンドパレット)
入力欄 + アイコン。見た目はほぼ同じ。でも振る舞いは全く違います。
やりがちな実装
// ❌ 入力欄+アイコンだから同じコンポーネントで
<SearchInput
mode="search"
value={query}
onChange={setQuery}
onSearch={handleSearch}
debounceMs={300}
showSuggestions
suggestions={suggestions}
onSelectSuggestion={handleSelect}
/>
<SearchInput
mode="filter"
value={filter}
onChange={setFilter}
// debounce不要、即時フィルタリング
// suggestions不要
clearable
/>
<SearchInput
mode="command"
value={command}
onChange={setCommand}
onKeyDown={handleKeyDown} // Enter で実行
// debounce不要
// suggestions ではなく commands
commands={availableCommands}
onSelectCommand={executeCommand}
shortcutKey="cmd+k" // 他には不要
/>
modeで分岐するたびに、コンポーネント内部のイベントハンドリングが複雑になります。検索はデバウンス付き、フィルタは即時反映、コマンドパレットはキーボードショートカットで開く。同じonChangeでも裏で起きていることが全然違います。
正しい分離
// ✅ 入力欄の見た目だけ共通化
function InputWithIcon({ icon, ...inputProps }: InputWithIconProps) {
return (
<div className="relative">
<Icon name={icon} className="absolute left-3 top-1/2 -translate-y-1/2 ..." />
<input {...inputProps} className="pl-10 ..." />
</div>
);
}
// 検索: デバウンス + サジェスト
function SearchBox({ onSearch }: SearchBoxProps) {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
// ...サジェストのロジック
return (
<div>
<InputWithIcon icon="lucide:search" value={query} onChange={e => setQuery(e.target.value)} />
{suggestions.length > 0 && <SuggestionList items={suggestions} />}
</div>
);
}
// フィルタ: 即時反映 + クリアボタン
function FilterInput({ value, onChange, onClear }: FilterInputProps) {
return (
<div>
<InputWithIcon icon="lucide:filter" value={value} onChange={onChange} />
{value && <button onClick={onClear}>×</button>}
</div>
);
}
InputWithIconは「アイコン付きの入力欄」というUIプリミティブだけを担当します。検索のデバウンス、フィルタの即時反映、コマンドパレットのキーバインドは、それぞれ別のコンポーネントが責任を持ちます。
なぜ「見た目の一致」で判断してしまうのか
ここまで4パターンを見てきたけど、なぜこの間違いを繰り返すのか。理由は3つあります。
まず、DRYが美徳として刷り込まれています。重複を見つけると「まとめなきゃ」という衝動が走ります。でもSandi Metzが指摘するように、間違った抽象化にハマると「既存コードが強い引力を持ち」、誰もインライン化に戻す判断ができなくなります。条件分岐を足す方が「安全」に見えるから。
もう1つ、デザインカンプが同じ見た目を強調すること。Figmaのコンポーネント一覧を見ると、同じスタイルのカードが並んでいます。デザイナーがスタイルを揃えるのは正しいけど、「スタイルが同じ = 実装も同じ」とは限りません。
そして、最初は本当に同じに見えます。1つ目と2つ目のユースケースでは違いがありません。3つ目が来たときに初めて「あれ、これ違うやつだ」と気づきます。Kent C. Doddsの言葉を借りれば、「変化に最適化せよ」。今同じに見えることより、将来どう変わるかが重要です。
判断基準: いつ共通化すべきか
全てを分離すべきという話ではありません。共通化すべきケースもあります。
同じ見た目のコンポーネントが複数ある
│
├─ 振る舞い(イベント、状態、データフロー)も同じ?
│ │
│ ├─ Yes → 共通化して OK
│ │
│ └─ No → 見た目のどこが共通?
│ │
│ ├─ レイアウト構造 → レイアウトコンポーネントを抽出
│ │ (CardLayout, DialogShell, FormField)
│ │
│ └─ 個別の要素 → UIプリミティブを抽出
│ (InputWithIcon, Badge, Avatar)
│
└─ 振る舞いごとに別コンポーネントを作り、
共通UIプリミティブを内部で使う
ポイントは共通化の粒度。「見た目がまるごと同じ」を1コンポーネントにまとめるのではなく、「見た目のどの部分が共通か」を分解します。
でも「振る舞いが同じかどうか」はどう判断すればいいのか。フローチャートだけだと抽象的なので、2つの具体的なテストを紹介します。
テスト1: 変更理由テスト
「片方だけ仕様変更が来たら、もう片方も変わるか?」
この質問に「いいえ」と答えたら、振る舞いは別です。
FormFieldのラベル → 「必須バッジの色を赤にする」で全フィールドに波及。同じ理由で変わる → 共通化OKLabeledTextareaの中身 → 「AIプロンプトにシンタックスハイライト追加」は備考欄に波及しない。別の理由で変わる → 共通化NGDialogShellの枠 → 「ダイアログの角丸を変える」で全ダイアログに波及。同じ理由で変わる → 共通化OKAppDialogの中身 → 「フォームにバリデーション追加」は確認ダイアログに波及しない。別の理由で変わる → 共通化NG
UI的な共通性はデザインシステムの変更で一緒に変わります。振る舞い的な共通性はビジネス要件の変更で一緒に変わります。「見た目が同じ」は前者でしかありません。
テスト2: propsの責務チェック
正しい抽象化のコンポーネントには、自分以外のもののためのpropsが存在しません。
// ✅ propsが自分の責務だけ
type FormFieldProps = {
title: string; // ラベルの表示 → 自分の責務
required?: boolean; // 必須バッジの表示 → 自分の責務
children: ReactNode; // 中身は知らない → 利用側に委ねる
};
// ❌ 特定の中身のためのpropsが混在
type LabeledTextareaProps = {
title: string; // 自分の責務
required?: boolean; // 自分の責務
monospace?: boolean; // ← AIプロンプトのためのprops
syntaxHighlight?: boolean; // ← AIプロンプトのためのprops
onCtrlEnter?: () => void; // ← AIプロンプトのためのprops
};
monospace、syntaxHighlight、onCtrlEnterは「AIプロンプト」という特定のユースケースのために存在しています。こういう「自分以外の誰かのためのprops」が生えたら、抽象化の境界を間違えているサイン。childrenで外に押し出すべき部分を、propsで内側に引き込んでしまっています。
shadcn/uiのコンポーネントがまさにこの設計思想です。Dialogはシェルだけ、Commandは入力欄だけ、Cardはレイアウトだけ。振る舞いは全部利用側がcompositionで組み立てる。こういうUIプリミティブが揃っていると、「間違った共通化」をそもそもやらなくなります。
まとめ
4つのパターンに共通する教訓は1つ。見た目の一致は、抽象化の根拠にならない。
| パターン | 見た目の共通点 | 振る舞いの違い | 正しい共通化の粒度 |
|---|---|---|---|
| フォームフィールド | タイトル + バッジ + 入力欄 | 入力の種類が違う | FormField(ラベル部分のみ) |
| ダイアログ | タイトル + 本文 + ボタン | 確認とフォーム送信 | DialogShell(枠だけ) |
| カード | 画像 + タイトル + 説明 | クリック時の挙動、メタ情報 | CardLayout or 独立コンポーネント |
| 検索UI | 入力欄 + アイコン | デバウンス、サジェスト、キーバインド | InputWithIcon(見た目だけ) |
共通化すべきは「振る舞い」であって「見た目」ではありません。見た目が同じなら、UIプリミティブ(FormField、DialogShell、InputWithIcon)を抽出すれば十分です。振る舞いは、利用側がそれぞれ責任を持ちます。
間違った抽象化を見つけたら、Sandi Metzの処方箋に従いましょう。「最も速い前進は、後退すること」。共通コンポーネントをインライン化し、不要なコードパスを削除し、本当に共通な部分だけを再抽出します。
状態の組み合わせを型で制限する|React props設計開発