© TOOLS BOX — Next.js / React / TypeScript コードサンプル集

サンプルガイド
←サンプル一覧
nextjsstate-management

Zustand Test を Server Component と use client の境界で安全に動かす

Zustand store はクライアント専用。Server Component から直接読まず、初期値の受け渡しと hydration mismatch を避ける書き方を解説する。

難易度: 上級·更新: 2026-05-07·
zustand

対応バージョン

nextjs 15react 19zustand 5

前提環境

Next.js App Router の Server Component / Client Component の区別を理解していること

概要

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) — 完成例と外部デモ

注意点

Playground 追加記事。store はクライアント側にしか存在しないことを前提に、SSR との境界・初期値受け渡し・hydration mismatch 対策を整理する。

リポジトリを見る

関連サンプル

同じテーマや技術スタックを使った実装例

  • Zustand Test のアクションを selector → props で安全に受け渡す

    Zustand のアクションは store 生成時から参照が安定している性質を活かし、selector で取り出して props で子に渡す書き方を解説する。

  • Zustand Test の保存処理を非同期 action と loading / error 状態で扱う

    saveSettings のような非同期処理を store のアクションに閉じ込め、status: 'idle'|'loading'|'error'|'success' を 1 つのフィールドで表現する書き方を解説する。

  • Zustand Test の settingsStore を BroadcastChannel で複数タブに同期する

    BroadcastChannel API で別タブの settingsStore に変更をブロードキャストし、エコーバックを避けながら受信側を更新する手順を解説する。

  • Zustand Test の suggestedRate を subscribeWithSelector で派生する

    settingsStore.rate から detailStore.suggestedRate を導出する派生状態の作り方を、subscribeWithSelector と shallow を軸に解説する。

  • Zustand Test の devtools をアクション命名規則で読みやすくする

    store 名 / アクション名 / replace モードを揃え、Redux DevTools のタイムラインを 4 store 横断で読み解けるようにする命名規則を解説する。

関連仕様

このサンプルを理解するのに役立つ仕様や概念

  • FrameworkNext.jsReact ベースのフルスタックフレームワーク。SSR・SSG・App Router・API Routes を提供する。
←サンプル一覧に戻る