最初のレンダーが document、matchMedia、location.hash、localStorage、あるいは Cookie に依存しているなら、値が 1 つ食い違うだけで hydration mismatch になる。これらの API は SSR 中には存在せず、クライアントの最初のレンダーではすでに本物の値になっているからだ。 それでも tsc、ESLint、Node 環境のユニットテスト一式はすべてパスする。どれ一つとして hydration レンダーを実行しないからだ。 誠実な修正は 2 つ。マウント後に seed する(簡単だが 1 フレームちらつく)か、値をサーバー側で読む(ちらつかないが Cookie の往復が要る)。
症状
Admin のサイドバー。アイコンだけのレールに畳めて、その選択は保持される。別の admin ページに移動すると、レールが一瞬展開されてからパチンと閉じる — おまけにコンソールには hydration warning。
すべてグリーンだった。tsc --noEmit: exit 0。eslint: exit 0。ユニットテスト 327 件パス。このバグは自分の目で、ブラウザで、2 回目のページ読み込みでしか見えなかった。
自分が思い込んでいたこと
「クライアント専用 API」とは「useEffect が走るまで使えない」という意味だと思い込んでいた。だからレンダー中に document.cookie を直接読むのは、まあ安全そうに感じていた。サーバーは typeof document === "undefined" の分岐を通り、クライアントは本物の分岐を通り、その差は React が吸収してくれる、と。
これは特定の意味で間違っていて、その特定の意味こそが全部だ。
実際の仕組み
サーバーでレンダリングされた HTML が届く。そのあと React は hydration レンダー を行う — コンポーネントツリーをクライアントで再実行し、その出力が受け取った HTML と完全に一致することを期待する。このレンダーは本物のブラウザで走る。document は存在する。window.matchMedia も存在する。location.hash には値が入っている。localStorage は読める。
つまり「サーバー用の分岐」として書いたコードが通るのは、SSR のときの 1 回だけ だ。クライアントの一番最初のレンダー — 一致しなければならない、まさにあのレンダー — では本物の値が返ってくる。その本物の値がサーバーのデフォルトと違えば、ツリーは食い違う。React は警告を出してテキストを繕うか、サーバーの HTML を捨てて最も近い Suspense 境界からクライアント側で再レンダーする — 境界がなければルート全体を、だ。目に見えるちらつきは、その修正だ。
Cookie バナーの素朴な版はこう書ける。そして型チェックは完璧に通る。
// DON'T
const [consent] = useState(() =>
typeof document === "undefined" ? "ssr" : readCookie("vs-cookie-consent"),
);
if (consent !== "none") return null;サーバー: "ssr" → 何も描画しない。クライアントの最初のレンダー: "none" → 固定表示のバナーを描画する。初回訪問のたびに、確実に mismatch。
修正 (a): マウント後に seed する
サーバーでも同じように作れる値から始めて、effect の中で補正する。levain-template/src/components/sections/auth/AccountDashboard.tsx から。
// Start on "orders" so the server render and the first client render agree
// (window.location.hash is unavailable on the server). A `#favorites` (etc.)
// deep-link is honoured just after mount via the effect below, which keeps
// hydration in sync at the cost of one imperceptible tab switch.
const [tab, setTab] = useState<Tab>("orders");
useEffect(() => {
const hash = window.location.hash.replace("#", "");
const valid: Tab[] = ["profile", "orders", "addresses", "favorites"];
if ((valid as string[]).includes(hash)) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional client-only post-mount seed to keep SSR/CSR hydration in sync
setTab(hash as Tab);
}
}, []);disable コメントに注目。eslint-config-next 16.2.10(eslint-plugin-react-hooks 7 を引き込む)では、react-hooks/set-state-in-effect がまさにこの形を指摘する。一般論としては無駄なレンダーだからだ。ここでは意図的なので、理由を説明するコメントを付けてある。
linter と言い争いたくないなら、useSyncExternalStore が同じ発想をプリミティブにしたものだ — getServerSnapshot は文字どおり「SSR と hydration レンダーで使う値」だ。
// booking-template/src/lib/useIsMobile.ts
function subscribe(onChange: () => void): () => void {
const mql = window.matchMedia(MOBILE_QUERY);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}
function getSnapshot(): boolean {
return window.matchMedia(MOBILE_QUERY).matches;
}
function getServerSnapshot(): boolean {
return false;
}
export function useIsMobile(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}React は hydration パスで getServerSnapshot を使い、その直後に getSnapshot へ切り替える。形は同じ、lint と揉めることもなく、変更の購読までタダで付いてくる。booking-template/src/components/landing/CookieBanner.tsx の Cookie バナーは、boolean ではなく番兵値を使っている。
function readConsent(): string {
if (typeof document === "undefined") return "ssr";
const hit = document.cookie
.split("; ")
.find((c) => c.startsWith(`${CONSENT_COOKIE}=`));
return hit ? (hit.split("=")[1] ?? "none") : "none";
}
const getServerSnapshot = () => "ssr";"ssr" は何も描画しないので、hydration レンダーはサーバーのものとバイト単位で同一になる。バナーは 1 tick 遅れて現れる。同意バーならそれで十分だ。
修正 (b): サーバー側で読む
サイドバーではそれでは足りなかった — ナビゲーションのたびにレールが目に見えて開き直すのは、壊れているようにしか見えない。そこで値は、サーバーが読める Cookie に移す。
// booking-template/src/lib/admin/collapsed.ts
export const ADMIN_COLLAPSED_COOKIE = "vs-admin-collapsed";
export async function getAdminCollapsed(): Promise<boolean> {
const { cookies } = await import("next/headers");
return (await cookies()).get(ADMIN_COLLAPSED_COOKIE)?.value === "1";
}admin レイアウトがこれを呼び、値を context provider 経由で渡し、サイドバーはそこから自分の state を seed する。
// booking-template/src/components/admin/AdminSidebar.tsx
const [collapsed, setCollapsed] = useState(useInitialCollapsed());これで SSR も hydration も true を算出する。mismatch なし、ちらつきなし。同じ継ぎ目でテーマも扱える。locale レイアウトが cookie jar から vs-theme / vs-mode を読み、data-theme / data-mode を <html> に直接刻むので、最初に描画されるフレームからすでに正しいテーマになっている。
トレードオフは本物なので、はっきり書いておく。(a) はローカルに閉じる — ファイル 1 つ、サーバー側の変更なし、そしてちらつき得る。(b) は絶対にちらつかないが、値が Cookie にある必要があり、つまりクライアントからの書き込み、反映のためのナビゲーションかリロード、そしてそのルートの動的レンダーが要る。ちらつきがレイアウトシフトを起こす、あるいはブランドとして目立つ場合は (b)、それ以外はすべて (a) を使う。
みんなが忘れる 3 つ目の選択肢がある。JS でそもそも分岐しないことだ。landing の Reveal は隠れている状態を html.has-js で切り替わる CSS に持たせ、prefers-reduced-motion にメディアクエリで無効化させている — useReducedMotion の呼び出しがないので、mismatch する対象がない。
なぜ CI はグリーンだったのか
CI の中で hydrate するものが一つもないからだ。
booking-template/vitest.config.ts には environment: "node" と include: ["tests/**/*.test.ts"] と書いてある — .ts だけで、.tsx はない。あの 327 件のテストが叩いているのは純粋なヘルパーだ。空き枠の計算、クーポンのルール、i18n キーのカバレッジ。ツリーを 2 度マウントすることが一度もないので、hydration mismatch を観測できない。tsc から見れば、どちらの分岐も型は正しい。ESLint から見れば、合法な typeof ガードだ。
グリーンな CI が教えてくれたのは、コードが整形式だということだけだ。機能が動くかどうかについては何も言っていない。
チェックリスト
以下はどれも SSR 中は undefined/存在せず、クライアントの最初のレンダーでは本物になる。このうちの一つが return する内容を決めているなら、手を止めて (a) か (b) を選ぶこと。
document—document.cookieを含むwindow、window.location.search、window.location.hashlocalStorage/sessionStoragewindow.matchMedia(...)とその上に作られたもの全部、useReducedMotionも含むnavigator(language、userAgent、onLine)- 描画される出力に流れ込む場合の
Date.now()/new Date() Math.random()と、シードなしの id すべて
これらをレンダー本体と useState の初期化子の中で grep すること。その grep で、自分のコードから午後 1 回で 4 件見つかった。
これが booking テンプレートで出荷しているパターンだ。ライブデモは booking.violettadev.com。