0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

'use client' はファイル単位の指定ではなく、境界の宣言です

0
Posted at

Next.js の App Router で、AIに「このボタンにクリック処理を足して」と頼む。返ってきたコードを貼ると、こう言われる。

Error: Event handlers cannot be passed to Client Component props.
  <button onClick={function onClick} children=...>
                  ^^^^^^^^^^^^^^^^^^

If you need interactivity, consider converting part of this to a Client Component.

エラーメッセージが「Client Component にしろ」と言っているので、AIに貼ると 'use client' を足した修正が返ってきます。動きます。

問題は、それをどのファイルの先頭に足したかです。

まず、どこまで巻き込まれるのか

まず前提から。App Router では、コンポーネントはデフォルトで Server Component です。'use client' はそのファイルを Client Component にする指定ですが、効果はそのファイルだけに閉じません。

'use client' を書いたファイルから import されるモジュールは、すべてクライアント側のバンドルに含まれます。

つまり 'use client' は「このファイルをクライアントにする」ではなく、「ここから先はクライアント側」という境界の宣言です。

app/page.tsx            ← Server('use client' なし)
 └─ components/Dashboard.tsx   ← ここに 'use client' を書くと
     ├─ components/Chart.tsx        ← 一緒にクライアントへ
     ├─ components/Table.tsx        ← 一緒にクライアントへ
     └─ lib/formatCurrency.ts       ← これも一緒にクライアントへ

境界を上に置くほど、巻き込む範囲が広くなります。

何が起きるか

巻き込みが起きても、画面が普通に動いてしまうことがあります。だから気づきません(ただし next/headersfs を使うファイルを巻き込むとビルドエラーになりますし、page.tsx を client にすると export const metadata が使えなくなります。気づけないのは、たまたま両方で動くコードだけを巻き込んだときです)。起きるのは次の3つです。

1. バンドルサイズが増える

サーバー側だけで完結していたはずのコードが、クライアントへ送られます。日付フォーマット用の重いライブラリなどを巻き込むと、そのぶん初回表示に効いてきます。

2. サーバー専用の処理が使えなくなる

境界の内側では、async なコンポーネントとしてサーバーで直接データを取る書き方ができません。データ取得をクライアント側の useEffect に移す修正が入って、表示までのラウンドトリップが1往復増える、という展開になりがちです。

3. サーバー側の値を書いたつもりが、クライアントに露出する

これが一番まずいパターンです。'use client' を付けたファイルの中では、NEXT_PUBLIC_ の付かない環境変数はブラウザ側から読めず undefined になります。問題はその先で、「undefined になるなら」と NEXT_PUBLIC_ を付けて解決してしまうと、その値はビルド時にクライアントのコードへ埋め込まれ、ブラウザから誰でも読める状態になります。環境変数に関して言えば、露出が起きるのは境界を動かした時点ではなく、動かなくなったのを NEXT_PUBLIC_ で直した時点です。

(厳密には、'use client' を付けたファイルも初回はサーバー側で一度描画されます。そこで読んだ値を画面に出すと、HTML に乗って見えることはあります)

'use client';

// ❌ NEXT_PUBLIC_ が付いていない環境変数はクライアントでは undefined になる。
//    そして「動かないから」と NEXT_PUBLIC_ を付けて解決すると、
//    今度は本当にブラウザから見える値になる
const KEY = process.env.API_SECRET_KEY;

正しい直し方は、その値を使う処理をサーバー側(Server Component / Route Handler / Server Actions)に残して、結果だけをクライアントへ渡すことです。値そのものを境界の内側へ持ち込まない、という形にします。

Before:ページの先頭に付ける

AIにエラー文だけ渡すと、一番手前のファイルに 'use client' が付くことがあります。エラーが消えるので、その場では正解に見えます。

// app/dashboard/page.tsx
'use client';                        // ← ここに付いた

import { useEffect, useState } from 'react';
import Chart from '@/components/Chart';
import Table from '@/components/Table';
import { fetchSummary, type Summary } from '@/lib/summary';

export default function DashboardPage() {
  const [open, setOpen] = useState(false);
  const [summary, setSummary] = useState<Summary | null>(null);

  // サーバーで取れていたはずのデータを、クライアントから取り直すことになる
  useEffect(() => {
    fetchSummary().then(setSummary);
  }, []);

  return (
    <div>
      <button onClick={() => setOpen(true)}>開く</button>
      {open && <p>開きました</p>}
      {summary && (
        <>
          <Chart data={summary} />
          <Table data={summary} />
        </>
      )}
    </div>
  );
}

この場合、ChartTablelib/summary も、まとめてクライアント側に入ります。実際にクリック処理が要るのは button だけなのに、です。

(なお lib/summary が DB に直接アクセスしていたら、そもそもクライアントからは動きません。その場合は API 経由に作り替える修正まで連鎖します)

After:対話が必要な部分だけ切り出す

境界は、インタラクションが必要な一番小さい範囲まで下ろします。

// components/OpenButton.tsx
'use client';

import { useState } from 'react';

export default function OpenButton() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(true)}>開く</button>
      {open && <p>開きました</p>}
    </>
  );
}
// app/dashboard/page.tsx   ← 'use client' なし(Server のまま)
import OpenButton from '@/components/OpenButton';
import Chart from '@/components/Chart';
import Table from '@/components/Table';
import { fetchSummary } from '@/lib/summary';

export default async function DashboardPage() {
  const summary = await fetchSummary();     // サーバーで直接取れる
  return (
    <div>
      <OpenButton />
      <Chart data={summary} />
      <Table data={summary} />
    </div>
  );
}

ページは Server のままなので、Chart / Table / lib/summary はクライアントに送られません。データ取得もサーバー側で完結します。

なお、チャート系のライブラリのように Chart 自体が Client Component になる場合もあります。その場合は Chart 側に境界が付くだけで、ページと Tablelib/summary は Server のまま保てます。

ただし、Server から Client へ props で渡した値はそのままブラウザに送られて読めます。境界をまたいで渡すデータに秘密の情報を含めないでください(上の例なら summary の中身です)。

もう一つ、Server Component は Client Component の children として渡すこともできます。レイアウト的に囲みたいだけなら、この形で境界を広げずに済みます。

// app/dashboard/page.tsx(Server Component)← この JSX を「どこに書くか」が条件
import ClientAccordion from '@/components/ClientAccordion'; // 'use client' あり
import ServerHeavyList from '@/components/ServerHeavyList'; // 'use client' なし

export default function Page() {
  return (
    <ClientAccordion>
      <ServerHeavyList />
    </ClientAccordion>
  );
}

⚠️ 逆に、この組み立てを ClientAccordion.tsx の中に書いて ServerHeavyList を import すると、import 経由で境界の内側に入るのでサーバーのままにはなりません。合成する場所が Server Component 側であることが条件です。

AIに頼むときの言い方

エラー文だけ貼ると、AIは「エラーが消える最短の場所」に 'use client' を置きます。前提を足すと変わります。

このエラーを直したいのですが、条件があります。

- ページ全体は Server Component のまま維持したい
- 'use client' は、インタラクションが必要な最小のコンポーネントにだけ付けたい
- そのために新しいファイルを切り出してもらって構いません
- 切り出した結果、クライアントバンドルに入るファイルがどれになるかも教えてください

この方針で修正案を出してください。

最後の1行があると、境界がどこに引かれたかを毎回確認できます。

今の境界を確認する

すでに書いてしまったコードで境界がどこにあるかを見るには、'use client' の位置を出すのが手っ取り早いです。

grep -rn "use client" app components --include="*.tsx" --include="*.ts"
# src/ 構成のプロジェクトなら
grep -rn "use client" src --include="*.tsx" --include="*.ts"

出てきたファイルが境界です。ページやレイアウトのような上流のファイルが並んでいたら、下ろせる余地があります

2点だけ注意があります。'use client' は**ファイルの先頭(import より前)**に書かないと効かないので、行番号が先頭付近でないものは効いていない可能性があります。また、コメントや文字列の中の記述も拾うので、出てきた行は一度目視してください。

ビルド後の実測で見たい場合は、Next.js のビルド出力に出る各ルートの First Load JS を、境界を下ろす前後で比べると差が分かります。

まとめ

  • 'use client' はファイル単位の指定ではなく、そこから下がクライアントになる境界
  • 上流に付けると、import 先まで巻き込んでクライアントに送られる
  • 巻き込んでも画面が動いてしまうことがあるので気づきにくい。効くのはバンドルサイズ・データ取得の形・NEXT_PUBLIC_ で直したときの環境変数の露出
  • 境界をまたいで props で渡した値はブラウザから読める(秘密の情報を含めない)
  • 直すときは「ページは Server のまま」「最小コンポーネントに切り出す」「入るファイルを教えて」の3点を先に伝える
  • grep -rn "use client" で今の境界を確認できる

未経験者向けの講座を運営しています

未経験から Next.js + Supabase + Claude Code で Webアプリを公開するまで を、全20セッションで体系化した教材です。Claude Code を学習パートナーにする CLAUDE.md と学習モード(learner / developer)の設計までセットで含みます。

※ この教材は Qiita 読者の方には易しすぎる内容なので、初心者の知り合いへの紹介や社内研修の参考としてどうぞ。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?