最新記事公開時にプッシュ通知します

Reactデザインパターン「Compound Components」実践ガイド。TypeScriptの型設計でPropsを意のままに操る

2025年10月15日

Reactデザインパターン「Compound Components」実践ガイド。TypeScriptの型設計でPropsを意のままに操る[レバテックLAB]

執筆

中川 幸哉

有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティ(代表 山田祥寛)に所属するテクニカルライター。新潟県上越市出身。会津大学コンピュータ理工学部卒業後、現在は新潟市に在住。ReactやAndroidを軸に、モバイルアプリ開発やWebサイト制作、Webメディア編集部の業務改善や、プログラミング技術記事の執筆等に携わっている。著書に「たった1日で基本が身に付く! Androidアプリ開発超入門」(技術評論社)、「基礎から学ぶ React Native入門」(翔泳社)他。

監修

山田 祥寛

静岡県榛原町生まれ。一橋大学経済学部卒業後、NECにてシステム企画業務に携わるが、2003年4月に念願かなってフリーライターに転身。Microsoft MVP for Visual Studio and Development Technologies。執筆コミュニティ「WINGSプロジェクト」代表。
主な著書に「独習」シリーズ、「これからはじめるReact実践入門」、「改訂3版 JavaScript本格入門」他、著書多数

はじめに:Propsはインターフェース設計である

「このボタンコンポーネントにどこまでのPropsを持たせるべきか?」

日々のコンポーネント開発で、このような判断に迷ったことはありませんか。onClick、disabled、loading、icon、variant、size…どこまで汎用化すべきか、どこで線引きすべきかという問題です。

前回まででコンポーネントの「構造」(分割と命名)と「配置」(フォルダ設計)を学んできました。今回から2回にわたって、コンポーネント間の「連携」、つまりPropsを通じたデータフローとインターフェース設計に焦点を当てます。

Propsはコンポーネント間の「契約」です。良いインターフェースは使いやすく、間違いを防ぎ、拡張可能である必要があります。

本稿では、複雑なUIを柔軟に構築するCompound Componentsパターンと、TypeScriptによる型安全なProps設計について解説します。これらの手法は密接に関連しており、Compound Componentsで実現する柔軟な構造を、TypeScriptの型システムによって安全に保つことで、保守性の高いコンポーネントアーキテクチャを構築できます。

Compound Componentsパターン:柔軟性と保守性の両立

複雑なUIコンポーネントを設計する際、すべての設定をPropsで渡すアプローチには限界があります。Compound Componentsパターンは、複数の小さなコンポーネントを組み合わせて一つの機能を実現する手法で、HTMLの<select>/<option>要素のように親子関係を持つコンポーネント群で構成されます。

このパターンの利点は、柔軟性と保守性の両立にあります。使用者が自由にレイアウトを決められる柔軟性を持ちながら、各部品の責務が明確で保守しやすい構造を実現できます。また、React.Contextによる暗黙的なProps共有により、不要なProps Drilling(Propsの階層を跨ぐバケツリレー状態)を避けることも可能です。

具体例として、タブ機能を実装してみましょう。まず素直なアプローチを確認し、その後Compound Componentsの利点を比較します。

素直なアプローチの課題

タブの定義をPropsで表現する場合、おそらく配列データとして定義するのが素直な方法でしょう。

interface TabsProps {
  tabs: Array<{ id: string; label: string; content: ReactNode }>;
  defaultTab: string;
}

しかし、この方法では、タブの構造が固定化され、レイアウトの柔軟性に欠けるという問題がありました。

Compound Componentsアプローチ:構造の自由度を提供

Compound Componentsでは、以下のような構造で同じ機能を実現します。

// 親子コンポーネント間でデータを共有するためのContext
const TabsContext = React.createContext(null);

// メインのTabsコンポーネント:アクティブタブの状態を管理
export function Tabs({ children, defaultTab, onTabChange }) {
  // どのタブがアクティブかを管理する状態
  const [activeTab, setActiveTab] = useState(defaultTab);

  return (
    // Contextで子コンポーネントにデータを提供
    <TabsContext.Provider value={{ activeTab, onTabChange: setActiveTab }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

// タブボタンコンポーネント:クリック時の動作とスタイルを制御
export function Tab({ id, children }) {
  // 親から共有されたデータを取得
  const { activeTab, onTabChange } = useContext(TabsContext);
  const isActive = activeTab === id;

  return (
    <button
      className={`tab ${isActive ? 'active' : ''}`}
      onClick={() => onTabChange(id)} // タブ切り替えを実行
    >
      {children}
    </button>
  );
}

// タブパネルコンポーネント:アクティブタブのみ表示
export function TabPanel({ id, children }) {
  const { activeTab } = useContext(TabsContext);
  // アクティブタブの場合のみ内容を表示
  return activeTab === id ? <div className="tab-panel">{children}</div> : null;
}

このパターンの構造とデータフローを図で確認しましょう。

▲Compound Componentsパターンの構造

使用例では、各コンポーネントが自然な構造で組み合わされます。

<Tabs defaultTab="profile">
  <Tab id="profile">プロフィール</Tab>
  <Tab id="settings">設定</Tab>
  <TabPanel id="profile"><ProfileForm /></TabPanel>
  <TabPanel id="settings"><SettingsForm /></TabPanel>
</Tabs>

 

実際の動作例では、以下のようにタブが切り替わり、各コンポーネントが連動して動作します。

▲Tabsコンポーネントの動作例

タブの順序やレイアウトを使用側で自由に決められる一方で、状態管理はTabs親コンポーネントが一元的に処理します。図からも分かるように、「現在のアクティブタブ」が動的に変更され、タブボタンのスタイルとコンテンツ表示が自動的に連動しています。このパターンは、小さい部品に分けることで各コンポーネントの責務が明確になり、単体でのテストが容易になります。また、後から新しい要素(例:TabIcon、TabBadge)を追加しやすく、既存コードに影響を与えずに機能拡張が可能な高い拡張性も実現できます。

TypeScriptによる型安全なインターフェース設計

適切な型設計は、コンポーネントの使い方を明確にし、誤用を防ぎ、IDEのサポートを最大限に活用できます。TypeScriptの型システムを活用することで、Props設計における様々な課題を解決できます。

HTMLAttributes継承による基本的な型定義

多くのコンポーネントは、標準HTML要素の機能を拡張します。HTML要素の既存属性を継承しつつ、独自のPropsを追加する設計が効果的です。

// HTML標準のbutton要素の属性を継承しつつ、独自Propsを追加
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'sm' | 'md' | 'lg';
  loading?: boolean;
}

// 実装例:標準属性(onClick、disabled等)が自動的に利用可能
const Button: React.FC<ButtonProps> = ({ variant, size, loading, ...htmlProps }) =>
  <button {...htmlProps} disabled={loading} className={`btn btn-${variant} btn-${size}`} />;

この設計により、onClick、disabled、typeなどのHTML標準属性が自動的に利用可能になり、同時に独自のvariantやsizeといった拡張機能も型安全に提供できます。

Generic型による汎用コンポーネント設計

再利用性の高いコンポーネントでは、Generic型を活用して型安全性を保ちながら柔軟性を実現できます。

// 任意のデータ型に対応できるリストコンポーネント
interface ListProps<T> {
  items: T[];
  renderItem: (item: T) => ReactNode;
}

// Generic型により、Tの型が自動推論される
const List = <T,>({ items, renderItem }: ListProps<T>) => (
  <>{items.map((item) => renderItem(item))}</>
);

// 使用時:User型が自動的に推論され、item.nameが型安全に利用可能
// <List items={users} renderItem={(user) => <span>{user.name}</span>} />

Union型による選択肢の制約

Props で受け入れる値を特定の選択肢に制限したい場合、Union型が効果的です。

// ステータス表示コンポーネント:状態を限定された値に制約
interface StatusProps {
  status: 'loading' | 'success' | 'error' | 'idle';
  message?: string;
}

// Union型により型システムがswitch文の網羅性をチェック
// 全ての状態を処理していればdefault case不要
const Status: React.FC<StatusProps> = ({ status, message }) => {
  // switch (status) { ... } で全状態の処理が強制される
  return <div className={`status status--${status}`}>{message}</div>;
};

まとめ

前編では、Compound ComponentsパターンとTypeScriptによる型安全なProps設計について学びました。これらの手法は、コンポーネントの柔軟性と保守性を両立し、開発効率を大幅に向上させます。

次回(後編)では、パフォーマンスを考慮したProps設計、useMemo・useCallbackを活用した最適化手法、そして避けるべきアンチパターンについて詳しく解説します。

関連記事

人気記事

  • コピーしました

RSS
RSS