レビュー済み·難易度: 中級·更新: 2026-04-18

React で Compound Component パターンを実装する

Context と Children を組み合わせた Compound Component パターンで、Tabs や Accordion のような柔軟な UI コンポーネントを実装する例。

nextjsui-component

対応バージョン

nextjs 15react 19

前提環境

React の useContext と createContext の基本を理解していること

概要

Context と静的プロパティを使った Compound Component パターンで、<Tabs> コンポーネントを実装する。親が状態を持ち、子コンポーネント(Tabs.List / Tabs.Tab / Tabs.Panel)が Context 経由でその状態を共有する。Radix UI や shadcn/ui の内部設計の土台になるパターン。

インストール

# 追加インストールは不要

実装

Tabs コンポーネント

// components/Tabs.tsx
"use client";

import {
  createContext,
  useContext,
  useState,
  type ReactNode,
} from "react";

type TabsContextValue = {
  activeTab: string;
  setActiveTab: (id: string) => void;
};

const TabsContext = createContext<TabsContextValue | null>(null);

function useTabs() {
  const ctx = useContext(TabsContext);
  if (!ctx) throw new Error("Tabs の子コンポーネント内で使用してください");
  return ctx;
}

// ルートコンポーネント: 状態を持つ
function TabsRoot({
  defaultTab,
  children,
}: {
  defaultTab: string;
  children: ReactNode;
}) {
  const [activeTab, setActiveTab] = useState(defaultTab);
  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      <div>{children}</div>
    </TabsContext.Provider>
  );
}

// タブボタンのリスト
function TabsList({ children }: { children: ReactNode }) {
  return (
    <div className="flex gap-1 border-b border-gray-200" role="tablist">
      {children}
    </div>
  );
}

// 個別のタブボタン
function TabsTab({
  id,
  children,
}: {
  id: string;
  children: ReactNode;
}) {
  const { activeTab, setActiveTab } = useTabs();
  const isActive = activeTab === id;

  return (
    <button
      role="tab"
      aria-selected={isActive}
      onClick={() => setActiveTab(id)}
      className={`px-4 py-2 text-sm font-medium transition-colors ${
        isActive
          ? "border-b-2 border-blue-600 text-blue-600"
          : "text-gray-500 hover:text-gray-800"
      }`}
    >
      {children}
    </button>
  );
}

// タブに対応するコンテンツ
function TabsPanel({
  id,
  children,
}: {
  id: string;
  children: ReactNode;
}) {
  const { activeTab } = useTabs();
  if (activeTab !== id) return null;
  return (
    <div role="tabpanel" className="pt-4">
      {children}
    </div>
  );
}

// 静的プロパティとして子コンポーネントを束ねる
export const Tabs = Object.assign(TabsRoot, {
  List: TabsList,
  Tab: TabsTab,
  Panel: TabsPanel,
});

使用例

// app/page.tsx
import { Tabs } from "@/components/Tabs";

export default function Page() {
  return (
    <main className="mx-auto max-w-xl p-8">
      <Tabs defaultTab="overview">
        <Tabs.List>
          <Tabs.Tab id="overview">概要</Tabs.Tab>
          <Tabs.Tab id="details">詳細</Tabs.Tab>
          <Tabs.Tab id="reviews">レビュー</Tabs.Tab>
        </Tabs.List>

        <Tabs.Panel id="overview">
          <p className="text-gray-600">概要コンテンツ</p>
        </Tabs.Panel>
        <Tabs.Panel id="details">
          <p className="text-gray-600">詳細コンテンツ</p>
        </Tabs.Panel>
        <Tabs.Panel id="reviews">
          <p className="text-gray-600">レビューコンテンツ</p>
        </Tabs.Panel>
      </Tabs>
    </main>
  );
}

ポイント

  • createContext で状態を Context に入れ、親コンポーネントが activeTab を管理する。子は useContext 経由で状態を読み書きする
  • Object.assign(TabsRoot, { List, Tab, Panel }) で子コンポーネントを静的プロパティとして束ねると、<Tabs.Tab> のような名前空間付き API になる
  • useTabs のカスタムフックで null チェックを集約し、Provider 外で使った場合に明快なエラーを投げる
  • 状態はルートコンポーネントに閉じており、個別コンポーネントに props を垂れ流さない(prop drilling なし)
  • Radix UI や shadcn/ui はこのパターンを応用している。外部ライブラリを理解する土台として実装を把握しておくと役立つ

注意点

外部ライブラリを使わず React のみで実装する。Radix UI や shadcn/ui の内部設計を理解する土台になるパターン。

関連サンプル