5/13 ローンチ予定!
PiloTube

PiloTube 開発日誌

← 「ひとり社長のAI開発記」一覧へ

Next.jsのServer Componentで「onClick is not a function」——use clientつけ忘れが引き起こす地獄

約12分で読めます
Next.jsApp RouterServer Componentuse client開発秘話

onClickハンドラを書いた。コードに問題はないはずだ。ビルドも通る。

しかしボタンを押しても何も起きない。console.logを仕込んでも出力されない。エラーメッセージは「Event handlers cannot be passed to Client Component props.」——意味がよくわからない。

Next.js App Routerに本格移行したとき、このエラーで丸一日潰した。


Next.js App Routerの基本前提——全コンポーネントはデフォルトでServer Component

Next.js 13以降のApp Routerでは、app/ディレクトリ配下のコンポーネントはデフォルトでServer Componentとして扱われる

Server Componentはサーバー側でレンダリングされるため、ブラウザ側のAPIには一切アクセスできない。具体的には:

  • onClickonChangeなどのイベントハンドラが使えない
  • useStateuseEffectなどのhooksが使えない
  • windowdocumentなどのブラウザAPIが使えない

これらを使いたい場合は、ファイルの先頭に"use client"を宣言してClient Componentにする必要がある。

Pages RouterからApp Routerへ移行したとき、この前提変更につまずく人が多い。

Pages Routerでは全コンポーネントがデフォルトでClient Componentだった。移行後は意識的に"use client"を書かないといけない。


ハマりパターン1: onClick付きコンポーネントに"use client"がない

最もシンプルなミス。

// app/components/LikeButton.tsx
// ← "use client" がない!

export default function LikeButton() {
  const handleClick = () => {
    console.log('clicked'); // ← これが実行されない
  };

  return <button onClick={handleClick}>いいね</button>;
}

このコードはビルドが通る場合がある。しかし実行時にエラーが出るか、ボタンが押せるように見えても何も起きない。

修正:

"use client"; // ← 先頭に追加

export default function LikeButton() {
  const handleClick = () => {
    console.log('clicked');
  };

  return <button onClick={handleClick}>いいね</button>;
}

ハマりパターン2: useStateを使っているのに"use client"がない

useStateを使ったコンポーネントは必ずClient Componentにする必要がある。しかしエラーメッセージが分かりにくい。

// app/components/Counter.tsx

import { useState } from 'react'; // ← このimport自体はエラーにならない

export default function Counter() {
  const [count, setCount] = useState(0); // ← ここでエラー
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

出力されるエラー:

Error: useState only works in a Client Component.
Add the "use client" directive at the top of the file to use it.

このエラーは比較的分かりやすいが、エラーが開発サーバーのコンソールに出て、ブラウザには「何も起きない」だけの場合がある。ブラウザの開発者ツールを開いていないと気づけない。


ハマりパターン3: 親がServer Component、子にonClickを渡す

最も気づきにくいパターン。Server ComponentからClient Componentにイベントハンドラをprops経由で渡そうとするケース。

// app/page.tsx(Server Component)

export default function Page() {
  const handleClick = () => { // ← Server側で定義した関数
    console.log('clicked');
  };

  return <Button onClick={handleClick} />; // ← これがエラー
}

エラーメッセージ:

Error: Event handlers cannot be passed to Client Component props.
  <button onClick={function} children=...>
                  ^^^^^^^^^
If you need interactivity, consider converting part of this to a Client Component.

なぜエラーになるか: Server Componentでは関数をシリアライズ(JSON化)してクライアントに送れない。クリックハンドラはJavaScriptの関数なので、サーバーからクライアントへ渡す手段がない。

修正方針: インタラクションが必要な部分をClient Componentに分離する。

// app/components/Button.tsx
"use client";

export default function Button({ label }: { label: string }) {
  const handleClick = () => {
    console.log('clicked');
  };
  return <button onClick={handleClick}>{label}</button>;
}

// app/page.tsx(Server Component)
import Button from './components/Button';

export default function Page() {
  // ← ここでonClickを定義しない。Client Component側に移す
  return <Button label="クリック" />;
}

ハマりパターン4: コンポーネントツリーの途中でServer→Clientの境界を意識していない

App Routerでは、コンポーネントツリーに「Server / Client の境界」がある。

  • Server Component は子に Server ComponentClient Component も持てる
  • Client Component は子に Server Component を持てない(持とうとするとClient扱いになる)

これを理解していないと、"use client"を付けているのに期待通り動かないケースが出る。

// app/layout.tsx(Server Component)
import Nav from './Nav'; // Nav は "use client"

export default function Layout({ children }) {
  return (
    <html>
      <body>
        <Nav />
        {children} {/* ← children は Server Component のまま */}
      </body>
    </html>
  );
}
// app/Nav.tsx
"use client";

import SomeServerComponent from './SomeServerComponent';
// ← SomeServerComponent は Client として扱われる!

大原則: "use client"の境界より下のツリーは全部Client扱いになる。Server Componentはそのツリーの外からchildrenとして渡すことで組み合わせられる。


ハマりパターン5: ローカルでは動くが本番でhydrationエラー

ローカル開発環境では動いているのに、Vercelにデプロイした後にHydration failedエラーが出るケース。

原因のひとつはServer側のレンダリング結果とClient側のレンダリング結果が一致しないこと。

典型例: 日時の表示

"use client";

export default function Timestamp() {
  return <span>{new Date().toLocaleString()}</span>;
  // ↑ サーバーでレンダリングした時刻とクライアントでレンダリングした時刻が異なる
}

ローカルではSSRとhydrationのタイミング差が小さく、気づきにくい。本番環境ではサーバーとクライアントの処理タイミングの差が出やすく、hydrationエラーとして顕在化する。

修正: useEffectでクライアント側でのみ実行する。

"use client";
import { useState, useEffect } from 'react';

export default function Timestamp() {
  const [time, setTime] = useState('');

  useEffect(() => {
    setTime(new Date().toLocaleString());
  }, []);

  return <span>{time}</span>;
}

なぜApp RouterのServer Component設計が分かりにくいか

Pages Routerに慣れていた場合、「全コンポーネントがデフォルトでサーバーサイド」という発想の転換が難しい。

特につまずく点は3つある。

1. ビルドが通るのに実行時にエラーになる

TypeScriptの型チェックやnext buildの段階ではエラーにならないケースが多い。「ビルドが通ったから大丈夫」という思い込みが危険。

2. エラーメッセージが初見では意味不明

Event handlers cannot be passed to Client Component props. useState only works in a Client Component.

どちらも初めて見ると何を言われているか分からない。「Client Component って何?」という状態だと解決の糸口をつかめない。

3. 部分的に直すと別の場所でエラーが出る

"use client"を追加すると、そのコンポーネントがimportしている他のコンポーネントも影響を受ける。一箇所直したら別の場所でエラー、という連鎖が起きやすい。


実装前に決めておくべき分類

App Routerで開発する際、コンポーネントを設計するときに「これはServer か Client か」を先に決める習慣をつけると詰まりにくくなる。

| 分類 | 条件 | "use client" | |------|------|-------------| | Server Component | データフェッチのみ、インタラクションなし | 不要 | | Client Component | onClick / useState / useEffect を使う | 必要 | | 混在 | データフェッチ + インタラクション | 分離推奨 |

推奨パターン: データフェッチはServer Component、インタラクション部分はClient Componentに分離する。

Page(Server)
├── UserProfile(Server)← DBからデータ取得
├── UserStats(Server)← DBからデータ取得
└── EditButton(Client)← onClick処理

Server ComponentとClient Componentを適切に分けることで、クライアントに送るJavaScriptのバンドルサイズを小さく保てる。これはパフォーマンスにも効いてくる。


チェックリスト

App Routerでonclick や useState が動かないとき、以下の順に確認する。

  • [ ] そのコンポーネントのファイル先頭に"use client"があるか
  • [ ] 親コンポーネントからイベントハンドラをpropsで渡していないか
  • [ ] Client Component がServer Componentを子としてimportしていないか
  • [ ] ブラウザの開発者ツールでhydrationエラーが出ていないか
  • [ ] サーバーとクライアントで異なる値を出力するコードがないか

まとめ

| パターン | 症状 | 解決策 | |---------|------|--------| | "use client"なしのonClick | クリックしても反応なし | ファイル先頭に"use client"追加 | | "use client"なしのuseState | コンソールにエラー、画面に変化なし | 同上 | | Server→ClientへのonClick props渡し | ビルドエラー or 実行時エラー | Click処理をClient Component側に移す | | SSR/Hydration不一致 | 本番デプロイ後にエラー | useEffectでクライアント側のみ実行 |

App RouterへのPages Router移行は、コンポーネントの「動く場所」に関する前提が根本的に変わる。"use client"の付与漏れは一度痛い目を見ると忘れなくなるが、最初の一回目が辛い。この記事がその一回目を乗り越える助けになれば。

チャプター生成AI

URL貼るだけ。AIがチャプターを自動生成。

1

YouTubeのURLをコピーして貼る

2

「生成する」を押す

3

概要欄にコピペして完了

無料でチャプターを生成する →

月3回まで無料 · クレジットカード不要