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

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

Zustand Test の settingsStore に immer を入れて深いネストを楽に更新する

settingsStore にネストしたフィールドを足すときに、immer middleware で『見た目はミュータブル、実体は immutable』な更新に書き換える手順を解説する。

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

対応バージョン

nextjs 15react 19zustand 5immer 10

前提環境

Zustand の create と middleware の重ね方を理解していること

概要

Zustand Test の settingsStore は今のところフラットですが、フォームに address や preferences などの ネストしたフィールド を追加すると set の書き方がきつくなります。immer middleware を被せると、見た目はミュータブルな代入として書けるようになり、実体は immutable な更新で安全です。

// before(ネストの展開が手書きで増える)
set((s) => ({
  ...s,
  address: { ...s.address, city: "Tokyo" },
}));

// after(immer で深い更新を素直に書ける)
set((s) => {
  s.address.city = "Tokyo";
});

インストール

npm install zustand immer

settingsStore に immer を被せる

middleware の重ね順は devtools(persist(subscribeWithSelector(immer(...)))) が読みやすいです。immer は store の中で set の挙動を書き換える ので、最も内側に置きます。

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

type Address = {
  zip:  string;
  city: string;
};

type SettingsState = {
  name:    string;
  email:   string;
  rate:    number;
  address: Address;
  setName:    (name: string) => void;
  setAddress: (partial: Partial<Address>) => void;
  reset:      () => void;
};

const INITIAL: Pick<SettingsState, "name" | "email" | "rate" | "address"> = {
  name:    "",
  email:   "",
  rate:    0,
  address: { zip: "", city: "" },
};

export const useSettingsStore = create<SettingsState>()(
  devtools(
    subscribeWithSelector(
      immer((set) => ({
        ...INITIAL,
        setName:    (name) => set((s) => { s.name = name; }),
        setAddress: (partial) => set((s) => { Object.assign(s.address, partial); }),
        reset:      () => set(() => INITIAL),
      }))
    ),
    { name: "SettingsStore" }
  )
);

ポイントは 3 つ。

  • immer は zustand/middleware/immer から import する(zustand/middleware 直下の旧 immer は v4 の書き方)
  • set((s) => { s.x = ...; }) のように draft への代入 で書く。戻り値はいらない
  • リセットだけは set(() => INITIAL) で全置き換えの方が明示的

部分マージは Object.assign で

Partial<Address> を受けて部分更新したいときは、Object.assign(s.address, partial) が短くて読みやすいです。スプレッドで戻り値を返す書き方も使えますが、immer のスタイルを揃えるなら draft の代入 に統一した方が混乱しません。

setAddress: (partial) => set((s) => {
  Object.assign(s.address, partial);
}),

呼び出し側は setAddress({ city: "Tokyo" }) のように書けます。zip の値は維持されます。

配列の更新も immer で素直に

配列フィールドを抱えるなら、immer の出番がより増えます。Playground の例として、フォームに「タグ」配列を追加するケースを考えます。

type SettingsState = {
  /* ... */
  tags:       string[];
  addTag:     (tag: string) => void;
  removeTag:  (tag: string) => void;
};

export const useSettingsStore = create<SettingsState>()(
  immer((set) => ({
    /* ... */
    tags: [],
    addTag:    (tag) => set((s) => { s.tags.push(tag); }),
    removeTag: (tag) => set((s) => {
      s.tags = s.tags.filter((t) => t !== tag);
    }),
  }))
);

push / splice / s.tags = ... のいずれも書けます。「配列を返したいときは代入、要素操作だけなら mutator」 と使い分けるとシンプルです。

subscribeWithSelector との組み合わせ

subscribeWithSelector の selector は draft ではなく 通常の state を受け取ります。selector 側で代入してはいけません。

useSettingsStore.subscribe(
  (s) => s.address.city,           // ← s は通常の immutable な state
  (city) => {
    useProfileStore.getState().updateFromCity(city);
  }
);

draft への代入は set((s) => { ... }) の中だけのルールです。読み取り(selector / getState())では普通の state として扱います。

移行のコスト

Playground のように setFormData(values) で 全置き換え が中心の書き方では、immer のメリットは限定的です。patchFormData(partial) の部分更新も浅いネストならスプレッドで足ります。 ネストが 2 階層以上に増えた / 配列の要素更新が出てきた タイミングで導入するのが良いバランスです。

ポイント

  • immer は zustand/middleware/immer から import。最も内側 に重ねる
  • set((s) => { s.x = ...; }) のように draft への代入 で書く。戻り値は省略
  • 配列は push / filter / s.tags = ... のどれでも書ける。配列ごと差し替えるときは代入 が読みやすい
  • 部分更新は Object.assign(s.address, partial) が短くて意図が明確
  • selector / getState() は draft ではなく 通常の state を返す。読み取り側で代入してはいけない

関連記事

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

  • Zustand 編 — フラットな state での基本構成
  • slice / combine 編 — 大きい store を分割するなら
  • zustand-with-immer — immer の汎用的な解説
  • Zustand Test (Build) — 完成例と外部デモ

注意点

Playground 追加記事。settingsStore がプロフィール詳細などネストを抱えたら immer に切り替える、という移行を意識した書き方をまとめる。

リポジトリを見る

関連サンプル

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

  • Zustand + Immer で immutable な状態更新をシンプルに書く

    Zustand の immer middleware を使い、ネストした状態をミュータブルな書き方で安全に更新する実装例。

  • Zustand Test の middleware 連鎖を StateCreator で型崩れなく書く

    devtools / persist / subscribeWithSelector / immer を重ねたときに set の引数推論が壊れないよう、StateCreator のジェネリクスで型を補強する手順を解説する。

  • 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 に変更をブロードキャストし、エコーバックを避けながら受信側を更新する手順を解説する。

関連仕様

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

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