0
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?

props drillingで失敗したReact初心者がレビューで学んだComposition Patternの話

Last updated at Posted at 2026-02-06

はじめに

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が発生している

  • itemsetDropdownOpen を全てのMenuItemに渡している
  • 今は2階層なので致命的ではないが、MenuItemの内部にさらにコンポーネントがある場合は3階層以上になる
  • また、childrenで渡すコンポーネントが親のstateを必要とするケースでもある

2. if分岐でコードが重複

  • EditMenuItemDeleteMenuItem が両方のブロックに登場
  • 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>

あれ、これって openonOpenChange を子コンポーネントに渡していない。でも DialogTrigger をクリックすると開く。

これがCompound Components Patternか!

内部でContextを使って、親子間でstateを共有しているんですね。UIライブラリでは定番のパターンでした。


検討した選択肢

リファクタリング方法をいくつか検討しました。

パターン 概要 判定 理由
location分岐 最初の実装 props drillingが残る
render props 関数をpropsで渡す childrenの柔軟性に劣る
Composition + Context childrenで渡す + Context共有 柔軟で自己完結

After:Composition Pattern + Context

設計のポイント

  1. ContextitemsetOpen を共有
  2. Composition Patternchildren として項目を渡す
  3. 各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:

  • ItemActionMenuitemopen/setOpen を提供するだけ
  • 各MenuItemが自分のロジックとstateを持つ(自己完結)
  • 表示箇所の分岐は使う側にある(柔軟性向上)

結果と効果

このリファクタリングの結果:

  • props drilling: 完全解消
  • 状態管理: 各MenuItemに分散(単一責任の原則)
  • 拡張性: 新しいMenuItemを追加するだけでOK

振り返り:Contextを使ってよかったのか?

よかった点

1. Composition Patternとの相性が抜群

  • children で渡すコンポーネントが親のstateにアクセスできる
  • propsのバケツリレーが不要

2. 共有するデータが明確

  • itemopen/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のベストプラクティスの宝庫です。

この記事が、同じように悩んでいる誰かの参考になれば幸いです。

0
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
0
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?