この記事はシリーズ「動いているリポジトリを読む — Next.js 16 で学ぶクリーンアーキテクチャと TDD」の第 5 回(全 19 回)です。
実際に動いている公開リポジトリ kai-kou/gem-hunter(MIT)を1本まるごと読み解く連載です。架空のサンプルではなく実ファイルを引用し、掲載しているコマンド結果はすべて実際に動かして採取しています。
全体を通しで読みたい方へ: 同じ内容を 1 冊にまとめた Zenn Book を無料で公開しています。
シリーズ全体の目次
- 第 1 回 動いているリポジトリを読むという学び方
- 第 2 回 clone からテストが緑になるまで
- 第 3 回 要件IDとADRで、何を解くアプリなのかを地図にする
- 第 4 回 App Router の地図。フォルダがそのまま URL になる
- 第 5 回 'use client' が6ファイルしかないアプリの境界の引き方(この記事)
- 第 6 回 「クリーンアーキテクチャだから」を理由にしない層の分け方
- 第 7 回 ブランド型で「検証済みの値」を型にする
- 第 8 回 DIコンテナを使わない依存性逆転
- 第 9 回 外部APIの語彙を持ち込ませない翻訳層の作り方
- 第 10 回 依存規則を440行のPythonで機械検査する
- 第 11 回 URLのクエリが画面に出るまでを5層ぶん追跡する
- 第 12 回 ライブラリなしのi18nと、画面の変化を目で見ていない人へ伝える実装
- 第 13 回 どの層を何でテストするかを表で決めておく(公開予定)
- 第 14 回 失敗するテストを先に書いて Red→Green を1周する(公開予定)
- 第 15 回 「実ネットワークに出ない」を設定1行で保証する(公開予定)
- 第 16 回 外部APIに依存しないE2Eの組み方(公開予定)
- 第 17 回 赤くなったテストをどう判定するか、axeの限界とflakyの正体(公開予定)
- 第 18 回 生IPを残さないレート制限と、第三者HTMLを安全に表示する(公開予定)
- 第 19 回 CIは道具ではなくゲートの集合、そして自分のプロジェクトへの持ち帰り方(公開予定)
TL;DR
対象読者は、React Server Components をまだ業務で書いたことがないエンジニアです。「どこに 'use client' を書くべきか」を、実プロダクトの 6 ファイルから逆算して掴みます。
- Next.js 16 では 全部のコンポーネントが既定でサーバー側。
'use client'を書いたところから先だけがブラウザへ送られます - gem-hunter で
'use client'が付いているのは 6 ファイルだけ でした - 検索フォームもページネーションも言語切替も、JavaScript なしで動いています
先に React を 30 秒だけ
React を触ったことがない方向けに、この回で出てくる分だけ翻訳します。
1. コンポーネントは「HTML を返す関数」です。
function Hello({ name }: { name: string }) {
return <p>こんにちは、{name}さん</p>
}
JavaScript の中に HTML のようなものが書けます(JSX)。{} の中には式が書けます。
2. useState は「変わる値」を持たせる仕組みです。
const [count, setCount] = useState(0)
setCount(1) を呼ぶと、この関数がもう一度実行されて表示が更新されます。
3. useEffect は「描画されたあとに何かする」仕組みです。
useEffect(() => {
document.title = '新しいタイトル'
}, [])
この 3 つは ブラウザで動くことが前提 です。だから、これらを使う部品には印が要ります。
既定はサーバー、印を付けたところからクライアント
App Router では、書いたコンポーネントは 何も書かなければサーバーでだけ実行され、結果の HTML だけがブラウザへ届きます。そのコンポーネントの JavaScript はブラウザに送られません。
useState や useEffect、onClick を使いたいときだけ、ファイルの先頭にこう書きます。
'use client'
境界の性質で大事なのは 2 つです。
-
'use client'はファイル単位。そのファイル全体がブラウザへ送られます -
境界は伝播する。
'use client'を書いたファイルが import しているファイルも、まとめてブラウザへ送られます
だから 'use client' は「できるだけ葉っぱに近いところ」に書く のが定石です。ページの一番上に書くと、そのページ配下が全部クライアント側になります。
実プロダクトで数えてみる
gem-hunter で 'use client' が付いているファイルを数えてみました。
grep -rl "^'use client'" src app
結果は 6 ファイルです。
src/ui/focus-on-navigate.tsx
src/ui/locale-switch-announcer.tsx
src/ui/set-document-title.tsx
src/ui/seen-digest/seen-digest-provider.tsx
src/ui/seen-digest/new-since-last-visit-badge.tsx
src/ui/seen-digest/first-visit-note.tsx
src/ui/ には 30 以上のコンポーネントがありますが、そのうちブラウザへ送られるのは 6 つだけです。
6 つに共通しているもの
| ファイル | クライアントである理由 |
|---|---|
focus-on-navigate.tsx |
ページ遷移後に document.getElementById().focus() を呼ぶ |
locale-switch-announcer.tsx |
言語切替を読み上げソフトへ通知するため DOM に文字を書き込む |
set-document-title.tsx |
document.title を直接書き換える |
seen-digest-provider.tsx |
localStorage を読み書きする |
new-since-last-visit-badge.tsx |
上記の値を使う |
first-visit-note.tsx |
同上 |
全部「ブラウザにしか存在しない API を触る必要があるから」です。 「クリックできるから」でも「動きがあるから」でもありません。
例を 1 つ見ます。
'use client'
import { useEffect, useRef } from 'react'
export function FocusOnNavigate({ watch, targetId }: { watch: string; targetId: string }) {
const isFirstRender = useRef(true)
useEffect(() => {
if (isFirstRender.current) {
// 初回ロード時に勝手にフォーカスを奪わない(ページを開いた瞬間に見出しへ飛ぶのは誤り)。
isFirstRender.current = false
return
}
document.getElementById(targetId)?.focus()
}, [watch, targetId])
return null
}
return null です。画面には何も出しません。 「ページが切り替わったら結果の見出しへフォーカスを移す」という副作用だけを担当します。
キーボードだけで操作している人にとって、検索して結果が変わったのにフォーカスがフォームに残っていると、結果まで Tab を何度も押すことになります。それを解消する部品です。詳しくは第 12 回で。
フォームがサーバー側にある
いちばん面白いのは、検索フォームが 'use client' を持っていない ことです。
/**
* 検索フォーム(US-9 / AC-2)。
* GET フォームなのでクライアント JS を持たない(E-8 / NFR-3)。
* 送信でキーワードが URL のクエリに反映される(パラメータ名の正本は prd.md §2.4.1 で、
* SEARCH_PARAM_KEYS はその実装。名前を他ファイルへ直書きしない)。
*/
export function SearchForm({ keyword, action, labels }: SearchFormProps) {
return (
<form action={action} method="get" role="search" className="flex gap-2">
<label htmlFor={SEARCH_PARAM_KEYS.keyword} className="sr-only">
{labels.inputLabel}
</label>
<Input
id={SEARCH_PARAM_KEYS.keyword}
name={SEARCH_PARAM_KEYS.keyword}
type="search"
size="xl"
defaultValue={keyword}
placeholder={labels.placeholder}
className="flex-1"
/>
<Button type="submit" size="xl">
{labels.submit}
</Button>
</form>
)
}
useState も onChange も onSubmit もありません。素の <form method="get"> です。
送信すると、ブラウザが ?q=react のような URL へ遷移します。サーバーはその URL を見て検索し、結果の HTML を返します。JavaScript が 1 行も要りません。
同じ発想で、ページネーション・並び替え・表示件数・言語切替も、すべて ただのリンク として実装されています。
この設計の効果は 3 つあります。
- JavaScript が無効でも動く
- URL がそのまま状態 なので、共有・ブックマーク・戻るボタンが素直に効く
- ブラウザへ送るコードが減る
サーバーとクライアントを繋ぐときの作法
サーバー側の部品からクライアント側の部品へ値を渡すときは、プレーンな値だけ を渡します(関数やクラスのインスタンスは渡せません)。
gem-hunter では、翻訳された文字列を labels というオブジェクトにまとめて渡しています。
<RepositoryDetail
labels={{
stars: t.detail.stars,
watchers: t.detail.watchers,
// ...
}}
/>
翻訳の仕組み全体をクライアントへ渡すのではなく、その部品が表示に使う文字列だけ を渡す。境界を跨ぐデータを最小にする、というやり方です。
ハイドレーション不一致という落とし穴
クライアント部品の中で最も難しいのが seen-digest-provider.tsx です。localStorage に「前回ここまで見た」を記録して、新着にバッジを出します。
問題は、localStorage はサーバーに存在しない ことです。サーバーは「新着かどうか」を知らずに HTML を作り、ブラウザ側で初めて分かります。この食い違いを React が検出すると hydration mismatch の警告が出ます。
解決策がこれです。
const state = useSyncExternalStore(
subscribeNoop,
() => readyState, // クライアントで返る値
() => pendingState, // サーバーで返る値(常に pending)
)
useSyncExternalStore は第 3 引数に「サーバーで描画するときの値」を渡せます。ここを常に pending(まだ分からない)にしておくと、サーバーとクライアントの初回描画が必ず一致します。
つまり「サーバーでは『分からない』を描画し、ブラウザで確定してから差し替える」わけです。「サーバーとクライアントで違う値が出るなら、最初は両方『分からない』にする」という考え方は、この手の問題の定石として覚えておく価値があります。
'use server' は 0 件
もう 1 つ数えておきます。Server Actions('use server')はリポジトリ全体で 0 件 です。
Server Actions は「フォーム送信をサーバー側の関数で直接受ける」仕組みで、Next.js 16 の目玉機能の 1 つです。使っていない理由は、このアプリにデータを書き込む操作がない からです。検索も詳細表示も読み取りだけで、GET のフォームとリンクで完結します。
新しい機能を「あるから使う」のではなく「必要になったら使う」。これも 1 つの判断です。
落とし穴
-
grep "'use client'"は誤検知します。行頭アンカーなしで数えると 8 件出ますが、うち 2 件は 説明用のコメントの中に文字列として書かれているもの でした。^'use client'と行頭を固定して数えるのが正確です -
'use client'を境界の上の方に書くと、配下が全部クライアントになります。「このボタンだけ動きが要る」なら、そのボタンだけを別ファイルに切り出します - クライアント部品に渡す props は最小限に。オブジェクト全体を渡すと、それも丸ごとブラウザへ送られます
まとめ
- 既定はサーバー。
'use client'を書いたファイルから先だけがブラウザへ送られる - 実プロダクトで
'use client'が付いていたのは 6 ファイルだけ。全部「ブラウザにしかない API を触る必要がある」もの - 検索フォーム・ページネーション・言語切替は 素の form とリンク で JavaScript なし
- サーバーとクライアントで値が食い違うときは、サーバー側を「分からない」に固定 して不一致を消す
シリーズの前後の記事
- ⬅️ 前の記事: 第 4 回 App Router の地図。フォルダがそのまま URL になる
- ➡️ 次の記事: 第 6 回 「クリーンアーキテクチャだから」を理由にしない層の分け方
全 19 回をまとめて読みたい方は、同じ内容の Zenn Book(無料)へどうぞ。