はじめに
shadcn/ui は Radix UI + Tailwind CSS をベースにした人気のコンポーネントライブラリです。しかし Next.js App Router と組み合わせると、以下のようなエラーで詰まるケースが後を絶ちません。
Error: You're importing a component that needs useState.
It only works in a Client Component but none of its parents are marked with "use client",
so they're all Server Components by default.
または:
TypeError: Cannot read properties of null (reading 'useContext')
「どこに “use client” を追加すればいいのか分からない」という状況になりがちです。この記事では、よくある5つの原因と正確な対処法をコード例付きで解説します。
症状チェック:こんな症状はありませんか?
- shadcn/ui の
ButtonやDialogを配置したら急にエラーが出た npx shadcn@latest addでインストールしたコンポーネントをそのまま Server Component で使っているlayout.tsxにToasterやThemeProviderを置いたらビルドが壊れた- エラーメッセージに
useStateやuseEffectが含まれているが、自分のコードでは使っていない - ページコンポーネントに “use client” を付けたら解決したが、なぜかが分からない
前提知識:App Router の Server / Client Component
Next.js App Router では、全コンポーネントはデフォルトで Server Component です。ファイルの先頭に "use client" ディレクティブを書いたファイルだけが Client Component になります。
shadcn/ui のコンポーネントは内部で React フック(useState・useRef・useContext など)や DOM API を使っているものが多く、それらは Client Component でしか動きません。
原因1:インタラクティブな shadcn/ui コンポーネントを Server Component で直接使っている
Dialog・DropdownMenu・Popover など、インタラクション(開閉・フォーカス管理)を伴うコンポーネントは内部で大量の React フックを使っています。これらを Server Component で直接インポートすると冒頭のエラーが発生します。
// ❌ エラーになるパターン
// app/page.tsx(Server Component)
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"
export default function Page() {
return (
<Dialog>
<DialogTrigger>開く</DialogTrigger>
<DialogContent>内容</DialogContent>
</Dialog>
)
}
解決策:ラップした Client Component を作る
// components/confirm-dialog.tsx
"use client"
import { useState } from "react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
interface ConfirmDialogProps {
title: string
description: string
}
export function ConfirmDialog({ title, description }: ConfirmDialogProps) {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">開く</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<p>{description}</p>
</DialogContent>
</Dialog>
)
}
// app/page.tsx(Server Component のまま)
import { ConfirmDialog } from "@/components/confirm-dialog"
export default function Page() {
return <ConfirmDialog title="確認" description="本当に削除しますか?" />
}
原因2:Toaster や ThemeProvider を layout.tsx に直接置いてエラー
shadcn/ui の Toaster(sonner / toast)や next-themes の ThemeProvider は Client Component です。layout.tsx に直接置いて "use client" を追加してしまうと、レイアウト全体が Client Component になってしまいます。
// ❌ 問題のあるパターン
// app/layout.tsx
"use client" // ← これを追加してしまうと全体が Client Component に!
import { Toaster } from "@/components/ui/toaster"
import { ThemeProvider } from "next-themes"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<ThemeProvider>
{children}
<Toaster />
</ThemeProvider>
</body>
</html>
)
}
レイアウト全体が Client Component になると、Server Component の恩恵(サーバーサイドのデータフェッチ・バンドルサイズ削減など)が失われます。
解決策:Provider をまとめた専用 Client Component を作る
// components/providers.tsx
"use client"
import { ThemeProvider } from "next-themes"
import { Toaster } from "@/components/ui/toaster"
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
<Toaster />
</ThemeProvider>
)
}
// app/layout.tsx("use client" なし、Server Component のまま)
import { Providers } from "@/components/providers"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ja" suppressHydrationWarning>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
children として渡された Server Component は、Providers(Client Component)にバンドルされるのではなく、サーバー側でレンダリングされた結果が渡されます。この仕組みにより、layout.tsx は Server Component のまま維持できます。
原因3:onClick などのイベントハンドラを Server Component で渡している
Server Component では、関数をイベントハンドラとして Props に渡せません。関数はシリアライズできない(JSON に変換できない)ためです。
// ❌ エラーになるパターン
// app/page.tsx(Server Component)
import { Button } from "@/components/ui/button"
export default function Page() {
const handleClick = () => alert("clicked") // 関数は Props として渡せない
return <Button onClick={handleClick}>クリック</Button>
// Error: Event handlers cannot be passed to Client Component props.
}
解決策:インタラクションを含む部分を Client Component に切り出す
// components/delete-button.tsx
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
interface DeleteButtonProps {
itemId: string
}
export function DeleteButton({ itemId }: DeleteButtonProps) {
const [loading, setLoading] = useState(false)
const handleDelete = async () => {
setLoading(true)
await fetch(`/api/items/${itemId}`, { method: "DELETE" })
setLoading(false)
}
return (
<Button variant="destructive" onClick={handleDelete} disabled={loading}>
{loading ? "削除中..." : "削除"}
</Button>
)
}
// app/items/page.tsx(Server Component のまま)
import { DeleteButton } from "@/components/delete-button"
async function getItems() {
return fetch("https://api.example.com/items").then(r => r.json())
}
export default async function ItemsPage() {
const items = await getItems()
return (
<ul>
{items.map((item: { id: string; name: string }) => (
<li key={item.id}>
{item.name}
<DeleteButton itemId={item.id} />
</li>
))}
</ul>
)
}
原因4:useToast フックを Server Component 側のロジックで呼び出している
shadcn/ui の Toast 通知を使う場合、useToast() フックを呼び出す必要があります。これは React フックなので Client Component 専用です。
// ❌ エラーになるパターン
// app/page.tsx(Server Component)
import { useToast } from "@/components/ui/use-toast"
export default function Page() {
const { toast } = useToast() // Error: hooks は Server Component で使えない
return <button onClick={() => toast({ title: "成功" })}>送信</button>
}
解決策:フォーム送信処理は Client Component に移す
// components/contact-form.tsx
"use client"
import { useToast } from "@/components/ui/use-toast"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
export function ContactForm() {
const { toast } = useToast()
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const res = await fetch("/api/contact", {
method: "POST",
body: formData,
})
if (res.ok) {
toast({ title: "送信完了", description: "お問い合わせを受け付けました。" })
} else {
toast({ title: "エラー", description: "送信に失敗しました。", variant: "destructive" })
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<Input name="email" type="email" placeholder="メールアドレス" required />
<Button type="submit">送信</Button>
</form>
)
}
Server Actions を使う場合は、useActionState(React 19)や useFormState(React 18)と組み合わせてトーストを表示するパターンも有効です。
原因5:asChild + Slot パターンでServer Component の子を渡している
shadcn/ui の多くのコンポーネントは asChild プロパティをサポートしており、Radix UI の Slot コンポーネントで実装されています。基本的な使い方(Button asChild + Link)は App Router でも動きますが、Server Component を子として Slot に渡すと予期しない問題が起きるケースがあります。
// ⚠️ 注意が必要なパターン
import { Button } from "@/components/ui/button"
// Server Component 内の非同期コンポーネントを asChild で渡す
async function AsyncLabel() {
const text = await fetch("...").then(r => r.text())
return <span>{text}</span>
}
export default function Page() {
return (
<Button asChild>
<AsyncLabel /> {/* Slot + 非同期 Server Component の組み合わせは不安定 */}
</Button>
)
}
解決策:asChild は Client Component 内で使うか、シンプルな要素のみ渡す
// components/nav-button.tsx
"use client"
import { Button } from "@/components/ui/button"
import Link from "next/link"
interface NavButtonProps {
href: string
children: React.ReactNode
}
export function NavButton({ href, children }: NavButtonProps) {
return (
<Button asChild>
<Link href={href}>{children}</Link>
</Button>
)
}
// app/page.tsx(Server Component)
import { NavButton } from "@/components/nav-button"
export default function Page() {
return <NavButton href="/about">詳細を見る</NavButton>
}
まとめ:Server / Client Component 境界の基本ルール
shadcn/ui + Next.js App Router で「動かない」問題を解決するための基本ルールをまとめます。
| 状況 | 対処法 |
|---|---|
| Dialog・Dropdown など開閉インタラクションがあるコンポーネントを使う | ラップした Client Component を作成する |
useToast・useState・useEffect を使う |
そのファイルに "use client" を追加 |
| Provider を layout.tsx に置きたい | providers.tsx(Client Component)でまとめて children を渡す |
onClick などのイベントハンドラを渡す |
インタラクション部分を Client Component に切り出す |
asChild + Slot パターン |
Client Component 内で使う |
デバッグの手順
- エラーメッセージに含まれる React API(
useState,useEffect,useContextなど)を確認する - そのAPIを使っているファイルを特定する(shadcn/ui コンポーネント内部の場合もある)
- そのコンポーネントをラップする Client Component を作成する、または使用箇所のファイルに
"use client"を追加する "use client"の追加範囲を最小限にする(できるだけ葉に近いコンポーネントに付ける)
「Client Component の children は Server Component になれる」という重要な仕組み
// components/interactive-wrapper.tsx
"use client"
import { useState } from "react"
export function InteractiveWrapper({ children }: { children: React.ReactNode }) {
const [count, setCount] = useState(0)
return (
<div>
<button onClick={() => setCount(c => c + 1)}>カウント: {count}</button>
{children} {/* ここに Server Component を渡せる! */}
</div>
)
}
// app/page.tsx
import { InteractiveWrapper } from "@/components/interactive-wrapper"
async function ServerContent() {
const data = await fetch("https://api.example.com/data").then(r => r.json())
return <p>{data.message}</p>
}
export default function Page() {
return (
<InteractiveWrapper>
<ServerContent /> {/* Server Component を Client Component の children に渡せる */}
</InteractiveWrapper>
)
}
children として渡された ServerContent はサーバーサイドでレンダリングされた結果が InteractiveWrapper に注入されます。Client Component のバンドルには含まれないため、Server Component の恩恵(非同期データフェッチ、バンドルサイズ削減)を保持できます。
この仕組みを理解することで、shadcn/ui + App Router を最大限に活用しつつ、パフォーマンスと開発体験の両方を維持できます。
