言語を切り替える
中文 翻訳中 English 翻訳中 日本語
テーマを切り替える

shadcn/ui コンポーネント組み合わせパターン:実践ベストプラクティス

深夜2時、ユーザー管理ページのコードを眺めていて、少し落ち込みました。

DataTable でユーザー一覧を表示し、各行に DropdownMenu の操作メニュー、そして「編集」をクリックすると Dialog が開き、中に Form が入っている。単純な機能に聞こえますよね?

でも私のコードはこうでした:状態があちこちで渡され、prop drilling が5層目に達し、Dialog の open 状態は親コンポーネント、Form のデータは子コンポーネント、送信後のコールバックはまた親に戻って DataTable を更新…

正直なところ、当時は shadcn/ui に疑問を感じていました。「単体のコンポーネントは使いやすいのに、組み合わせるとどうしてこんなに複雑になるんだ?」

その後、shadcn/ui の設計ドキュメントを読んでみて、問題はコンポーネントライブラリではなく、自分の組み合わせパターンへの理解不足だと気づきました。shadcn/ui の核心理念は「継承より合成(Composition over Inheritance)」であり、すべてのコンポーネントが統一された予測可能なインターフェースを持っています。このインターフェースの背後にある設計哲学を知らないと、私のように組み合わせが混沌としてしまいます。

今日は私が踏んだ落とし穴と、その後学んだ組み合わせパターンのベストプラクティスについてお話しします。


まず shadcn/ui の設計哲学を理解する

具体的な組み合わせについて話す前に、shadcn/ui の設計ロジックを理解する必要があります。そうしないと、同じ React コンポーネントライブラリを使っているのに、他の人はきれいに組み合わせているのに、自分のコードはスパゲッティのようになってしまう理由がわかりません。

shadcn/ui と従来の UI ライブラリの最大の違い:これは npm パッケージではなく、package.json に @shadcn/ui という依存関係は表示されません。すべてのコンポーネントコードは、プロジェクトに直接コピーされます。

原始的に聞こえますよね?でも、これこそが設計哲学です:

Open Code:コンポーネントコードは完全にオープンで、いつでも変更でき、バージョン競合を心配する必要はありません。例えば、Button コンポーネントの特定のスタイルが気に入らない場合、公式の新バージョンを待たずにソースコードを直接変更できます。

Composition:すべてのコンポーネントは統一された合成可能なインターフェースを使用します。どういうことかというと、各コンポーネントの構造が予測可能であるということです。例えば、Card コンポーネントは必ず <Card>&lt;CardHeader>&lt;CardTitle>&lt;CardContent> というネスト構造で、Dialog は必ず <Dialog>&lt;DialogContent>&lt;DialogHeader>&lt;DialogTitle> です。

この統一されたインターフェースの利点:複数のコンポーネントを組み合わせる際、どこでネストすべきか、どこで並列すべきかがわかります。「このコンポーネントはあれを包む必要があるけど、あれは外側にあることを要求する」といった矛盾が発生しません。


基本組み合わせ:Dialog + Form

最も一般的な組み合わせシナリオ:モーダルの中にフォームを入れる。

ユーザーが「編集」ボタンをクリックすると Dialog が開き、中に Form があり、入力して送信すると Dialog が閉じる。単純に聞こえますが、最初に書いた時、私は間違いを犯していました:Dialog と Form の状態を混在させてしまったのです。

間違ったアプローチ

// ❌ これが私の失敗バージョン
function EditUserDialog() {
  const [open, setOpen] = useState(false)
  const [formData, setFormData] = useState&#123;&#123;&#125;&#125;

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button onClick={() => fetchUserData()}>編集</Button>
      </DialogTrigger>
      <DialogContent>
        <form onSubmit={(e) => {
          e.preventDefault()
          submitForm(formData)
          setOpen(false)
        }}>
          <Input
            value={formData.username}
            onChange={(e) => setFormData(&#123;...formData, username: e.target.value&#125;)}
          />
          <Button type="submit">保存</Button>
        </form>
      </DialogContent>
    </Dialog>
  )
}

問題はどこに?Dialog の open 状態と Form のデータ状態が1つのコンポーネントに混在し、さらに React Hook Form を使わずに手動で form 状態を管理していたため、バリデーションやエラー表示が乱雑でした。

正しいアプローチ

shadcn/ui の Form コンポーネントは React Hook Form + Zod をベースにしており、この組み合わせを使うとコードがかなりきれいになります:

// ✅ 正しい組み合わせ方法
import &#123; useForm &#125; from "react-hook-form"
import &#123; zodResolver &#125; from "@hookform/resolvers/zod"
import * as z from "zod"

// 1. まず Schema を定義(コンポーネント外)
const userSchema = z.object({
  username: z.string().min(3, "ユーザー名は3文字以上必要です"),
  email: z.string().email("メールアドレスの形式が正しくありません")
})

function EditUserDialog(&#123; user, onSubmit &#125;) {
  const [open, setOpen] = useState(false)
  const form = useForm(&#123;
    resolver: zodResolver(userSchema),
    defaultValues: user // ユーザーデータを直接渡す
  &#125;)

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button variant="outline">編集</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>ユーザー情報を編集</DialogTitle>
        </DialogHeader>

        {/* Form で shadcn の Form コンポーネントを直接使用 */}
        <Form &#123;...form&#125;>
          <form onSubmit={form.handleSubmit((data) => &#123;
            onSubmit(data)      // データを送信
            setOpen(false)      // Dialog を閉じる
          &#125;)}>
            <FormField
              name="username"
              render=&#123;(&#123; field &#125;) => (
                <FormItem>
                  <FormLabel>ユーザー名</FormLabel>
                  <FormControl>&lt;Input &#123;...field&#125; /></FormControl>
                  <FormMessage /> {/* エラーを自動表示 */}
                </FormItem>
              )&#125;
            />
            <Button type="submit">保存</Button>
          </form>
        </Form>
      </DialogContent>
    </Dialog>
  )
}

重要なポイント:

  1. Dialog はコンテナ、Form はコンテンツ:Dialog は「開く/閉じる」のみを管理し、Form は「データ/バリデーション/送信」を管理し、責任を分離します。
  2. Form は React Hook Form + Zod を使用:form 状態を手動で管理せず、form.handleSubmit がバリデーションと送信を自動処理します。
  3. FormMessage がエラーを自動表示:エラーロジックを手動で書く必要がなく、Zod バリデーションが失敗すると自動的にエラーメッセージが表示されます。

こうすると、Dialog と Form の状態が明確になります:Dialog の open は親コンポーネント、Form のデータは Form コンポーネント内部(React Hook Form で管理)にあります。


DataTable + DropdownMenu:テーブル行操作

もう一つの一般的なシナリオ:テーブルの各行に操作メニューがあり、「編集」をクリックすると Dialog が開く。

私が踏んだ落とし穴:行データを Dialog に渡す方法がわからなかった。DataTable の column 定義では row.original(現在の行データ)を取得できますが、Dialog は DataTable の外にあり、どうやって渡すのか?

間違ったアプローチ

// ❌ 私の最初のアプローチ:Dialog を cell 内にネスト
const columns = [
  &#123;
    id: "actions",
    cell: &#123; row &#125; => (
      <Dialog>
        <DialogTrigger asChild>
          <Button>編集</Button>
        </DialogTrigger>
        <DialogContent>
          {/* 問題:各 cell レンダリングで Dialog インスタンスが作成される */}
          <EditForm user={row.original} />
        </DialogContent>
      </Dialog>
    )
  &#125;
]

このアプローチの問題:各行が Dialog インスタンスを作成し、100行のデータで100個の Dialog になり、パフォーマンスが非常に悪くなります。また、Dialog の状態を統一的に管理するのが困難です。

正しいアプローチ

グローバルな Dialog を1つ使い、Hook で状態を管理:

// 1. まず Dialog 状態を管理する Hook を定義
const useEditDialog = () => &#123;
  const [open, setOpen] = useState(false)
  const [editingUser, setEditingUser] = useState(null)

  const openEdit = (user) => &#123;
    setEditingUser(user)
    setOpen(true)
  &#125;

  const closeEdit = () => &#123;
    setOpen(false)
    setEditingUser(null)
  &#125;

  return &#123; open, editingUser, openEdit, closeEdit &#125;
&#125;

// 2. DataTable 列定義にはトリガーボタンのみ配置
function UserDataTable(&#123; users &#125;) &#123;
  const &#123; open, editingUser, openEdit, closeEdit &#125; = useEditDialog()

  const columns = [
    &#123;
      id: "actions",
      cell: &#123; row &#125; => (
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button variant="ghost" size="icon">
              <MoreHorizontal />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent>
            <DropdownMenuItem onClick={() => openEdit(row.original)}>
              編集
            </DropdownMenuItem>
            <DropdownMenuItem onClick={() => deleteUser(row.original.id)}>
              削除
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      )
    &#125;
  ]

  return (
    <>
      <DataTable columns={columns} data={users} />
      {/* グローバルで唯一の Dialog */}
      <Dialog open={open} onOpenChange={(o) => !o && closeEdit()}>
        <DialogContent>
          <EditUserForm
            user={editingUser}
            onSubmit={(data) => &#123;
              updateUser(data)
              closeEdit()
              refreshTable() // テーブルデータを更新
            &#125;}
          />
        </DialogContent>
      </Dialog>
    </>
  )
&#125;

重要なポイント:

  1. グローバル Dialog:DataTable の外に1つの Dialog を配置し、各行で作成するのではなく。
  2. Hook で状態管理:openEdit が Dialog を開きデータを渡し、closeEdit が閉じてデータをクリア。
  3. DropdownMenu がトリガー:cell にはトリガーボタンのみ配置し、onClick で openEdit(row.original) を呼び出し。

これで構造が明確になります:DataTable は「データ表示」、DropdownMenu は「操作トリガー」、Dialog は「フォーム表示」、Hook は「状態フロー」を管理します。


上級:Context パターンで Prop Drilling を回避

複数のコンポーネントを組み合わせる際、最も踏みやすい落とし穴は prop drilling です:状態が層を超えて渡され、5層目に達するとこの prop がどこから来たのかわからなくなります。

shadcn/ui の多くのコンポーネント自体が Compound Components(複合コンポーネント)パターンを採用しています。例えば Card:

<Card>
  <CardHeader>
    <CardTitle>タイトル</CardTitle>
    <CardDescription>説明</CardDescription>
  </CardHeader>
  <CardContent>コンテンツ</CardContent>
  <CardFooter>フッター</CardFooter>
</Card>

このようなネスト構造を見て、「CardTitle はどの Card に属しているかをどうやって知るのか?cardId を渡すべきか?」と考えるかもしれません。

実は必要ありません。Compound Components の核心は、Context を使って状態を共有し、子コンポーネントが自動的に自分がどの親コンポーネントにいるかを「知る」ことです。

折りたたみ可能な Card を自分で実装

shadcn/ui の Card はデフォルトで折りたたみできません。折りたたみ可能なバージョンを拡張して、Context パターンを学びましょう:

// 1. Context を作成
import &#123; createContext, useContext, useState &#125; from "react"

type CardContextValue = &#123;
  isCollapsed: boolean
  toggle: () => void
&#125;

const CardContext = createContext&lt;CardContextValue | null>(null)

// 2. Root コンポーネント:状態を管理、Context を提供
CollapsibleCard.Root = &#123; children, defaultCollapsed = false &#125; => &#123;
  const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)

  return (
    <CardContext.Provider value=&#123;&#123;
      isCollapsed,
      toggle: () => setIsCollapsed(!isCollapsed)
    &#125;}>
      <Card className="border rounded-lg">&#123;children&#125;</Card>
    </CardContext.Provider>
  )
&#125;

// 3. Header コンポーネント:タイトル + 折りたたみボタンを表示
CollapsibleCard.Header = &#123; title &#125; => &#123;
  const ctx = useContext(CardContext)
  if (!ctx) throw new Error("Header must be in CollapsibleCard.Root")

  return (
    <CardHeader className="cursor-pointer" onClick={ctx.toggle}>
      <div className="flex items-center justify-between">
        <CardTitle>&#123;title&#125;</CardTitle>
        &#123;ctx.isCollapsed ? <ChevronDown /> : <ChevronUp />&#125;
      </div>
    </CardHeader>
  )
&#125;

// 4. Content コンポーネント:折りたたみ状態に応答
CollapsibleCard.Content = &#123; children &#125; => &#123;
  const ctx = useContext(CardContext)
  if (!ctx) throw new Error("Content must be in CollapsibleCard.Root")

  if (ctx.isCollapsed) return null // 折りたたまれている時は表示しない

  return <CardContent>&#123;children&#125;</CardContent>
&#125;

使用例:

<CollapsibleCard.Root defaultCollapsed={false}>
  <CollapsibleCard.Header title="ユーザー情報" />
  <CollapsibleCard.Content>
    <p>名前:田中太郎</p>
    <p>メール:test@example.com</p>
  </CollapsibleCard.Content>
</CollapsibleCard.Root>

重要なポイント:

  1. Context が状態を共有:Root コンポーネントが Context を作成し、子コンポーネントは useContext で自動的に状態を取得、prop drilling が不要。
  2. 子コンポーネントが自動応答:Header のクリックで状態を切り替え、Content が自動的に表示/非表示、両者は直接通信する必要がありません。
  3. 親コンポーネントの制約を強制:子コンポーネントが Root 内にない場合、エラーを投げて警告します。

このパターンの利点:複数のコンポーネントを組み合わせる際、状態をどう渡すか心配する必要がありません。子コンポーネントが親コンポーネント内にあれば、自動的に状態を取得できます。


完全なシナリオ:DataTable + Dialog + Form

これまで学んだことを組み合わせて、完全なユーザー管理ページを作成しましょう:DataTable で一覧を表示し、「編集」をクリックすると Dialog が開き、Dialog 内に Form があり、送信後にテーブルを更新します。

完全なコード例は上記の各セクションを参照してください。ここでは重要なフローをまとめます:

  1. Schema 定義:Zod でユーザーデータ構造とバリデーションルールを定義
  2. Dialog 状態 Hook:Dialog の開く/閉じるとデータ受け渡しを統一的に管理
  3. DataTable 列定義:DropdownMenu 操作列を含む
  4. 編集フォームコンポーネント:Form + FormField + 各種 Input
  5. メインページコンポーネント:DataTable と Dialog を組み合わせ

この完全な例で、すべての組み合わせパターンが見られます:

  • DataTable がデータを表示
  • DropdownMenu が操作をトリガー
  • Dialog がフォームを表示
  • Form がバリデーションと送信
  • Hook が状態フローを管理

各コンポーネントの責任が明確で、状態は Hook と Context で管理され、prop drilling を回避しています。


上級テクニック:パフォーマンス最適化と型安全性

Context による再レンダリングを回避

Compound Components で Context を使うのは便利ですが、落とし穴があります:Context 値が変更されると、すべての useContext コンポーネントが再レンダリングされます。

例えば CollapsibleCard、折りたたみ状態が変更されると、Header と Content の両方が再レンダリングされます。Content 内に複雑なリストがある場合、再レンダリングが遅くなります。

解決策:Context を分離する。

// 状態 Context(頻繁に変更)
const CardStateContext = createContext&lt;&#123; isCollapsed: boolean &#125;>()

// 設定 Context(変更なし)
const CardConfigContext = createContext&lt;&#123; collapsible: boolean &#125;>()

// Root コンポーネントが2つの Context を提供
CollapsibleCard.Root = &#123; children, collapsible = true, defaultCollapsed = false &#125; => &#123;
  const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)

  return (
    <CardConfigContext.Provider value=&#123;&#123; collapsible &#125;}>
      <CardStateContext.Provider value=&#123;&#123; isCollapsed &#125;}>
        <Card>
          &#123;children&#125;
          {/* Toggle ボタンをここに单独で配置、Context 変更が子コンポーネントに影響しないように */}
          &#123;collapsible && (
            <button onClick={() => setIsCollapsed(!isCollapsed)}>
              &#123;isCollapsed ? "展開" : "折りたたみ"&#125;
            </button>
          )&#125;
        </Card>
      </CardStateContext.Provider>
    </CardConfigContext.Provider>
  )
&#125;

Header は CardConfigContext のみ読み取り(変更なし、再レンダリングなし)、Content は CardStateContext のみ読み取り(折りたたみ時のみ再レンダリング)。

TypeScript 型安全性

Compound Components の型定義は慎重に行う必要があります。そうしないと、TypeScript が「子コンポーネントが親コンポーネント内にない可能性がある」とエラーを出します。

// 完全な型定義
type CollapsibleCardProps = &#123;
  children: React.ReactNode
  defaultCollapsed?: boolean
  collapsible?: boolean
&#125;

type CollapsibleCardComponents = &#123;
  Root: FC&lt;CollapsibleCardProps>
  Header: FC&lt;&#123; title: string &#125;>
  Content: FC&lt;&#123; children: React.ReactNode &#125;>
&#125;

const CollapsibleCard: CollapsibleCardComponents = &#123;
  Root: &#123; children, defaultCollapsed = false, collapsible = true &#125; => &#123;
    // ...
  &#125;,
  Header: &#123; title &#125; => &#123;
    // ...
  &#125;,
  Content: &#123; children &#125; => &#123;
    // ...
  &#125;
&#125;

これにより、使用時に TypeScript が props が正しいかチェックします:

// ✅ 正しい
<CollapsibleCard.Root defaultCollapsed={true}>
  <CollapsibleCard.Header title="タイトル" />
  <CollapsibleCard.Content>コンテンツ</CollapsibleCard.Content>
</CollapsibleCard.Root>

// ❌ TypeScript エラー:title は必須
<CollapsibleCard.Header />

まとめ:組み合わせパターンチェックリスト

長々と話してきましたが、重要な原則をまとめましょう:

基本組み合わせ

  • Dialog + Form:Dialog はコンテナ、Form はコンテンツ、責任を分離
  • DataTable + DropdownMenu:DropdownMenu が操作をトリガー、row.original でデータを渡す
  • Tabs + Form:Tabs がナビゲーション、TabsContent が異なるフォームを含む

上級テクニック

  • Context パターン:prop drilling を回避、子コンポーネントが自動的に状態を取得
  • Hook で状態管理:統一的な Dialog 状態管理、行ごとにインスタンスを作成しない
  • Form + Zod:統一的なバリデーション Schema、FormMessage がエラーを自動表示

高度な最適化

  • Context を分離:頻繁に変更される状態がすべての子コンポーネントの再レンダリングを引き起こすのを回避
  • TypeScript 型:完全な型定義、props の渡し間違いを回避
  • Server/Client 分離:Next.js App Router で、データ取得は Server、UI は Client

最後のアドバイス:shadcn/ui コンポーネントファイルを直接変更しないでください。スタイルをカスタマイズしたい場合、ラッパーコンポーネントを作成するか、variants を使用するか、theme でカスタマイズしてください。ソースコードを直接変更すると、後でアップグレードするのが難しくなります。

正直なところ、shadcn/ui の組み合わせパターンを学んだ後、私のコードは確かにかなりきれいになりました。以前のユーザー管理ページは300行以上から150行以内に削減され、状態管理も明確になりました。もちろん、Context パターンやパフォーマンス最適化といった上級テクニックは、最初は少し複雑に感じるかもしれませんが、何回か書いているうちに慣れてきます。

同じような組み合わせの難問に遭遇したことはありますか?もしあれば、これらのパターンを試してみてください、きっと思考を整理するのに役立つはずです。


DataTable + Dialog + Form 組み合わせを実装

完全なユーザー管理ページ実装フロー

⏱️ 目安時間: 45 分

  1. 1

    ステップ1: Zod Schema を定義

    コンポーネント外でデータ構造バリデーションを定義:

    • z.object() でフィールドを定義
    • バリデーションルールを追加(min、email、enum)
    • schema と type を export
  2. 2

    ステップ2: Dialog 状態 Hook を作成

    Dialog 状態を統一的に管理:

    • useState で open と editingUser を管理
    • openDialog で開いてデータを渡す
    • closeDialog で閉じてデータをクリア
  3. 3

    ステップ3: DataTable 列を定義

    columns に操作列を追加:

    • cell 内に DropdownMenu を配置
    • onClick で openDialog(row.original) を呼び出し
    • Dialog を cell 内にネストしない
  4. 4

    ステップ4: 編集フォームコンポーネントを作成

    shadcn Form コンポーネントを使用:

    • useForm + zodResolver
    • FormField + FormControl
    • FormMessage がエラーを自動表示
  5. 5

    ステップ5: メインページを組み合わせ

    すべてのコンポーネントを組み合わせ:

    • DataTable + グローバル Dialog
    • Dialog 内に Form を配置
    • 送信後にリストデータを更新

FAQ

なぜ Dialog を DataTable cell 内にネストしてはいけないのか?
各行に Dialog インスタンスを作成するとパフォーマンスの問題が発生します。100行のデータで100個の Dialog になり、状態の統一管理も困難です。正しい方法は、グローバルな Dialog を1つ使い、Hook で状態を管理することです。
Form は React Hook Form と手動管理のどちらを使うべきか?
React Hook Form + Zod を強く推奨:

• 自動バリデーションとエラー表示
• 型安全(z.infer で自動推論)
• パフォーマンス向上(再レンダリング削減)
• FormMessage がエラーメッセージを自動表示
Context パターンはパフォーマンス問題を引き起こすか?
はい。Context 値が変更されると、すべての useContext コンポーネントが再レンダリングされます。解決策:

• Context を分離(状態 Context + 設定 Context)
• 状態に応答する必要があるコンポーネントのみ状態 Context を読み取り
• 変更のない設定は設定 Context に配置
prop drilling をどう回避するか?
Context パターンを使用:Root コンポーネントが Context を作成し、子コンポーネントが useContext で自動的に状態を取得。子コンポーネントは props を層を超えて渡す必要がなく、親コンポーネント内にあれば状態を取得できます。
shadcn/ui コンポーネントのソースコードを直接変更できるか?
推奨しません。正しいアプローチ:

• ラッパーコンポーネントを作成
• variants を使用してバリアントを定義
• theme でスタイルをカスタマイズ

ソースコードを直接変更すると、後続のアップグレードが困難になります。
Compound Components の TypeScript 型をどう定義するか?
完全な型定義を使用:

• Root/Header/Content の Props 型を定義
• FC<Props> でコンポーネントを制約
• 子が Root 内にない場合エラーを投げて警告

これにより TypeScript が props が正しいかチェックします。

6 min read · 公開日: 2026年4月1日 · 更新日: 2026年4月20日

関連記事

コメント

GitHubアカウントでログインしてコメントできます