はじめに
Reactでアプリを作っていると、こんな状況に出くわしませんか?
「あれ、このドロップダウンメニュー、ヘッダーでも一覧でも使ってるけど、微妙に項目が違うだけでほぼ同じUIだな...」
私もそうでした。同じようなコンポーネントが複数箇所に散らばっていて、修正するたびに両方を直さないといけない。これはリファクタリングのチャンスだと思い、共通化に挑戦しました。
結果として、props drillingという罠にハマり、先輩のレビューで指摘されて初めて「Composition Pattern」と「Context」の正しい使い方を学びました。
この記事では、私の失敗から学んだことを共有します。
最初の実装(失敗)
やりたかったこと
-
ActionMenuを共通コンポーネントにする - ヘッダーと一覧で表示する項目を切り替えられるようにする
最初に書いたコード
// ItemActionMenu.tsx(最初の実装)
type Item = {
id: string;
title: string;
status: "active" | "completed";
};
type MenuLocation = "header" | "list";
function ItemActionMenu({ item, location }: { item: Item; location: MenuLocation }) {
const [dropdownOpen, setDropdownOpen] = useState(false);
if (location === "header") {
return (
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<EditMenuItem item={item} setDropdownOpen={setDropdownOpen} />
<DeleteMenuItem item={item} setDropdownOpen={setDropdownOpen} />
<ArchiveMenuItem itemId={item.id} setDropdownOpen={setDropdownOpen} />
{/* header専用の項目 */}
</DropdownMenu>
);
}
// list location
return (
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<EditMenuItem item={item} setDropdownOpen={setDropdownOpen} />
<DeleteMenuItem item={item} setDropdownOpen={setDropdownOpen} />
<ShareMenuItem item={item} setDropdownOpen={setDropdownOpen} />
{/* list専用の項目 */}
</DropdownMenu>
);
}
一見、共通化できているように見えます。でも改善の余地がありました。
問題点
1. props drillingが発生している
-
itemとsetDropdownOpenを全てのMenuItemに渡している - 今は2階層なので致命的ではないが、MenuItemの内部にさらにコンポーネントがある場合は3階層以上になる
- また、
childrenで渡すコンポーネントが親のstateを必要とするケースでもある
2. if分岐でコードが重複
-
EditMenuItemとDeleteMenuItemが両方のブロックに登場 - DRY原則に従っていない
3. 拡張性が低い
- 新しいlocationを追加すると、さらにif分岐が増える
- 「サイドバー専用」「モーダル専用」などが増えたら管理が大変になる
先輩の指摘
PRを出したところ、レビューでこんな指摘をもらいました。
「props drillingが残ってるからComposition Patternを使うと解決できるよ」
「社内docsに書いてあるけど、『コンポーネントは自己完結』『stateは使う場所に配置』という原則があるから、確認してみて」
正直、最初は「props drilling?ちゃんと渡してるだけじゃん」と思いました。でも社内ドキュメントを読み返してみると...
「idはpropsで渡さずContextで共有する」
「各コンポーネントが自分の状態を持つべき」
なるほど、確かに私のコードは ActionMenu が全てを管理しようとしていて、各MenuItemが自己完結していませんでした。
葛藤:本当にContextを使っていいのか?
ここで私は悩みました。
「Contextって重いんじゃないの?」
「グローバルステートに使うものでしょ?」
Reduxの代わりに使うイメージが強くて、こんな小さなコンポーネントに使っていいのか不安でした。
でも、先輩のアドバイスでRadix UIやshadcn/uiのソースコードを見てみると...
// Radix UIのDialogの使い方
<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Title</DialogTitle>
</DialogHeader>
</DialogContent>
</Dialog>
あれ、これって open や onOpenChange を子コンポーネントに渡していない。でも DialogTrigger をクリックすると開く。
これがCompound Components Patternか!
内部でContextを使って、親子間でstateを共有しているんですね。UIライブラリでは定番のパターンでした。
検討した選択肢
リファクタリング方法をいくつか検討しました。
| パターン | 概要 | 判定 | 理由 |
|---|---|---|---|
| location分岐 | 最初の実装 | ❌ | props drillingが残る |
| render props | 関数をpropsで渡す | ❌ | childrenの柔軟性に劣る |
| Composition + Context | childrenで渡す + Context共有 | ✅ | 柔軟で自己完結 |
After:Composition Pattern + Context
設計のポイント
-
Context で
itemとsetOpenを共有 -
Composition Pattern で
childrenとして項目を渡す - 各MenuItemが自分のstateを管理(モーダルの開閉など)
リファクタリング後のコード
// ItemActionMenu.tsx
import { createContext, useContext, useState, ReactNode } from "react";
type ItemActionMenuContextType = {
item: Item;
open: boolean;
setOpen: (open: boolean) => void;
};
const ItemActionMenuContext = createContext<ItemActionMenuContextType | null>(null);
// Contextを安全に取得するhook
export function useItemActionMenuContext() {
const context = useContext(ItemActionMenuContext);
if (!context) {
throw new Error("useItemActionMenuContext must be used within ItemActionMenu");
}
return context;
}
// 親コンポーネント
export function ItemActionMenu({
item,
children,
}: {
item: Item;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
<ItemActionMenuContext.Provider value={{ item, open, setOpen }}>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>{children}</DropdownMenuContent>
</DropdownMenu>
</ItemActionMenuContext.Provider>
);
}
// EditMenuItem.tsx(自己完結したコンポーネント)
export function EditMenuItem() {
const { item, setOpen } = useItemActionMenuContext();
const [dialogOpen, setDialogOpen] = useState(false);
const updateMutation = useUpdateItem();
const handleUpdate = (data: UpdateData) => {
updateMutation.mutate({ id: item.id, data });
setOpen(false); // ドロップダウンを閉じる
};
return (
<>
<DropdownMenuItem onSelect={() => setDialogOpen(true)}>
<Pencil className="mr-2 h-4 w-4" />
編集する
</DropdownMenuItem>
<EditDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
onSubmit={handleUpdate}
defaultValues={item}
/>
</>
);
}
// 使う側(ヘッダー)
<ItemActionMenu item={item}>
<EditMenuItem />
<DeleteMenuItem />
<ArchiveMenuItem />
</ItemActionMenu>
// 使う側(一覧)
<ItemActionMenu item={item}>
<EditMenuItem />
<DeleteMenuItem />
<ShareMenuItem />
</ItemActionMenu>
何が変わったか
Before:
-
ItemActionMenuが全てのpropsを管理 - MenuItemはpropsを受け取るだけの「ダム」なコンポーネント
- 表示箇所の分岐が
ItemActionMenu内にある
After:
-
ItemActionMenuはitemとopen/setOpenを提供するだけ - 各MenuItemが自分のロジックとstateを持つ(自己完結)
- 表示箇所の分岐は使う側にある(柔軟性向上)
結果と効果
このリファクタリングの結果:
- props drilling: 完全解消
- 状態管理: 各MenuItemに分散(単一責任の原則)
- 拡張性: 新しいMenuItemを追加するだけでOK
振り返り:Contextを使ってよかったのか?
よかった点
1. Composition Patternとの相性が抜群
-
childrenで渡すコンポーネントが親のstateにアクセスできる - propsのバケツリレーが不要
2. 共有するデータが明確
-
itemとopen/setOpenだけ - スコープが限定されていて、グローバルに影響しない
3. UIライブラリの定番パターン
- Radix UI、shadcn/ui、Chakra UIなどが使ってる
注意点
-
MenuItemは
ItemActionMenu内でしか使えない-
useItemActionMenuContextがエラーを投げる - でも、それは意図通りの制約
-
これは「制限」ではなく「正しい使い方を強制する仕組み」です。
Context vs Props の判断基準
最後に、今回学んだ判断基準をまとめます。
Contextを使うべき場面
- ✅
childrenで渡すコンポーネントがデータを必要とする - ✅ 複数の子コンポーネントが同じデータにアクセスする
- ✅ UIライブラリ的なCompound Componentsを作りたい
- ✅ 階層が深い(3階層以上)
Propsで十分な場面
- ✅ 1〜2階層のみ
- ✅ 渡すデータが1〜2個
- ✅ 子コンポーネントが固定(
childrenでない) - ✅ 単純な親子関係
簡単な判定フロー
Q: childrenで渡すコンポーネントが親のstateを使う?
├─ Yes → Context + Composition Pattern
└─ No
└─ Q: 3階層以上にpropsを渡す?
├─ Yes → Context検討
└─ No → Propsで十分
おわりに
最初の実装で失敗して、先輩に指摘されて、ようやく理解できました。
- 「パターンを適用すること」が目的ではない
- 「問題を解決すること」が目的
Composition PatternもContextも、props drillingという問題を解決するための手段です。
また、UIライブラリのソースコードを読むことの大切さも実感しました。Radix UIやshadcn/uiは、Reactのベストプラクティスの宝庫です。
この記事が、同じように悩んでいる誰かの参考になれば幸いです。