← 記事一覧に戻る

shadcn/ui が Next.js App Router で動かない・"use client" エラーになる時の5つの原因と対策

はじめに

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 の ButtonDialog を配置したら急にエラーが出た
  • npx shadcn@latest add でインストールしたコンポーネントをそのまま Server Component で使っている
  • layout.tsxToasterThemeProvider を置いたらビルドが壊れた
  • エラーメッセージに useStateuseEffect が含まれているが、自分のコードでは使っていない
  • ページコンポーネントに “use client” を付けたら解決したが、なぜかが分からない

前提知識:App Router の Server / Client Component

Next.js App Router では、全コンポーネントはデフォルトで Server Component です。ファイルの先頭に "use client" ディレクティブを書いたファイルだけが Client Component になります。

shadcn/ui のコンポーネントは内部で React フック(useStateuseRefuseContext など)や DOM API を使っているものが多く、それらは Client Component でしか動きません。


原因1:インタラクティブな shadcn/ui コンポーネントを Server Component で直接使っている

DialogDropdownMenuPopover など、インタラクション(開閉・フォーカス管理)を伴うコンポーネントは内部で大量の 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:ToasterThemeProvider を layout.tsx に直接置いてエラー

shadcn/ui の Toaster(sonner / toast)や next-themesThemeProvider は 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 を作成する
useToastuseStateuseEffect を使う そのファイルに "use client" を追加
Provider を layout.tsx に置きたい providers.tsx(Client Component)でまとめて children を渡す
onClick などのイベントハンドラを渡す インタラクション部分を Client Component に切り出す
asChild + Slot パターン Client Component 内で使う

デバッグの手順

  1. エラーメッセージに含まれる React API(useState, useEffect, useContext など)を確認する
  2. そのAPIを使っているファイルを特定する(shadcn/ui コンポーネント内部の場合もある)
  3. そのコンポーネントをラップする Client Component を作成する、または使用箇所のファイルに "use client" を追加する
  4. "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 を最大限に活用しつつ、パフォーマンスと開発体験の両方を維持できます。