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><CardHeader><CardTitle><CardContent> というネスト構造で、Dialog は必ず <Dialog><DialogContent><DialogHeader><DialogTitle> です。
この統一されたインターフェースの利点:複数のコンポーネントを組み合わせる際、どこでネストすべきか、どこで並列すべきかがわかります。「このコンポーネントはあれを包む必要があるけど、あれは外側にあることを要求する」といった矛盾が発生しません。
基本組み合わせ:Dialog + Form
最も一般的な組み合わせシナリオ:モーダルの中にフォームを入れる。
ユーザーが「編集」ボタンをクリックすると Dialog が開き、中に Form があり、入力して送信すると Dialog が閉じる。単純に聞こえますが、最初に書いた時、私は間違いを犯していました:Dialog と Form の状態を混在させてしまったのです。
間違ったアプローチ
// ❌ これが私の失敗バージョン
function EditUserDialog() {
const [open, setOpen] = useState(false)
const [formData, setFormData] = useState{{}}
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({...formData, username: e.target.value})}
/>
<Button type="submit">保存</Button>
</form>
</DialogContent>
</Dialog>
)
}
問題はどこに?Dialog の open 状態と Form のデータ状態が1つのコンポーネントに混在し、さらに React Hook Form を使わずに手動で form 状態を管理していたため、バリデーションやエラー表示が乱雑でした。
正しいアプローチ
shadcn/ui の Form コンポーネントは React Hook Form + Zod をベースにしており、この組み合わせを使うとコードがかなりきれいになります:
// ✅ 正しい組み合わせ方法
import { useForm } from "react-hook-form"
import { zodResolver } 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({ user, onSubmit }) {
const [open, setOpen] = useState(false)
const form = useForm({
resolver: zodResolver(userSchema),
defaultValues: user // ユーザーデータを直接渡す
})
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">編集</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>ユーザー情報を編集</DialogTitle>
</DialogHeader>
{/* Form で shadcn の Form コンポーネントを直接使用 */}
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => {
onSubmit(data) // データを送信
setOpen(false) // Dialog を閉じる
})}>
<FormField
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>ユーザー名</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage /> {/* エラーを自動表示 */}
</FormItem>
)}
/>
<Button type="submit">保存</Button>
</form>
</Form>
</DialogContent>
</Dialog>
)
}
重要なポイント:
- Dialog はコンテナ、Form はコンテンツ:Dialog は「開く/閉じる」のみを管理し、Form は「データ/バリデーション/送信」を管理し、責任を分離します。
- Form は React Hook Form + Zod を使用:form 状態を手動で管理せず、form.handleSubmit がバリデーションと送信を自動処理します。
- 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 = [
{
id: "actions",
cell: { row } => (
<Dialog>
<DialogTrigger asChild>
<Button>編集</Button>
</DialogTrigger>
<DialogContent>
{/* 問題:各 cell レンダリングで Dialog インスタンスが作成される */}
<EditForm user={row.original} />
</DialogContent>
</Dialog>
)
}
]
このアプローチの問題:各行が Dialog インスタンスを作成し、100行のデータで100個の Dialog になり、パフォーマンスが非常に悪くなります。また、Dialog の状態を統一的に管理するのが困難です。
正しいアプローチ
グローバルな Dialog を1つ使い、Hook で状態を管理:
// 1. まず Dialog 状態を管理する Hook を定義
const useEditDialog = () => {
const [open, setOpen] = useState(false)
const [editingUser, setEditingUser] = useState(null)
const openEdit = (user) => {
setEditingUser(user)
setOpen(true)
}
const closeEdit = () => {
setOpen(false)
setEditingUser(null)
}
return { open, editingUser, openEdit, closeEdit }
}
// 2. DataTable 列定義にはトリガーボタンのみ配置
function UserDataTable({ users }) {
const { open, editingUser, openEdit, closeEdit } = useEditDialog()
const columns = [
{
id: "actions",
cell: { row } => (
<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>
)
}
]
return (
<>
<DataTable columns={columns} data={users} />
{/* グローバルで唯一の Dialog */}
<Dialog open={open} onOpenChange={(o) => !o && closeEdit()}>
<DialogContent>
<EditUserForm
user={editingUser}
onSubmit={(data) => {
updateUser(data)
closeEdit()
refreshTable() // テーブルデータを更新
}}
/>
</DialogContent>
</Dialog>
</>
)
}
重要なポイント:
- グローバル Dialog:DataTable の外に1つの Dialog を配置し、各行で作成するのではなく。
- Hook で状態管理:openEdit が Dialog を開きデータを渡し、closeEdit が閉じてデータをクリア。
- 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 { createContext, useContext, useState } from "react"
type CardContextValue = {
isCollapsed: boolean
toggle: () => void
}
const CardContext = createContext<CardContextValue | null>(null)
// 2. Root コンポーネント:状態を管理、Context を提供
CollapsibleCard.Root = { children, defaultCollapsed = false } => {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
return (
<CardContext.Provider value={{
isCollapsed,
toggle: () => setIsCollapsed(!isCollapsed)
}}>
<Card className="border rounded-lg">{children}</Card>
</CardContext.Provider>
)
}
// 3. Header コンポーネント:タイトル + 折りたたみボタンを表示
CollapsibleCard.Header = { title } => {
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>{title}</CardTitle>
{ctx.isCollapsed ? <ChevronDown /> : <ChevronUp />}
</div>
</CardHeader>
)
}
// 4. Content コンポーネント:折りたたみ状態に応答
CollapsibleCard.Content = { children } => {
const ctx = useContext(CardContext)
if (!ctx) throw new Error("Content must be in CollapsibleCard.Root")
if (ctx.isCollapsed) return null // 折りたたまれている時は表示しない
return <CardContent>{children}</CardContent>
}
使用例:
<CollapsibleCard.Root defaultCollapsed={false}>
<CollapsibleCard.Header title="ユーザー情報" />
<CollapsibleCard.Content>
<p>名前:田中太郎</p>
<p>メール:test@example.com</p>
</CollapsibleCard.Content>
</CollapsibleCard.Root>
重要なポイント:
- Context が状態を共有:Root コンポーネントが Context を作成し、子コンポーネントは useContext で自動的に状態を取得、prop drilling が不要。
- 子コンポーネントが自動応答:Header のクリックで状態を切り替え、Content が自動的に表示/非表示、両者は直接通信する必要がありません。
- 親コンポーネントの制約を強制:子コンポーネントが Root 内にない場合、エラーを投げて警告します。
このパターンの利点:複数のコンポーネントを組み合わせる際、状態をどう渡すか心配する必要がありません。子コンポーネントが親コンポーネント内にあれば、自動的に状態を取得できます。
完全なシナリオ:DataTable + Dialog + Form
これまで学んだことを組み合わせて、完全なユーザー管理ページを作成しましょう:DataTable で一覧を表示し、「編集」をクリックすると Dialog が開き、Dialog 内に Form があり、送信後にテーブルを更新します。
完全なコード例は上記の各セクションを参照してください。ここでは重要なフローをまとめます:
- Schema 定義:Zod でユーザーデータ構造とバリデーションルールを定義
- Dialog 状態 Hook:Dialog の開く/閉じるとデータ受け渡しを統一的に管理
- DataTable 列定義:DropdownMenu 操作列を含む
- 編集フォームコンポーネント:Form + FormField + 各種 Input
- メインページコンポーネント: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<{ isCollapsed: boolean }>()
// 設定 Context(変更なし)
const CardConfigContext = createContext<{ collapsible: boolean }>()
// Root コンポーネントが2つの Context を提供
CollapsibleCard.Root = { children, collapsible = true, defaultCollapsed = false } => {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
return (
<CardConfigContext.Provider value={{ collapsible }}>
<CardStateContext.Provider value={{ isCollapsed }}>
<Card>
{children}
{/* Toggle ボタンをここに单独で配置、Context 変更が子コンポーネントに影響しないように */}
{collapsible && (
<button onClick={() => setIsCollapsed(!isCollapsed)}>
{isCollapsed ? "展開" : "折りたたみ"}
</button>
)}
</Card>
</CardStateContext.Provider>
</CardConfigContext.Provider>
)
}
Header は CardConfigContext のみ読み取り(変更なし、再レンダリングなし)、Content は CardStateContext のみ読み取り(折りたたみ時のみ再レンダリング)。
TypeScript 型安全性
Compound Components の型定義は慎重に行う必要があります。そうしないと、TypeScript が「子コンポーネントが親コンポーネント内にない可能性がある」とエラーを出します。
// 完全な型定義
type CollapsibleCardProps = {
children: React.ReactNode
defaultCollapsed?: boolean
collapsible?: boolean
}
type CollapsibleCardComponents = {
Root: FC<CollapsibleCardProps>
Header: FC<{ title: string }>
Content: FC<{ children: React.ReactNode }>
}
const CollapsibleCard: CollapsibleCardComponents = {
Root: { children, defaultCollapsed = false, collapsible = true } => {
// ...
},
Header: { title } => {
// ...
},
Content: { children } => {
// ...
}
}
これにより、使用時に 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: Zod Schema を定義
コンポーネント外でデータ構造バリデーションを定義:
• z.object() でフィールドを定義
• バリデーションルールを追加(min、email、enum)
• schema と type を export - 2
ステップ2: Dialog 状態 Hook を作成
Dialog 状態を統一的に管理:
• useState で open と editingUser を管理
• openDialog で開いてデータを渡す
• closeDialog で閉じてデータをクリア - 3
ステップ3: DataTable 列を定義
columns に操作列を追加:
• cell 内に DropdownMenu を配置
• onClick で openDialog(row.original) を呼び出し
• Dialog を cell 内にネストしない - 4
ステップ4: 編集フォームコンポーネントを作成
shadcn Form コンポーネントを使用:
• useForm + zodResolver
• FormField + FormControl
• FormMessage がエラーを自動表示 - 5
ステップ5: メインページを組み合わせ
すべてのコンポーネントを組み合わせ:
• DataTable + グローバル Dialog
• Dialog 内に Form を配置
• 送信後にリストデータを更新
FAQ
なぜ Dialog を DataTable cell 内にネストしてはいけないのか?
Form は React Hook Form と手動管理のどちらを使うべきか?
• 自動バリデーションとエラー表示
• 型安全(z.infer で自動推論)
• パフォーマンス向上(再レンダリング削減)
• FormMessage がエラーメッセージを自動表示
Context パターンはパフォーマンス問題を引き起こすか?
• Context を分離(状態 Context + 設定 Context)
• 状態に応答する必要があるコンポーネントのみ状態 Context を読み取り
• 変更のない設定は設定 Context に配置
prop drilling をどう回避するか?
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日
Tailwind と shadcn/ui 実践ガイド
検索からこのページに来た場合は、前後の記事もあわせて読むと同じテーマの理解がかなり早く深まります。
前の記事
Tailwind ダークモード:class と data-theme の比較
Tailwind CSS ダークモードの class と data-theme 2つの戦略を比較。実装原理、設定方法、フレームワーク統合の実践を網羅し、プロジェクトに最適な選択をサポート
第 5 / 11 記事
次の記事
Dialog、Sheet、Popover:オーバーレイコンポーネントのアクセシビリティとフォーカス管理
shadcn/uiのDialog、Sheet、Popoverの3つのオーバーレイコンポーネントのアクセシビリティ実装とフォーカス管理を深掘り。WCAG標準、ARIA属性、キーボードナビゲーション、フォーカストラップを完全なコード例で解説
第 7 / 11 記事
関連記事
Tailwind v4 + Vite:5分で完了する完全設定テンプレート
Tailwind v4 + Vite:5分で完了する完全設定テンプレート
shadcn/ui インストールとテーマカスタマイズ完全ガイド(CSS変数付き)
shadcn/ui インストールとテーマカスタマイズ完全ガイド(CSS変数付き)
shadcn/uiで管理画面の骨組みを構築:Sidebar + Layout ベストプラクティス

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