Zustand immer ミドルウェアでネスト更新 TypeError が出る原因と正しい書き方
TypeError の原因は 外部ライブラリの Object.freeze か set 内のスプレッド混在です。格納前に structuredClone() でコピーし、set 内は直接変更のみに統一します。
// ✅ 正しい immer ミドルウェアの使い方
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
const useStore = create<State>()(
immer((set) => ({
deep: { nested: { count: 0 } },
increment: () =>
set((state) => {
// ドラフトを直接変更する(スプレッド不要)
state.deep.nested.count += 1;
}),
}))
);
修正手順
- エラーメッセージのプロパティ名を確認し、外部ライブラリ固有の名前(
width・chosen等)ならObject.freezeが原因 - 外部オブジェクトを格納する前に
structuredClone(externalObj)でディープコピーしてからsetに渡す setブロック内のスプレッド演算子をすべて削除して immer のドラフト直接変更に置き換える- インポートパスが
zustand/middleware/immerであることを確認する(immerパッケージ直接ではない)
TypeError が出る3つの原因
原因1:外部ライブラリの Object.freeze オブジェクトをそのまま格納
React Flow・Redux・一部の UI ライブラリは、内部で管理するオブジェクトに Object.freeze() を適用しています。これをそのまま Zustand ストアに格納すると、immer のプロキシが freeze を解除できずエラーになります。
エラーメッセージの末尾に出るプロパティ名(例: width、chosen、selected)が外部ライブラリ固有のプロパティなら、このパターンが原因です。
// ❌ React Flow の node オブジェクトをそのまま格納
set((state) => {
state.selectedNode = externalNode; // externalNode が Object.freeze されている
});
// ✅ 格納前に structuredClone でディープコピー
set((state) => {
state.selectedNode = structuredClone(externalNode);
});
structuredClone が使えない環境では JSON.parse(JSON.stringify(obj)) でも対応できますが、Date や undefined が失われる点に注意してください。
原因2:set ブロック内でスプレッドと直接変更を混在
immer のドラフト内でスプレッド演算子を使うと、immer のプロキシを外れた新しいオブジェクトが生成されます。そのオブジェクトのプロパティをさらに変更しようとすると TypeError になります。
// ❌ スプレッドと直接変更の混在
set((state) => {
state.user = { ...state.user, profile: { ...state.user.profile } };
state.user.profile.name = 'Kawa'; // ここで TypeError
});
// ✅ immer ドラフトを直接変更する
set((state) => {
state.user.profile.name = 'Kawa'; // スプレッド不要
});
immer は内部でプロキシを使ってすべての変更を追跡します。set ブロック内ではスプレッドを書く必要はありません。
原因3:zustand/middleware ではなく immer 本体を直接インポート
immer パッケージ本体の produce 関数を自前で組み合わせた場合、Zustand との統合が不完全になり freeze の扱いが変わります。
// ❌ immer 本体を直接使う独自実装
import produce from 'immer';
const useStore = create((set) => ({
increment: () =>
set(produce((state) => { state.count += 1; })),
}));
// ✅ Zustand 公式の immer ミドルウェアを使う
import { immer } from 'zustand/middleware/immer';
const useStore = create<State>()(
immer((set) => ({
count: 0,
increment: () => set((state) => { state.count += 1; }),
}))
);
ネストしたオブジェクト更新の実践パターン
配列内の特定要素を更新する
interface Todo {
id: string;
text: string;
done: boolean;
}
interface State {
todos: Todo[];
toggleTodo: (id: string) => void;
}
const useStore = create<State>()(
immer((set) => ({
todos: [],
toggleTodo: (id) =>
set((state) => {
const todo = state.todos.find((t) => t.id === id);
if (todo) {
todo.done = !todo.done; // immer なのでそのまま変更可能
}
}),
}))
);
immer のドラフト内では Array.find() で取得した要素をそのまま変更できます。map() で新しい配列を返す必要はありません。
深くネストしたオブジェクトを更新する
interface State {
settings: {
ui: {
theme: 'light' | 'dark';
sidebar: { collapsed: boolean };
};
};
toggleSidebar: () => void;
}
const useStore = create<State>()(
immer((set) => ({
settings: { ui: { theme: 'light', sidebar: { collapsed: false } } },
toggleSidebar: () =>
set((state) => {
// 何段ネストしていても直接アクセスして変更できる
state.settings.ui.sidebar.collapsed = !state.settings.ui.sidebar.collapsed;
}),
}))
);
selector でパフォーマンスを最適化する
immer ミドルウェアを使っていても、selector なしで全状態を購読すると不要な再レンダリングが発生します。
// ❌ 全状態を購読(state 全体が変わるたびに再レンダリング)
const state = useStore();
// ✅ 必要な値だけ selector で取得
const count = useStore((state) => state.deep.nested.count);
const toggleSidebar = useStore((state) => state.toggleSidebar);
アクション関数(toggleSidebar 等)は Zustand では参照が変わらないため、useCallback でラップする必要はありません。
Failure Boundary:初期ステートに関数を含めてはいけない
immer の structuredClone は関数をシリアライズできません。初期ステートのオブジェクトに関数(例えばコールバックや外部から受け取ったハンドラ)を含めると予期しない動作をします。関数は必ず Zustand のアクションとして別途定義してください。