「見た目が同じ」でコンポーネントを共通化して後悔した4つのパターン

開発

「このコンポーネント、デザイン同じだから共通化しよう」。この判断が地獄の始まりだった。

見た目が似ているコンポーネントを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型が崩壊し始めます。monospacesyntaxHighlightonCtrlEnterは「AIプロンプト」でしか使いません。でも型定義には全部optional propsとして並びます。

3つ目のユースケースが来た時点で「あ、これ違うやつだ」って気づくべきだった。
筆者

正しい分離

// ✅ 共通なのはラベル部分だけ → そこだけ共通化
<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分岐。確認ダイアログには不要なformFieldsvalidationSchemaisSubmittingが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で分岐、badgeratingonAddToCartisOnlineonFollow…。全部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のようなレイアウトプリミティブを用意すればいい。「画像 + タイトル + 説明文」は見た目の共通点であって、振る舞いの共通点ではありません。

shadcn/uiに学ぶReactのprops地獄から抜け出すCompound Componentパターン開発

パターン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のラベル → 「必須バッジの色を赤にする」で全フィールドに波及。同じ理由で変わる → 共通化OK
  • LabeledTextareaの中身 → 「AIプロンプトにシンタックスハイライト追加」は備考欄に波及しない。別の理由で変わる → 共通化NG
  • DialogShellの枠 → 「ダイアログの角丸を変える」で全ダイアログに波及。同じ理由で変わる → 共通化OK
  • AppDialogの中身 → 「フォームにバリデーション追加」は確認ダイアログに波及しない。別の理由で変わる → 共通化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
};

monospacesyntaxHighlightonCtrlEnterは「AIプロンプト」という特定のユースケースのために存在しています。こういう「自分以外の誰かのためのprops」が生えたら、抽象化の境界を間違えているサイン。childrenで外に押し出すべき部分を、propsで内側に引き込んでしまっています。

shadcn/uiのコンポーネントがまさにこの設計思想です。Dialogはシェルだけ、Commandは入力欄だけ、Cardはレイアウトだけ。振る舞いは全部利用側がcompositionで組み立てる。こういうUIプリミティブが揃っていると、「間違った共通化」をそもそもやらなくなります。

フロントエンドにクリーンアーキテクチャは必要か?判断基準とWeb/モバイルの違い開発

まとめ

4つのパターンに共通する教訓は1つ。見た目の一致は、抽象化の根拠にならない

パターン見た目の共通点振る舞いの違い正しい共通化の粒度
フォームフィールドタイトル + バッジ + 入力欄入力の種類が違うFormField(ラベル部分のみ)
ダイアログタイトル + 本文 + ボタン確認とフォーム送信DialogShell(枠だけ)
カード画像 + タイトル + 説明クリック時の挙動、メタ情報CardLayout or 独立コンポーネント
検索UI入力欄 + アイコンデバウンス、サジェスト、キーバインドInputWithIcon(見た目だけ)

共通化すべきは「振る舞い」であって「見た目」ではありません。見た目が同じなら、UIプリミティブ(FormFieldDialogShellInputWithIcon)を抽出すれば十分です。振る舞いは、利用側がそれぞれ責任を持ちます。

間違った抽象化を見つけたら、Sandi Metzの処方箋に従いましょう。「最も速い前進は、後退すること」。共通コンポーネントをインライン化し、不要なコードパスを削除し、本当に共通な部分だけを再抽出します。

状態の組み合わせを型で制限する|React props設計開発
Thanks for reading!
Reactコンポーネント設計TypeScriptフロントエンド設計リファクタリング