概要
Next.js App Router では、page.tsx などのファイルは デフォルトで Server Component です。Zustand の store はブラウザのメモリに値を持つ存在なので、Server Component から 直接 useStore を呼ぶことはできません。
Zustand Test は store とフォーム UI を全部クライアント側に置き、ページ自体(Server Component)は 入れ物 として振る舞わせる構成にしています。本記事ではその境界の引き方と、SSR と組み合わせるときに出やすい hydration mismatch の避け方をまとめます。
インストール
npm install zustand
ページは Server / store は Client
ページ本体は Server Component のままで、"use client" 境界は store を読む / 書くコンポーネント側 に引きます。
// src/app/zustand-demo/page.tsx ← Server Component
import { TabbedArea } from "@/components/TabbedArea";
import { StoreMonitor } from "@/components/monitor/StoreMonitor";
import { SyncBoot } from "@/components/SyncBoot";
export default function ZustandDemoPage() {
return (
<div className="grid grid-cols-[1fr_280px] gap-4">
<SyncBoot />
<TabbedArea />
<StoreMonitor />
</div>
);
}
// src/components/TabbedArea.tsx
"use client";
import { useUiStore } from "@/store/uiStore";
// ... store を購読
"use client" を store を実際に読む側だけ に置くと、Server Component の最適化(レイアウトのキャッシュ、RSC ペイロードの軽量化)を維持できます。
store ファイル自体に "use client" は不要
store の定義ファイル(settingsStore.ts 等)には "use client" は 付けません。これは「使う側が "use client" を宣言したクライアントコンポーネントである限り、store も自動的にクライアントで評価される」ためです。
// src/store/settingsStore.ts ← "use client" は書かない
import { create } from "zustand";
// ...
ただし、Server Component から誤って import しても気付けない ので、社内ルールとして「store は *.client.ts という命名にする」など制約を作るのも一案です。
初期値を Server から渡したい場合
「初期表示用のデータをサーバーで取得し、クライアントの store に流し込みたい」要件は、Provider パターンで解決します。
// src/store/SettingsProvider.tsx
"use client";
import { useRef, type ReactNode } from "react";
import { useSettingsStore } from "./settingsStore";
import type { FormData } from "@/schemas/formSchema";
export function SettingsProvider({
initial,
children,
}: {
initial: FormData;
children: ReactNode;
}) {
const initialized = useRef(false);
if (!initialized.current) {
useSettingsStore.getState().setFormData(initial);
initialized.current = true;
}
return <>{children}</>;
}
// src/app/zustand-demo/page.tsx ← Server Component
import { SettingsProvider } from "@/store/SettingsProvider";
export default async function ZustandDemoPage() {
const initial = await loadInitialSettings(); // Server で取得
return (
<SettingsProvider initial={initial}>
<TabbedArea />
</SettingsProvider>
);
}
useRef でガードして マウント時 1 回だけ 注入します。Provider の再レンダリングごとに setFormData が走らないのが要点です。
hydration mismatch の典型と回避
Server で描画した HTML と、Client が初回レンダーする HTML が 食い違う と React が警告を出します。Zustand 文脈で出やすいのは次の 3 ケース。
| ケース | 対策 |
|---|---|
persist で localStorage の値を即時反映 | 復元完了フラグまで描画を抑制 |
Date.now() などタイムゾーン依存の値を表示 | useEffect で計算してから表示 |
Math.random() を初期 state に使う | クライアントで初期化、または避ける |
persist の例は persist 編 に書いたとおり、useState のフラグで初期描画を SSR と揃える のがシンプルです。
"use client";
import { useEffect, useState } from "react";
import { useSettingsStore } from "@/store/settingsStore";
export function NameDisplay() {
const [ready, setReady] = useState(false);
const name = useSettingsStore((s) => s.name);
useEffect(() => setReady(true), []);
if (!ready) return <p>{/* SSR と同じ初期表示 */}</p>;
return <p>{name}</p>;
}
動的 import で client-only にする
「絶対に Server Component から取り込まれてほしくない」コンポーネントは next/dynamic で ssr: false を指定します。Store Monitor のように 完全にクライアント専用な UI に向いています。
// src/app/zustand-demo/page.tsx ← Server Component
import dynamic from "next/dynamic";
const StoreMonitor = dynamic(() => import("@/components/monitor/StoreMonitor"), {
ssr: false,
});
export default function ZustandDemoPage() {
return (
<>
{/* ... */}
<StoreMonitor />
</>
);
}
ただし ssr: false は 初回描画が遅れる トレードオフがあります。デフォルトでは "use client" で十分なので、必要になったときだけ使います。
storeSync を Provider 化するか
setupStoreSync は副作用なので、SyncBoot という小さなクライアントコンポーネント に閉じ込めて useEffect で起動するのが Playground の流儀です (Zustand 編 参照)。Server Component から見ると <SyncBoot /> という普通のコンポーネントなので、ページ側に複雑さが漏れません。
// src/components/SyncBoot.tsx
"use client";
import { useEffect } from "react";
import { setupStoreSync } from "@/store/storeSync";
export function SyncBoot() {
useEffect(() => {
const unsubscribe = setupStoreSync();
return () => unsubscribe();
}, []);
return null;
}
このコンポーネントは UI を出さないため、配置位置で見た目に影響しません。ページのトップに置く のが慣例です。
ポイント
- ページ(Server Component)は 入れ物。store を直接
useStoreで読まない - store ファイルに
"use client"は 書かない。読み手側のクライアント境界で十分 - Server から初期値を渡したい場合は Provider +
useRefガード で 1 回だけ注入する persistを使うときは hydration mismatch に注意。復元完了まで描画を SSR と揃えるsetupStoreSyncはSyncBootのような副作用専用クライアントコンポーネント に閉じ込める
関連記事
セットで読むと文脈がつながる関連記事は次のとおりです。
- Zustand 編 —
setupStoreSyncの実装 - persist 編 — hydration の具体例
- Store Monitor 編 — クライアント専用 UI の典型
- Zustand Test (Build) — 完成例と外部デモ