Zustand immer ミドルウェアでネスト更新 TypeError が出る原因と正しい書き方

Zustand Immer React TypeScript 状態管理
結論

TypeError の原因は 外部ライブラリの Object.freezeset 内のスプレッド混在です。格納前に 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;
      }),
  }))
);

修正手順

  1. エラーメッセージのプロパティ名を確認し、外部ライブラリ固有の名前(widthchosen 等)なら Object.freeze が原因
  2. 外部オブジェクトを格納する前に structuredClone(externalObj) でディープコピーしてから set に渡す
  3. set ブロック内のスプレッド演算子をすべて削除して immer のドラフト直接変更に置き換える
  4. インポートパスzustand/middleware/immer であることを確認する(immer パッケージ直接ではない)

TypeError が出る3つの原因

原因1:外部ライブラリの Object.freeze オブジェクトをそのまま格納

React Flow・Redux・一部の UI ライブラリは、内部で管理するオブジェクトに Object.freeze() を適用しています。これをそのまま Zustand ストアに格納すると、immer のプロキシが freeze を解除できずエラーになります。

エラーメッセージの末尾に出るプロパティ名(例: widthchosenselected)が外部ライブラリ固有のプロパティなら、このパターンが原因です。

// ❌ React Flow の node オブジェクトをそのまま格納
set((state) => {
  state.selectedNode = externalNode; // externalNode が Object.freeze されている
});

// ✅ 格納前に structuredClone でディープコピー
set((state) => {
  state.selectedNode = structuredClone(externalNode);
});

structuredClone が使えない環境では JSON.parse(JSON.stringify(obj)) でも対応できますが、Dateundefined が失われる点に注意してください。

原因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 のアクションとして別途定義してください。