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

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

Zustand Test の 4 ストア設計と storeSync を読み解く

settings / profile / detail / ui の 4 ストアを subscribe で連携し、patchFormData・setFormData・reset を使い分ける Playground の Zustand 実装を1ステップずつ解説する。

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

対応バージョン

nextjs 15react 19zustand 5

前提環境

React のフックと TypeScript の基本構文を理解していること

概要

Zustand Test は、フォーム入力をリアルタイムに反映しつつ、保存ボタンで Zod 検証を通した確定値を再書き込みする実験場です。Zustand 側は 4 つの store で役割を分担します。

store役割
settingsStore入力値 / 確定値を保持。store 間同期の起点
profileStore表示用に整形した値を保持(settingsStore から自動同期)
detailStore詳細画面用の派生状態(mode / suggestedRate など)
uiStoreアクティブなタブを保持。表示制御のみ

データの流れは フォーム入力 → settingsStore → storeSync → profileStore / detailStore → 各 UI の一方向です。本記事ではこのうち Zustand 部分 を上から順に解説します。

インストール

npm install zustand

settingsStore の最小構成

入力中・確定後の値を1つの store にまとめ、初期値を別オブジェクトとして外に切り出すのがポイントです。リセット時に 同じ初期値 をまた使えるようにするためです。

// src/store/settingsStore.ts
import { create } from "zustand";
import type { FormData } from "@/schemas/formSchema";

const INITIAL_SETTINGS: FormData = {
  name:  "",
  email: "",
  rate:  0,
};

type SettingsState = FormData & {
  patchFormData: (partial: Partial<FormData>) => void;
  setFormData:   (values: FormData) => void;
  reset:         () => void;
};

export const useSettingsStore = create<SettingsState>((set) => ({
  ...INITIAL_SETTINGS,
  patchFormData: (partial) => set(partial),
  setFormData:   (values)  => set(values),
  reset:         ()        => set(INITIAL_SETTINGS),
}));

patchFormData と setFormData を分けている意図は Zod 編 で詳しく扱いますが、要約すると 「Zod を通っていない部分更新」 と 「Zod を通った確定書き込み」 を区別するためです。

selector で必要なスライスだけ購読する

コンポーネントは useSettingsStore(selector) で必要なスライスだけ購読します。primitive を返すと参照等価で比較され、無関係な変化で再レンダリングされません。

// src/components/NameDisplay.tsx
"use client";
import { useSettingsStore } from "@/store/settingsStore";

export function NameDisplay() {
  const name = useSettingsStore((s) => s.name);
  return <p>{name}</p>;
}

action(patchFormData 等)も useSettingsStore((s) => s.patchFormData) で取り出します。action は store 生成時から参照が変わらないため、コンポーネント間でアクションを props で受け渡し ても再レンダリングは追加で発生しません。

set / get の使い分け

create のコールバックで受け取る set / get は store 内部から呼ぶ用です。

// 1. set: 部分更新(マージ)
set({ name: "new name" });

// 2. set with function: 現在の state を参照して更新
set((state) => ({ rate: state.rate + 1 }));

// 3. get: 現在値を一回読む
create<State>((set, get) => ({
  doubleRate: () => set({ rate: get().rate * 2 }),
}));

コンポーネント側からは get() を直接呼びません。代わりに useSettingsStore((s) => s.rate) を使うと、再レンダリング制御が効きます。

getState() と subscribe の違い

React の外側から store にアクセスする場面で使い分けます。

getState()subscribe()
タイミング呼んだ瞬間の値を 一回読む変化が起きるたびに通知
再レンダリング起こさない起こさない(自分で UI 反映を書く)
主な用途別 store の現在値を参照store 間の自動同期、外部連携
コンポーネント内使わない(selector を使う)使わない(useEffect で管理)

getState() は 読み切り の操作、subscribe() は 継続購読 の操作と覚えると使い分けが整理されます。

storeSync で store 間を連携する

storeSync は settingsStore の変化を起点に profileStore / detailStore を更新するレイヤです。subscribeWithSelector middleware を使い、特定フィールドの変化だけに反応させます。

// src/store/settingsStore.ts
import { create } from "zustand";
import { subscribeWithSelector } from "zustand/middleware";

export const useSettingsStore = create<SettingsState>()(
  subscribeWithSelector((set) => ({
    /* state + actions */
  }))
);
// src/store/storeSync.ts
import { shallow } from "zustand/shallow";
import { useSettingsStore } from "./settingsStore";
import { useProfileStore }  from "./profileStore";
import { useDetailStore }   from "./detailStore";

export function setupStoreSync() {
  // settings の name / email が変わったら profile を更新
  const unsub1 = useSettingsStore.subscribe(
    (s) => ({ name: s.name, email: s.email }),
    ({ name, email }) => {
      useProfileStore.getState().updateFrom({ name, email });
    },
    { equalityFn: shallow }
  );

  // settings の rate が変わったら detail の suggestedRate を更新
  const unsub2 = useSettingsStore.subscribe(
    (s) => s.rate,
    (rate) => {
      useDetailStore.getState().setSuggestedRate(Math.max(0, rate * 1.1));
    }
  );

  return () => {
    unsub1();
    unsub2();
  };
}

ポイントは3つです。

  • subscribeWithSelector は subscribe(selector, listener, options?) の 3 引数 形式を解放する
  • オブジェクトを返す selector は { equalityFn: shallow } を指定しないと毎回別参照になり発火しすぎる
  • 同期先の store には getState() で action を取り出して呼ぶ(React の外なので selector は使えない)

アクションをコンポーネント間で受け渡す

action はオブジェクト ID として安定しているため、props で渡しても再レンダリングを誘発しません。これにより「保存ボタン」を子コンポーネントに分離しても、上のフォームが再描画されません。

// src/components/SettingsForm.tsx
"use client";
import { useSettingsStore } from "@/store/settingsStore";
import { SaveButton } from "./SaveButton";

export function SettingsForm() {
  const setFormData = useSettingsStore((s) => s.setFormData);
  // ↓ 子に渡しても参照は変わらない
  return <SaveButton onSave={setFormData} />;
}

action を受け取る側は単純な関数 props として扱えます。

devtools middleware で可視化する

devtools を被せ、set の第 3 引数にアクション名を渡すと Redux DevTools のタイムラインで識別できます。store ごとに name を変えれば、複数 store を並べて追えます。

import { create } from "zustand";
import { devtools, subscribeWithSelector } from "zustand/middleware";

export const useSettingsStore = create<SettingsState>()(
  devtools(
    subscribeWithSelector((set) => ({
      ...INITIAL_SETTINGS,
      patchFormData: (partial) => set(partial,           false, "patchFormData"),
      setFormData:   (values)  => set(values,            false, "setFormData"),
      reset:         ()        => set(INITIAL_SETTINGS,  true,  "reset"),
    })),
    { name: "SettingsStore" }
  )
);

第 2 引数を true にすると state を置き換え(merge ではなく replace) するため、リセット時の挙動と相性が良いです。

store リセット(初期値への復元)

各 store に reset action を持たせ、画面の「全ストアをリセット」ボタンから順に呼びます。

"use client";
import { useSettingsStore } from "@/store/settingsStore";
import { useProfileStore }  from "@/store/profileStore";
import { useDetailStore }   from "@/store/detailStore";
import { useUiStore }       from "@/store/uiStore";

export function ResetAllButton() {
  const onClick = () => {
    useSettingsStore.getState().reset();
    useProfileStore.getState().reset();
    useDetailStore.getState().reset();
    useUiStore.getState().reset();
  };
  return <button onClick={onClick}>全ストアをリセット</button>;
}

getState().reset() を React の外から呼んでいるのは、ハンドラがマウント中の任意の瞬間に発火するためです。selector を経由する必要がない場面では getState() の方が手数が少なく済みます。

unsubscribe 管理

subscribe は登録したコールバックを Zustand 側に保持させます。解除しないとアンマウント後も呼ばれ続けるので、useEffect の cleanup で必ず解除します。

"use client";
import { useEffect } from "react";
import { setupStoreSync } from "@/store/storeSync";

export function SyncBoot() {
  useEffect(() => {
    const unsubscribe = setupStoreSync();
    return () => unsubscribe();
  }, []);
  return null;
}

アプリ全体で一度だけ立ち上げる用途なので、ルートレイアウト直下に置くのが自然です。

ポイント

  • create<T>() に state と action を1つにまとめ、初期値は外部定数として切り出すと reset で再利用 できる
  • selector は primitive を返すと再描画を最小化できる。action は参照が安定しているため props で受け渡しても安全
  • React 外からの操作は getState()、変化の継続監視は subscribe()。役割を必ず分ける
  • 多 store 連携は subscribeWithSelector + shallow で無関係なフィールドでは発火させず、戻り値の unsubscribe を useEffect の cleanup で必ず呼ぶ
  • devtools の set 第 3 引数にアクション名、{ name: "..." } で store 名を識別。reset は set(initial, true, "reset") で 置き換え更新

関連記事

セットで読むと文脈がつながる関連記事は次のとおりです。

  • Zod 編 — patchFormData / setFormData の使い分けと coerce
  • TypeScript 編 — 4 store を貫く型の流れ
  • Zustand Test (Build) — 完成例と外部デモ

注意点

Playground の3記事のうち Zustand 編。Zod 編 / TypeScript 編とセットで読むと、フォーム入力 → 検証 → store 投影 → store 間同期の全体像がつながる。

リポジトリを見る

関連サンプル

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

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

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

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

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

  • Zustand Test の storeSync を Vitest でユニットテストする

    settings から profile へ subscribe で同期するレイヤを、Vitest で「呼ばれた / 呼ばれない」「整形が正しい」を確かめるテストの書き方を解説する。

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

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

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

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

関連仕様

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

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