compound-pattern について
共有状態を持つ関連コンポーネント向けに、複合コンポーネントパターンを解説する React スキルです。ドロップダウン、タブ、メニューなどの柔軟な API を設計する開発者を対象としています。
主な機能
- 01React Context による状態共有
- 02子コンポーネントの親プロパティ化
- 03柔軟なコンポーネントのネスト
- 04Context と React.Children.map の比較
- 05パターンの追加コストの判断
活用シーン
- 01操作部とリストが連動するドロップダウンの構築
- 02共有状態を持つタブやメニューの作成
- 03コンポーネントライブラリ API の設計
- 04複合コンポーネントと単純な props の選択
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ patternsdev/ skills --skill compound-patternインストール後、Claude Code で/compound-patternを入力して読み込まれたか確認します。
詳細
コンパウンドパターン
目次
アプリケーションでは、互いに関連するコンポーネントをよく扱います。共有状態を通じて互いに依存し、ロジックを共有します。select、ドロップダウンコンポーネント、メニュー項目などのコンポーネントでよく見られます。コンパウンドコンポーネントパターンを使うと、連携してタスクを実行する複数のコンポーネントを作成できます。
使用する場面
- ドロップダウン、タブ、メニューなど、関連するサブコンポーネントを持つコンポーネントを構築するときに使用します
- 内部の状態管理を公開せず、すっきりしたコンポーネント API を提供したい場合に便利です
使用しない場面
- サブコンポーネント間で意味のある状態を共有しない場合。このパターンでは、不要な Context のオーバーヘッドが発生します
- props を使った単一のコンポーネントのほうが明快な、単発のシンプルな UI の場合
- 暗黙的な状態共有により、利用者にとってコンポーネントの動作が予測しにくくなる場合
手順
- React Context API を使用して、親のコンパウンドコンポーネントと子の間で状態を共有します
- 子コンポーネントを親の静的プロパティとして設定します(例:
FlyOut.Toggle、FlyOut.List) - 複雑な状況では、不要な再レンダリングを避けるために Context の値をメモ化します
- より柔軟にコンポーネントをネストできるよう、
React.Children.mapよりも Context を使う方法を優先します
詳細
Context API
例を見てみましょう。リスの画像のリストがあるとします。リスの画像を表示するだけでなく、ユーザーが画像を編集または削除できるボタンも追加したいと考えています。ユーザーがコンポーネントを切り替えるとリストを表示する FlyOut コンポーネントを実装できます。
FlyOut コンポーネントには、基本的に次の3つの要素があります。
- 切り替えボタンとリストを含む
FlyOutラッパー Listの表示を切り替えるToggleボタン- メニュー項目のリストを含む
List
React の Context API を使ったコンパウンドコンポーネントパターンは、この例にぴったりです。
まず、FlyOut コンポーネントを作成しましょう。このコンポーネントは状態を保持し、受け取ったすべての子に切り替え状態の値を渡す FlyOutProvider を返します。
const FlyOutContext = createContext();
function FlyOut(props) {
const [open, toggle] = useState(false);
return (
<FlyOutContext.Provider value={{ open, toggle }}>
{props.children}
</FlyOutContext.Provider>
);
}
これで、open と toggle の値を子に渡せる、状態を持つ FlyOut コンポーネントができました。
次に、Toggle コンポーネントを作成しましょう。このコンポーネントは、ユーザーがクリックしてメニューの表示を切り替えられるコンポーネントを描画するだけです。
function Toggle() {
const { open, toggle } = useContext(FlyOutContext);
return (
<div onClick={() => toggle(!open)}>
<Icon />
</div>
);
}
Toggle が実際に FlyOutContext プロバイダーにアクセスできるようにするには、FlyOut の子コンポーネントとして描画する必要があります。また、Toggle コンポーネントを FlyOut コンポーネントのプロパティにすることもできます。
const FlyOutContext = createContext();
function FlyOut(props) {
const [open, toggle] = useState(false);
return (
<FlyOutContext.Provider value={{ open, toggle }}>
{props.children}
</FlyOutContext.Provider>
);
}
function Toggle() {
const { open, toggle } = useContext(FlyOutContext);
return (
<div onClick={() => toggle(!open)}>
<Icon />
</div>
);
}
FlyOut.Toggle = Toggle;
つまり、どのファイルでも FlyOut コンポーネントを使いたいときは、FlyOut だけをインポートすればよいということです。
import React from "react";
import { FlyOut } from "./FlyOut";
export default function FlyoutMenu() {
return (
<FlyOut>
<FlyOut.Toggle />
</FlyOut>
);
}
切り替えボタンだけでは不十分です。open の値に応じて開閉する項目を含む List も必要です。
function List({ children }) {
const { open } = React.useContext(FlyOutContext);
return open && <ul>{children}</ul>;
}
function Item({ children }) {
return <li>{children}</li>;
}
List コンポーネントは、open の値が true か false かに応じて子を描画します。Toggle コンポーネントと同じように、List と Item を FlyOut コンポーネントのプロパティにしましょう。
const FlyOutContext = createContext();
function FlyOut(props) {
const [open, toggle] = useState(false);
return (
<FlyOutContext.Provider value={{ open, toggle }}>
{props.children}
</FlyOutContext.Provider>
);
}
function Toggle() {
const { open, toggle } = useContext(FlyOutContext);
return (
<div onClick={() => toggle(!open)}>
<Icon />
</div>
);
}
function List({ children }) {
const { open } = useContext(FlyOutContext);
return open && <ul>{children}</ul>;
}
function Item({ children }) {
return <li>{children}</li>;
}
FlyOut.Toggle = Toggle;
FlyOut.List = List;
FlyOut.Item = Item;
これで、FlyOut コンポーネントのプロパティとして使えるようになりました。この例では、ユーザーに編集と削除の2つの選択肢を表示したいとします。編集用と削除用にそれぞれ1つずつ、2つの FlyOut.Item コンポーネントを描画する FlyOut.List を作成しましょう。
import React from "react";
import { FlyOut } from "./FlyOut";
export default function FlyoutMenu() {
return (
<FlyOut>
<FlyOut.Toggle />
<FlyOut.List>
<FlyOut.Item>Edit</FlyOut.Item>
<FlyOut.Item>Delete</FlyOut.Item>
</FlyOut.List>
</FlyOut>
);
}
完了です。FlyOutMenu 自体に状態を追加することなく、FlyOut コンポーネント全体を作成できました。
コンパウンドパターンは、コンポーネントライブラリを構築する際に便利です。Semantic UI のような UI ライブラリを使うと、このパターンをよく見かけます。
React.Children.map
コンポーネントの子要素をマッピングすることでも、複合コンポーネントパターンを実装できます。これらの要素を追加のプロパティとともに複製することで、open プロパティと toggle プロパティを追加できます。
export function FlyOut(props) {
const [open, toggle] = React.useState(false);
return (
<div>
{React.Children.map(props.children, (child) =>
React.cloneElement(child, { open, toggle })
)}
</div>
);
}
すべての子コンポーネントが複製され、open と toggle の値が渡されます。前の例のように Context API を使う必要はなくなり、これら2つの値に props を通じてアクセスできるようになります。
メリット
複合コンポーネントは独自の内部状態を管理し、その状態を複数の子コンポーネントで共有します。複合コンポーネントを実装する際に、状態を自分で管理する必要はありません。
複合コンポーネントをインポートするとき、そのコンポーネントで利用できる子コンポーネントを明示的にインポートする必要はありません。
import { FlyOut } from "./FlyOut";
export default function FlyoutMenu() {
return (
<FlyOut>
<FlyOut.Toggle />
<FlyOut.List>
<FlyOut.Item>Edit</FlyOut.Item>
<FlyOut.Item>Delete</FlyOut.Item>
</FlyOut.List>
</FlyOut>
);
}
デメリット
React.Children.map を使って値を渡す場合、コンポーネントのネストには制限があります。親コンポーネントの 直接の子要素 だけが open プロパティと toggle プロパティにアクセスできます。つまり、これらのコンポーネントを別のコンポーネントでラップすることはできません。
export default function FlyoutMenu() {
return (
<FlyOut>
{/* This breaks */}
<div>
<FlyOut.Toggle />
<FlyOut.List>
<FlyOut.Item>Edit</FlyOut.Item>
<FlyOut.Item>Delete</FlyOut.Item>
</FlyOut.List>
</div>
</FlyOut>
);
}
React.cloneElement で要素を複製すると、浅いマージが行われます。既存のプロパティは、渡した新しいプロパティとマージされます。既存のプロパティが React.cloneElement メソッドに渡すプロパティと同じ名前の場合、名前が衝突する可能性があります。プロパティは浅くマージされるため、そのプロパティの値は渡した最新の値で上書きされます。