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