1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【React】Compound Componentsとは?実装例で理解するコンポーネント設計

1
Posted at

再利用可能なコンポーネント vs 複合可能なコンポーネント

Reactでコンポーネントを実装する際に、再利用しやすいように作ると、良いコンポーネントに見えるし、使いやすくなります。
しかし、「再利用性」を意識しすぎてコンポーネントを実装した結果、以下のようにpropsが多くなってしまった経験はないでしょうか?

<MyCard
  profileImage={....}
  profileShape="circle"
  link="https://www......"
  title="カード"
  description="カードに関する説明"
  size="lg"
  color="skyblue"
  showSnsButton={true}
  showFooter={false}
  layout="vertical"
/>

このようなコンポーネントは、最初のうちは手軽に実装できますが、仕様の追加や変更が重なるにつれて、徐々にメンテナンスしづらくなっていきます。
「再利用性」より「複合性」に目線を向けてみたらどうでしょうか。

実装のイメージは以下の通り

<MyCard>
  <MyCard.Profile img={...} />
  <MyCard.Body />
  <MyCard.Footer />
</MyCard>

こういう書き方をCompound Componentsパターンと言います。

Compound Componentsパターンとは

Compound Componentsパターンとは、複数のコンポーネントを組み合わせて一つの機能として動作させるReactのパターンです。

メリット

1. 関連するコンポーネントをまとめられる

MyCard.HeaderやMyCard.Bodyのように書けるため、そのコンポーネントが何に属しているのかが分かりやすくなります。

2. 拡張しやすい

例えば、<MyCard>コンポーネントにDeleteボタンを追加する仕様が追加されても拡張しやすいです。

<MyCard>
  <MyCard.Profile img={...} />
  <MyCard.Body />
  <MyCard.Footer />
  <MyCard.DeleteButton />
</MyCard>

3. Contextで状態の共有ができる

Tabs機能を実装することを仮定しましょう。まず、Tabsコンポーネントの仕様は以下のとおりです。

1. 複数のTabボタンが存在する。
2. Tabボタンをクリックすると、該当のTabが有効化され、対応する内容が表示される。

この場合、Tabボタンが有効化なのか状態を管理する必要があります。通常であれば、この状態は親で管理してpropsとして各子に渡す必要がありますが、子の数が増えるほどprops経由のやり取りが煩雑になります。(props drilling)
しかし、Tabsコンポーネントの中でContextを使えば、階層を気にせずどこからでも状態にアクセスできるため、楽に状態管理ができます。

Compound Componentsバターンが向いているケース

  • 複数の部品で構成される UI
  • 親と子が状態を共有する UI
  • Tabs、Accordion、Modal、Card など

例1)カードコンポーネント(状態管理なしのパターン)

スクリーンショット 2026-07-01 23.04.55.png

このようなユーザーカードを実装してみましょう。

<UserCard 
  profileImg={画像ファイル}
  title="ワンちゃん"
  description="こんにちは。お肉が大好きです"
  snsLists={[
   {snsName:'instagram', snsLink: "https://insta....."},
   {snsName: 'x', snsLink: "https://x....."}
  ]}
/>

いい感じのコンポーネントになりました。(?)

しかし、追加要望が来てしまいました。

  1. SNSボタンなしのバージョン
    スクリーンショット 2026-07-15 22.17.30 (1).png

  2. 右側に「もっと見る」ボタンありバージョン
    ray3.png

  3. チャットメッセージ型
    ray4.png

こんな場合にも既存のコンポーネントにPropsを追加したら対応はできますが、複雑になる可能性が高いです。
ここで、先ほどご紹介した Compound Componentsパターンが活躍します。
コードは以下の通りです。

UserCardコンポーネントの使用例

App.tsx
<UserCard>
    <UserCard.ProfileImg imgSrc={dogImg} />
    <UserCard.Body>
        <UserCard.Title>ワンちゃん</UserCard.Title>
            <UserCard.Description>
                こんにちは。お肉が大好きです
            </UserCard.Description>

            <UserCard.SnsBadgeContainer>
                <UserCard.SnsBadge snsName="instagram" snsLink="#" />
                <UserCard.SnsBadge snsName="x" snsLink="#" />
            </UserCard.SnsBadgeContainer>
    </UserCard.Body>
</UserCard>

UserCard.tsxの中身

UserCard.tsx
const snsIconMap = {
  x: xIcon,
  instagram: instagramIcon,
  facebook: facebookIcon,
} as const;

type SnsType = keyof typeof snsIconMap;

// UserCard Rootコンポーネント
type UserCardRootProps = {
  children: ReactNode;
};
const UserCardRoot = ({ children }: UserCardRootProps) => {
  return <article className="card-root">{children}</article>;
};

// UserCardのProfileコンポーネント
type UserCardProfileImgProps = {
  imgSrc: string;
  alt: string;
};
const UserCardProfileImg = ({ imgSrc, alt }: UserCardProfileImgProps) => {
  return (
    <div className="profile">
      <img src={imgSrc} alt={alt} className="profile__img" />
    </div>
  );
};

// UserCardのBodyコンポーネント
type UserCardBodyProps = {
  children: ReactNode;
};
const UserCardBody = ({ children }: UserCardBodyProps) => {
  return <div className="card-body">{children}</div>;
};

// UserCardのTitleコンポーネント
type UserCardTitleProps = {
  children: ReactNode;
};
const UserCardTitle = ({ children }: UserCardTitleProps) => {
  return <div className="title">{children}</div>;
};

// UserCardのDescriptionコンポーネント
type UserCardDescriptionProps = {
  children: ReactNode;
};
const UserCardDescription = ({ children }: UserCardDescriptionProps) => {
  return <div className="description">{children}</div>;
};

// UserCardのBadgeContainerコンポーネント
type UserCardSnsBadgeContainerProps = {
  children: ReactNode;
};
const UserCardSnsBadgeContainer = ({
  children,
}: UserCardSnsBadgeContainerProps) => {
  return <div className="sns-badge-container">{children}</div>;
};

// UserCardのSnsBadgeコンポーネント
type UserCardSnsBadgeProps = {
  snsName: SnsType;
  snsLink: string;
};
const UserCardSnsBadgeProps = ({ snsName, snsLink }: UserCardSnsBadgeProps) => {
  const iconMap: Record<SnsType, string> = {
    x: xIcon,
    instagram: instagramIcon,
    facebook: facebookIcon,
  };
  return (
    <a
      href={snsLink}
      className="sns-badge"
      target="_blank"
      rel="noopener noreferrer"
    >
      <img src={iconMap[snsName]} alt={`${snsName} icon`} />
    </a>
  );
};

// UserCardのMoreButtonコンポーネント
const UserCardMoreButton = () => {
  return <button className="more-button"></button>;
};

// UserCardのTimeコンポーネント
type UserCardTimeProps = {
  timeString: string;
};
const UserCardTime = ({ timeString }: UserCardTimeProps) => {
  return <time className="time">{timeString}</time>;
};

const UserCard = Object.assign(UserCardRoot, {
  ProfileImg: UserCardProfileImg,
  Body: UserCardBody,
  Title: UserCardTitle,
  Description: UserCardDescription,
  SnsBadgeContainer: UserCardSnsBadgeContainer,
  SnsBadge: UserCardSnsBadgeProps,
  MoreButton: UserCardMoreButton,
  Time: UserCardTime,
});
export default UserCard;

UserCard.tsxには複数のコンポーネントを定義しており、Object.assignを使ってそれらをまとめて exportしています。
これは、各コンポーネントが UserCardに属するコンポーネントであることを明確にするためです。

例2)Tabsコンポーネント(状態管理ありのパターン)

useContext と useState を使って状態を共有する Compound Components を実装することもできます。
代表的な例として、以下のような Tabs コンポーネントも実装できます。

画面収録 2026-09-05 17.58.27.gif

使用例

App.tsx
      <Tabs defaultValue="profile">
        <Tabs.List>
          <Tabs.Trigger value="profile">プロフィール</Tabs.Trigger>
          <Tabs.Trigger value="donate">寄付</Tabs.Trigger>
          <Tabs.Trigger value="form">フォーム</Tabs.Trigger>
        </Tabs.List>
        <Tabs.Content value="profile">
          <p>プロフィールページの内容が入ります。</p>
        </Tabs.Content>
        <Tabs.Content value="donate">
          <p>お肉を食べるために寄付をお待ちしております。</p>
          <div className="img-center">
            <img src={dogMeat} alt="img" />
          </div>
        </Tabs.Content>
        <Tabs.Content value="form">
          <form action="/">
            <label htmlFor="name">金額</label>
            <input className="name-input" id="name" name="name" type="number" />
            <button className="submit-button" type="submit">
              寄付する
            </button>
          </form>
        </Tabs.Content>
      </Tabs>

Tabs.tsxの中身

Tabs.tsx
import { createContext, useContext, useState, type ReactNode } from 'react';

type TabsContextType = {
  activeValue: string;
  setActiveValue: (value: string) => void;
};
const TabsContext = createContext<TabsContextType | null>(null);

const useTabsContext = () => {
  const context = useContext(TabsContext);

  if (!context) {
    throw new Error('Tabs コンポーネントは <Tabs> の内部で使用する必要があります。');
  }

  return context;
};

type TabsRootProps = {
  defaultValue: string;
  children: ReactNode;
};

const TabsRoot = ({ defaultValue, children }: TabsRootProps) => {
  const [activeValue, setActiveValue] = useState(defaultValue);

  return (
    <TabsContext.Provider value={{ activeValue, setActiveValue }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
};

type TabsListProps = {
  children: ReactNode;
};

const TabsList = ({ children }: TabsListProps) => {
  return <div role="tabs__list">{children}</div>;
};

type TabsTrigger = {
  value: string;
  children: ReactNode;
};

const TabsTrigger = ({ value, children }: TabsTrigger) => {
  const { activeValue, setActiveValue } = useTabsContext();
  const isActive = activeValue === value;

  return (
    <button
      type="button"
      role="tab"
      aria-selected={isActive}
      className="tabs__trigger"
      data-active={isActive}
      onClick={() => setActiveValue(value)}
    >
      {children}
    </button>
  );
};

type TabsContentProps = {
  value: string;
  children: ReactNode;
};

const TabsContent = ({ value, children }: TabsContentProps) => {
  const { activeValue } = useTabsContext();

  if (activeValue !== value) return null;

  return (
    <div className="tabs__content" role="tabpanel">
      {children}
    </div>
  );
};

const Tabs = Object.assign(TabsRoot, {
  List: TabsList,
  Trigger: TabsTrigger,
  Content: TabsContent,
});
export default Tabs;
  • TabsRoot
    • activeValueを useStateで管理する
    • TabsContext.Providerを通して、現在選択されているタブと更新関数を子コンポーネントに共有する
  • TabsList
    • Tabs.Triggerをまとめるためのコンテナである
  • TabsTrigger
    • useTabsContextから activeValueとsetActiveValueを取得する
    • クリックされたときに、自身の value を現在のアクティブなタブとして設定する
  • TabsContent
    • 自身の value とactiveValueを比較する
    • 値が一致する場合のみ、対応するコンテンツを表示する

その他

以下のURLに接続したら、全体的なソースコードの確認ができます。
https://stackblitz.com/edit/vitejs-vite-dvvtvs7z?file=src%2Fcomponents%2FTabs.tsx

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?