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

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

Zustand Test の suggestedRate を subscribeWithSelector で派生する

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

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

対応バージョン

nextjs 15react 19zustand 5

前提環境

Zustand の subscribe と subscribeWithSelector の違いを理解していること

概要

Zustand Test の detailStore には mode と並んで suggestedRate という派生値があります。 これは settingsStore.rate から Math.max(0, rate * 1.1) のように計算した「推奨レート」で、フォーム入力に追随して動きます。

派生状態の作り方は2種類あります。

方法どこで計算するか向いているケース
selector の中で導出コンポーネントの useStore 内単一コンポーネントだけで使う・軽い計算
別 store に書き出すstoreSync の subscribe 内複数コンポーネントで参照・Monitor に映す

Playground の suggestedRate は Monitor からも詳細タブからも参照する ため、後者を選んでいます。本記事は後者の作り方が主題です。

インストール

npm install zustand

detailStore に派生値の置き場を作る

派生値は 「同期されて入ってくる場所」 として store に持たせます。直接 set しない方針で、setSuggestedRate という専用 action を介します。

// src/store/detailStore.ts
import { create } from "zustand";
import { devtools } from "zustand/middleware";

const INITIAL_DETAIL = {
  mode:          "draft" as const,
  suggestedRate: 0,
};

type Mode = "draft" | "review" | "fixed";

type DetailState = {
  mode:             Mode;
  suggestedRate:    number;
  setMode:          (mode: Mode) => void;
  setSuggestedRate: (rate: number) => void;
  reset:            () => void;
};

export const useDetailStore = create<DetailState>()(
  devtools(
    (set) => ({
      ...INITIAL_DETAIL,
      setMode:          (mode) => set({ mode },                        false, "setMode"),
      setSuggestedRate: (rate) => set({ suggestedRate: rate },         false, "setSuggestedRate"),
      reset:            ()     => set(INITIAL_DETAIL,                  true,  "reset"),
    }),
    { name: "DetailStore" }
  )
);

派生値専用の setSuggestedRate を切ることで、「外から手で設定された値」と「同期で書かれた値」が混ざらない 設計を保てます。直接 set を許してしまうと、後で Monitor を見て「これは誰が書いた?」が追えなくなります。

storeSync で settingsStore の rate を購読する

subscribeWithSelector middleware が必要なのは購読する settings 側 です。subscribe の第 1 引数に「rate だけを返す selector」、第 2 引数に「変化が起きたときに呼ぶリスナー」を渡します。

// src/store/storeSync.ts
import { useSettingsStore } from "./settingsStore";
import { useDetailStore }   from "./detailStore";

export function setupSuggestedRateSync() {
  const unsubscribe = useSettingsStore.subscribe(
    (s) => s.rate,                               // selector
    (rate) => {                                  // listener
      const next = Math.max(0, rate * 1.1);
      useDetailStore.getState().setSuggestedRate(next);
    }
  );
  return unsubscribe;
}

ポイントは2つです。

  • selector が返す 値が変化したときだけ リスナーが発火する。name や email の変化では呼ばれない
  • リスナーから他の store の action を呼ぶときは useDetailStore.getState().setSuggestedRate(...)。React の外なので selector は使えない

settings 側の宣言は次のとおりです(再掲)。subscribeWithSelector を被せておくのが前提条件です。

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

export const useSettingsStore = create<SettingsState>()(
  subscribeWithSelector((set) => ({
    /* state + actions */
  }))
);

オブジェクトを返す selector では shallow を必ず指定する

rate は number なので参照等価で比較できますが、複数フィールドをまとめて派生に流したい場合は オブジェクトを返す selector になります。デフォルトの比較は === なので、毎回新しいオブジェクトが作られて 毎変化で発火 してしまいます。{ equalityFn: shallow } を指定すると 1 階層だけ深く比較され、内容が同じなら発火しません。

import { shallow } from "zustand/shallow";

useSettingsStore.subscribe(
  (s) => ({ name: s.name, email: s.email }),
  ({ name, email }) => {
    useProfileStore.getState().updateFrom({ name, email });
  },
  { equalityFn: shallow }
);

subscribeWithSelector を使うときは 「primitive を返す → 比較指定なし」「オブジェクトを返す → shallow 必須」 とセットで覚えるとミスがありません。

起動側で setupSync を 1 度だけ呼ぶ

useEffect の初回マウントで購読を張り、cleanup で必ず解除します。アプリ全体で 1 度だけ走らせる初期化なので、ルート直下のクライアントコンポーネントに置くのが自然です。

// src/components/SyncBoot.tsx
"use client";
import { useEffect } from "react";
import { setupSuggestedRateSync } from "@/store/storeSync";

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

複数の同期がある場合は setupSync 関数の中でまとめて配線し、戻り値の cleanup を 1 つに束ねます(Zustand 編 の setupStoreSync 参照)。

いつ selector 内で導出するか

別 store に書き出さず、selector の中で導出する 方が良いケースもあります。

// 1 か所でしか使わない・計算が軽い派生
const suggestedRate = useSettingsStore((s) => Math.max(0, s.rate * 1.1));

判断基準はシンプルです。

  • 1 コンポーネントでしか使わず、計算が軽い → selector で導出
  • 複数コンポーネントで参照する / Monitor に映す / 履歴を残したい → 別 store に書き出す

Playground の suggestedRate は Monitor と詳細タブの両方で見るため、別 store に書き出す方を採用しています。

ポイント

  • 派生値は 専用 action 経由でのみ書き換える。直接 set を許さないと由来が追える
  • 購読側 store には subscribeWithSelector middleware を被せる。これで subscribe(selector, listener, options) の 3 引数形式が解放される
  • selector が primitive を返す なら比較指定なしで OK。オブジェクトを返す なら { equalityFn: shallow } を必ず指定する
  • 同期リスナーから別 store を更新するときは useStore.getState().action(...)。React の外なので selector は使えない
  • 派生先を「別 store に書き出す」か「selector で導出」かは、参照範囲の広さ で判断する

関連記事

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

  • Zustand 編 — 4 store と setupStoreSync の全体像
  • Store Monitor 編 — 派生値が Monitor に映るタイミング
  • selector 最適化 — useShallow と primitive selector の使い分け
  • Zustand Test (Build) — 完成例と外部デモ

注意点

Playground の派生状態(suggestedRate)の作り方をまとめた記事。selector で計算する方法と、別 store に書き出す方法の使い分けに触れる。

リポジトリを見る

関連サンプル

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

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

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

  • Zustand Test の profileStore で表示用整形を 1 か所にまとめる

    settingsStore の値を trim・大文字化・フォールバックといった整形を施して保持する profileStore の最小設計を解説する。

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

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

  • 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 を提供する。
←サンプル一覧に戻る