T-CREATOR

Immer 連携で状態更新をもっと簡単に:Zustand で使う produce の技

Immer 連携で状態更新をもっと簡単に:Zustand で使う produce の技

現代の React 開発において、状態管理の複雑さは避けて通れない課題となっています。特に大規模なアプリケーションでは、深くネストしたオブジェクトや複雑な配列操作が頻繁に発生し、従来のスプレッド構文だけでは可読性とメンテナンス性の確保が困難になってきました。Zustand はその軽量性とシンプルさで多くの開発者に愛用されていますが、Immer と組み合わせることで、さらに強力で直感的な状態更新を実現できます。この記事では、Zustand で Immer を活用して、複雑な状態更新をエレガントに解決する実践的な技法を詳しく解説いたします。

背景

イミュータブルな状態更新の複雑さ

React の世界では、状態のイミュータビリティ(不変性)が基本原則となっています。これは、状態オブジェクトを直接変更するのではなく、新しいオブジェクトを作成することで、React の再レンダリング機能や開発者ツールでの状態変更追跡を正しく動作させるためです。

しかし、この原則を守りながら複雑な状態更新を行うことは、しばしば困難な課題となります。単純な状態であれば問題ありませんが、実際のアプリケーションでは多層のネストしたオブジェクトや、様々な操作が必要な配列を扱うことが一般的です。

従来のスプレッド構文を使った更新では、このような複雑さに対処するために、深いネストに対応した煩雑なコードを書く必要がありました。その結果、コードの可読性が低下し、バグの温床となりやすい状況が生まれていました。

ネストしたオブジェクトや配列の更新時の課題

実際のアプリケーションでは、以下のような複雑な状態構造を扱うことが珍しくありません:

typescriptinterface AppState {
  user: {
    profile: {
      personal: {
        name: string;
        email: string;
        preferences: {
          theme: string;
          notifications: {
            email: boolean;
            push: boolean;
            sms: boolean;
          };
        };
      };
      professional: {
        company: string;
        position: string;
        skills: string[];
      };
    };
    activity: {
      loginHistory: Array<{
        timestamp: number;
        location: string;
        device: string;
      }>;
    };
  };
  projects: Array<{
    id: string;
    name: string;
    tasks: Array<{
      id: string;
      title: string;
      completed: boolean;
      assignees: string[];
      comments: Array<{
        id: string;
        author: string;
        content: string;
        timestamp: number;
      }>;
    }>;
  }>;
}

このような構造で、例えば特定のタスクのコメントを更新したい場合、従来の方法では非常に複雑なスプレッド構文が必要になります。

開発体験とパフォーマンスの両立

開発者体験の向上は、プロダクトの品質向上に直結する重要な要素です。しかし、状態更新の複雑さが増すにつれて、開発者はより多くの時間をボイラープレートコードの記述に費やすことになり、本来注力すべきビジネスロジックの実装に集中できなくなってしまいます。

また、複雑な状態更新はバグの原因となりやすく、デバッグにかかる時間も増大します。これらの問題は、開発チーム全体の生産性に悪影響を与える可能性があります。

一方で、パフォーマンスの観点も無視できません。不適切な状態更新は不必要な再レンダリングを引き起こし、アプリケーションのパフォーマンス低下につながります。

課題

スプレッド構文の限界と可読性の問題

従来の Zustand での状態更新は、主にスプレッド構文を使用して行われてきました。シンプルな状態であれば問題ありませんが、複雑になるにつれて深刻な課題が浮上します。

例えば、ネストしたオブジェクトのプロパティを更新する場合:

typescript// 従来の方法での深いネスト更新
const useStore = create((set) => ({
  user: {
    profile: {
      personal: {
        name: '田中太郎',
        preferences: {
          theme: 'light',
          notifications: {
            email: true,
            push: false,
            sms: false,
          },
        },
      },
    },
  },

  // プッシュ通知設定を更新する場合
  updatePushNotification: (enabled) => {
    set((state) => ({
      user: {
        ...state.user,
        profile: {
          ...state.user.profile,
          personal: {
            ...state.user.profile.personal,
            preferences: {
              ...state.user.profile.personal.preferences,
              notifications: {
                ...state.user.profile.personal.preferences
                  .notifications,
                push: enabled,
              },
            },
          },
        },
      },
    }));
  },
}));

このコードの問題点は明らかです。単一のプロパティを更新するために、多くのスプレッド構文を記述する必要があり、コードの可読性が著しく低下します。また、途中でスプレッドを忘れた場合、意図しない状態の上書きが発生する可能性があります。

深くネストした状態の更新パターン

実際のアプリケーションでは、さらに複雑な更新パターンが求められます。例えば、配列内の特定要素のプロパティを更新する場合:

typescript// プロジェクト内の特定タスクのステータスを更新
updateTaskStatus: (projectId, taskId, completed) => {
  set((state) => ({
    projects: state.projects.map((project) =>
      project.id === projectId
        ? {
            ...project,
            tasks: project.tasks.map((task) =>
              task.id === taskId
                ? { ...task, completed }
                : task
            ),
          }
        : project
    ),
  }));
};

この例でも、わずか一つのプロパティを更新するために、複数の map 関数と条件分岐、スプレッド構文を組み合わせた複雑なコードが必要になります。

配列操作での冗長なコード

配列に対する基本的な CRUD 操作も、イミュータブルな方法で実装すると冗長になりがちです:

typescript// 配列への要素追加
addTask: (projectId, newTask) => {
  set((state) => ({
    projects: state.projects.map(project =>
      project.id === projectId
        ? {
            ...project,
            tasks: [...project.tasks, newTask]
          }
        : project
    )
  }));
},

// 配列からの要素削除
removeTask: (projectId, taskId) => {
  set((state) => ({
    projects: state.projects.map(project =>
      project.id === projectId
        ? {
            ...project,
            tasks: project.tasks.filter(task => task.id !== taskId)
          }
        : project
    )
  }));
},

// 配列内要素の並び替え
reorderTasks: (projectId, fromIndex, toIndex) => {
  set((state) => ({
    projects: state.projects.map(project =>
      project.id === projectId
        ? {
            ...project,
            tasks: (() => {
              const newTasks = [...project.tasks];
              const [removed] = newTasks.splice(fromIndex, 1);
              newTasks.splice(toIndex, 0, removed);
              return newTasks;
            })()
          }
        : project
    )
  }));
}

これらの操作は本来シンプルであるべきですが、イミュータビリティを保ちながら実装すると、非常に煩雑なコードになってしまいます。

バグの温床となりやすい手動的な更新

複雑なスプレッド構文は、しばしばバグの原因となります。最も一般的な問題は以下の通りです:

  1. スプレッドの漏れ:途中のレベルでスプレッドを忘れ、意図しない状態の上書きが発生
  2. 参照の共有:オブジェクトの参照が適切にコピーされず、元の状態が変更される
  3. 型安全性の欠如:複雑なネストにより、TypeScript の型チェックが効かない部分が発生
  4. デバッグの困難さ:どこで状態が変更されたかを追跡するのが困難

これらの問題は、アプリケーションの規模が大きくなるにつれてより深刻になり、開発チームの生産性に大きな影響を与えます。

解決策

Zustand での Immer 導入方法

Immer は、イミュータブルな状態更新を直感的で可読性の高いコードで実現するライブラリです。Zustand に Immer を導入することで、複雑な状態更新を大幅に簡素化できます。

まず、必要なパッケージをインストールします:

bashyarn add zustand immer

Immer を使用するための基本的なセットアップは以下の通りです:

typescriptimport { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

const useStore = create(
  immer((set, get) => ({
    // 状態の定義
    user: {
      profile: {
        name: '',
        email: '',
        preferences: {
          theme: 'light',
          notifications: {
            email: true,
            push: false,
          },
        },
      },
    },

    // アクションの定義(Immerを使用)
    updateUserName: (name) => {
      set((state) => {
        state.user.profile.name = name;
      });
    },

    togglePushNotification: () => {
      set((state) => {
        state.user.profile.preferences.notifications.push =
          !state.user.profile.preferences.notifications
            .push;
      });
    },
  }))
);

このように、immerミドルウェアを使用することで、状態を直接変更するような直感的なコードを書きながら、内部的にはイミュータブルな更新が行われます。

produce 関数の基本的な使い方

produce関数は、Immer の中核となる機能です。現在の状態を受け取り、ドラフト状態に対する変更を適用して、新しいイミュータブルな状態を生成します。

typescriptimport { produce } from 'immer';

// 基本的なproduce使用例
const updateUserProfile = (currentState, updates) => {
  return produce(currentState, (draft) => {
    // ドラフト状態を直接変更
    draft.user.profile.name = updates.name;
    draft.user.profile.email = updates.email;

    // 条件付きの更新も簡潔に
    if (updates.theme) {
      draft.user.profile.preferences.theme = updates.theme;
    }
  });
};

Zustand のset関数でもproduceを直接使用できます:

typescriptconst useStore = create((set, get) => ({
  data: {
    /* 複雑な状態構造 */
  },

  updateData: (path, value) => {
    set(
      produce((draft) => {
        // lodashのsetのように、パスを使った更新も可能
        _.set(draft, path, value);
      })
    );
  },
}));

複雑なネストオブジェクトの更新パターン

Immer を使用することで、深くネストしたオブジェクトの更新が劇的に簡素化されます:

typescriptconst useProjectStore = create(
  immer((set) => ({
    projects: [
      {
        id: '1',
        name: 'プロジェクトA',
        settings: {
          general: {
            visibility: 'public',
            collaboration: {
              allowComments: true,
              requireApproval: false,
              permissions: {
                read: ['all'],
                write: ['members'],
                admin: ['owner'],
              },
            },
          },
          advanced: {
            integrations: {
              slack: { enabled: false, webhook: '' },
              github: {
                enabled: true,
                repository: 'user/repo',
              },
            },
          },
        },
      },
    ],

    // 従来の方法では非常に複雑だった更新がシンプルに
    updateProjectPermissions: (projectId, role, users) => {
      set((state) => {
        const project = state.projects.find(
          (p) => p.id === projectId
        );
        if (project) {
          project.settings.general.collaboration.permissions[
            role
          ] = users;
        }
      });
    },

    toggleSlackIntegration: (projectId) => {
      set((state) => {
        const project = state.projects.find(
          (p) => p.id === projectId
        );
        if (project) {
          const slack =
            project.settings.advanced.integrations.slack;
          slack.enabled = !slack.enabled;
        }
      });
    },

    updateMultipleSettings: (projectId, updates) => {
      set((state) => {
        const project = state.projects.find(
          (p) => p.id === projectId
        );
        if (project) {
          // 複数の深いプロパティを一度に更新
          if (updates.visibility) {
            project.settings.general.visibility =
              updates.visibility;
          }
          if (updates.slackWebhook) {
            project.settings.advanced.integrations.slack.webhook =
              updates.slackWebhook;
          }
          if (updates.permissions) {
            Object.assign(
              project.settings.general.collaboration
                .permissions,
              updates.permissions
            );
          }
        }
      });
    },
  }))
);

配列操作の簡潔な書き方(CRUD 操作)

配列に対する CRUD 操作も、Immer によって大幅に簡素化されます:

typescriptconst useTaskStore = create(
  immer((set) => ({
    tasks: [],

    // Create: 新しいタスクを追加
    addTask: (task) => {
      set((state) => {
        state.tasks.push(task);
      });
    },

    // Create: 特定位置に挿入
    insertTaskAt: (index, task) => {
      set((state) => {
        state.tasks.splice(index, 0, task);
      });
    },

    // Read: フィルタリング(新しい配列を作成)
    filterTasks: (predicate) => {
      set((state) => {
        state.tasks = state.tasks.filter(predicate);
      });
    },

    // Update: 特定タスクの更新
    updateTask: (taskId, updates) => {
      set((state) => {
        const task = state.tasks.find(
          (t) => t.id === taskId
        );
        if (task) {
          Object.assign(task, updates);
        }
      });
    },

    // Update: 複数タスクの一括更新
    updateMultipleTasks: (taskIds, updates) => {
      set((state) => {
        state.tasks.forEach((task) => {
          if (taskIds.includes(task.id)) {
            Object.assign(task, updates);
          }
        });
      });
    },

    // Delete: 特定タスクを削除
    removeTask: (taskId) => {
      set((state) => {
        const index = state.tasks.findIndex(
          (t) => t.id === taskId
        );
        if (index !== -1) {
          state.tasks.splice(index, 1);
        }
      });
    },

    // Delete: 複数タスクを削除
    removeTasks: (taskIds) => {
      set((state) => {
        state.tasks = state.tasks.filter(
          (t) => !taskIds.includes(t.id)
        );
      });
    },

    // 並び替え
    reorderTasks: (fromIndex, toIndex) => {
      set((state) => {
        const [movedTask] = state.tasks.splice(
          fromIndex,
          1
        );
        state.tasks.splice(toIndex, 0, movedTask);
      });
    },

    // ソート
    sortTasks: (compareFn) => {
      set((state) => {
        state.tasks.sort(compareFn);
      });
    },
  }))
);

条件分岐を含む複雑な更新ロジック

実際のアプリケーションでは、複雑な条件分岐を含む更新ロジックが必要になることがあります。Immer を使用することで、このような複雑なロジックも直感的に記述できます:

typescriptconst useShoppingCartStore = create(
  immer((set, get) => ({
    cart: {
      items: [],
      discounts: [],
      shipping: null,
      total: 0,
    },
    user: {
      tier: 'standard', // 'standard', 'premium', 'vip'
      points: 0,
    },

    addToCart: (product, quantity) => {
      set((state) => {
        const existingItem = state.cart.items.find(
          (item) => item.productId === product.id
        );

        if (existingItem) {
          // 既存アイテムの更新
          existingItem.quantity += quantity;

          // 数量に応じた割引適用
          if (existingItem.quantity >= 5) {
            const bulkDiscount = state.cart.discounts.find(
              (d) => d.type === 'bulk'
            );
            if (!bulkDiscount) {
              state.cart.discounts.push({
                type: 'bulk',
                amount: 0.1,
                description: '5個以上で10%割引',
              });
            }
          }
        } else {
          // 新しいアイテムの追加
          state.cart.items.push({
            productId: product.id,
            name: product.name,
            price: product.price,
            quantity,
          });
        }

        // ユーザーティアに応じた特別処理
        if (state.user.tier === 'premium') {
          // プレミアム会員は送料無料
          state.cart.shipping = { type: 'free', cost: 0 };
        } else if (state.user.tier === 'vip') {
          // VIP会員は追加ポイント付与
          state.user.points +=
            product.price * quantity * 0.05;

          // VIP専用割引
          const vipDiscount = state.cart.discounts.find(
            (d) => d.type === 'vip'
          );
          if (!vipDiscount) {
            state.cart.discounts.push({
              type: 'vip',
              amount: 0.15,
              description: 'VIP会員15%割引',
            });
          }
        }

        // 合計金額の再計算
        const subtotal = state.cart.items.reduce(
          (sum, item) => sum + item.price * item.quantity,
          0
        );
        const discountAmount = state.cart.discounts.reduce(
          (sum, discount) =>
            sum + subtotal * discount.amount,
          0
        );
        const shippingCost = state.cart.shipping?.cost || 0;

        state.cart.total =
          subtotal - discountAmount + shippingCost;
      });
    },

    applyPromoCode: (code) => {
      set((state) => {
        // プロモーションコードの検証と適用
        const promoRules = {
          SUMMER2024: { amount: 0.2, minPurchase: 5000 },
          NEWUSER: { amount: 0.15, userType: 'new' },
          WEEKEND: {
            amount: 0.1,
            dayRestriction: 'weekend',
          },
        };

        const rule = promoRules[code];
        if (!rule) return; // 無効なコード

        const subtotal = state.cart.items.reduce(
          (sum, item) => sum + item.price * item.quantity,
          0
        );

        // 適用条件のチェック
        let canApply = true;

        if (
          rule.minPurchase &&
          subtotal < rule.minPurchase
        ) {
          canApply = false;
        }

        if (
          rule.userType === 'new' &&
          state.user.tier !== 'standard'
        ) {
          canApply = false;
        }

        if (rule.dayRestriction === 'weekend') {
          const today = new Date().getDay();
          if (today !== 0 && today !== 6) {
            // 0 = Sunday, 6 = Saturday
            canApply = false;
          }
        }

        if (canApply) {
          // 既存のプロモーション割引を削除
          state.cart.discounts =
            state.cart.discounts.filter(
              (d) => d.type !== 'promo'
            );

          // 新しいプロモーション割引を追加
          state.cart.discounts.push({
            type: 'promo',
            code,
            amount: rule.amount,
            description: `プロモーションコード: ${code}`,
          });

          // 合計金額の再計算
          const discountAmount =
            state.cart.discounts.reduce(
              (sum, discount) =>
                sum + subtotal * discount.amount,
              0
            );
          const shippingCost =
            state.cart.shipping?.cost || 0;
          state.cart.total =
            subtotal - discountAmount + shippingCost;
        }
      });
    },
  }))
);

この例では、商品の追加、割引の適用、ユーザーティアによる特別処理など、複雑な業務ロジックを Immer によって直感的に記述しています。従来のスプレッド構文では、このような複雑な条件分岐と状態更新を可読性を保ったまま実装することは困難でした。

具体例

TODO アプリでの実装比較(Immer あり/なし)

実際のアプリケーションで Immer の効果を確認するため、TODO アプリケーションでの実装を比較してみましょう。

従来の方法(Immer なし)

typescriptconst useTodoStore = create((set) => ({
  todos: [],
  categories: [],
  user: {
    preferences: {
      sortBy: 'dueDate',
      showCompleted: true,
      defaultCategory: null,
    },
  },

  // TODOの追加
  addTodo: (todoData) => {
    set((state) => ({
      todos: [
        ...state.todos,
        {
          ...todoData,
          id: Date.now().toString(),
          completed: false,
          createdAt: new Date().toISOString(),
        },
      ],
    }));
  },

  // TODOの完了状態切り替え
  toggleTodo: (todoId) => {
    set((state) => ({
      todos: state.todos.map((todo) =>
        todo.id === todoId
          ? { ...todo, completed: !todo.completed }
          : todo
      ),
    }));
  },

  // カテゴリ付きTODOの更新
  updateTodoWithCategory: (todoId, updates) => {
    set((state) => ({
      todos: state.todos.map((todo) =>
        todo.id === todoId
          ? {
              ...todo,
              ...updates,
              category: updates.categoryId
                ? state.categories.find(
                    (c) => c.id === updates.categoryId
                  )
                : todo.category,
            }
          : todo
      ),
    }));
  },

  // 複数TODOの一括操作
  bulkUpdateTodos: (todoIds, updates) => {
    set((state) => ({
      todos: state.todos.map((todo) =>
        todoIds.includes(todo.id)
          ? { ...todo, ...updates }
          : todo
      ),
    }));
  },

  // ユーザー設定の更新
  updateUserPreferences: (preferences) => {
    set((state) => ({
      user: {
        ...state.user,
        preferences: {
          ...state.user.preferences,
          ...preferences,
        },
      },
    }));
  },
}));

Immer を使用した方法

typescriptconst useTodoStoreWithImmer = create(
  immer((set) => ({
    todos: [],
    categories: [],
    user: {
      preferences: {
        sortBy: 'dueDate',
        showCompleted: true,
        defaultCategory: null,
      },
    },

    // TODOの追加
    addTodo: (todoData) => {
      set((state) => {
        state.todos.push({
          ...todoData,
          id: Date.now().toString(),
          completed: false,
          createdAt: new Date().toISOString(),
        });
      });
    },

    // TODOの完了状態切り替え
    toggleTodo: (todoId) => {
      set((state) => {
        const todo = state.todos.find(
          (t) => t.id === todoId
        );
        if (todo) {
          todo.completed = !todo.completed;
        }
      });
    },

    // カテゴリ付きTODOの更新
    updateTodoWithCategory: (todoId, updates) => {
      set((state) => {
        const todo = state.todos.find(
          (t) => t.id === todoId
        );
        if (todo) {
          Object.assign(todo, updates);
          if (updates.categoryId) {
            todo.category = state.categories.find(
              (c) => c.id === updates.categoryId
            );
          }
        }
      });
    },

    // 複数TODOの一括操作
    bulkUpdateTodos: (todoIds, updates) => {
      set((state) => {
        state.todos.forEach((todo) => {
          if (todoIds.includes(todo.id)) {
            Object.assign(todo, updates);
          }
        });
      });
    },

    // ユーザー設定の更新
    updateUserPreferences: (preferences) => {
      set((state) => {
        Object.assign(state.user.preferences, preferences);
      });
    },
  }))
);

このように、Immer を使用することで、コードの行数が削減され、可読性が大幅に向上しています。特に、複雑な条件分岐や配列操作において、その効果は顕著に現れます。

E コマースサイトのカート操作

E コマースサイトでのショッピングカート機能は、複雑な状態更新が必要な典型例です。Immer を使用した実装を見てみましょう:

typescriptconst useEcommerceStore = create(
  immer((set, get) => ({
    cart: {
      items: [],
      appliedCoupons: [],
      shippingMethod: null,
      billingAddress: null,
      shippingAddress: null,
    },
    products: [],
    user: {
      membershipTier: 'standard',
      savedAddresses: [],
    },

    // 商品をカートに追加
    addToCart: (product, quantity, options = {}) => {
      set((state) => {
        const existingItemIndex =
          state.cart.items.findIndex(
            (item) =>
              item.productId === product.id &&
              JSON.stringify(item.options) ===
                JSON.stringify(options)
          );

        if (existingItemIndex >= 0) {
          // 既存商品の数量を更新
          state.cart.items[existingItemIndex].quantity +=
            quantity;
        } else {
          // 新しい商品をカートに追加
          state.cart.items.push({
            productId: product.id,
            name: product.name,
            price: product.price,
            quantity,
            options,
            addedAt: new Date().toISOString(),
          });
        }

        // 商品追加時の自動処理
        if (state.user.membershipTier === 'premium') {
          // プレミアム会員は自動的に送料無料
          if (
            !state.cart.shippingMethod ||
            state.cart.shippingMethod.cost > 0
          ) {
            state.cart.shippingMethod = {
              id: 'free',
              name: '送料無料(プレミアム会員特典)',
              cost: 0,
            };
          }
        }
      });
    },

    // カート内商品の数量変更
    updateCartItemQuantity: (
      productId,
      options,
      newQuantity
    ) => {
      set((state) => {
        const item = state.cart.items.find(
          (item) =>
            item.productId === productId &&
            JSON.stringify(item.options) ===
              JSON.stringify(options)
        );

        if (item) {
          if (newQuantity <= 0) {
            // 数量が0以下の場合は商品を削除
            const index = state.cart.items.indexOf(item);
            state.cart.items.splice(index, 1);
          } else {
            item.quantity = newQuantity;
          }
        }
      });
    },

    // クーポンの適用
    applyCoupon: (couponCode) => {
      set((state) => {
        const existingCoupon =
          state.cart.appliedCoupons.find(
            (c) => c.code === couponCode
          );
        if (existingCoupon) return; // 既に適用済み

        // クーポンの検証(実際にはAPIコールになる)
        const couponData = validateCoupon(
          couponCode,
          state.cart
        );

        if (couponData.isValid) {
          state.cart.appliedCoupons.push({
            code: couponCode,
            discount: couponData.discount,
            type: couponData.type, // 'percentage' or 'fixed'
            appliedAt: new Date().toISOString(),
          });

          // 特定のクーポンタイプに応じた追加処理
          if (couponData.type === 'free-shipping') {
            state.cart.shippingMethod = {
              id: 'free',
              name: '送料無料(クーポン適用)',
              cost: 0,
            };
          }
        }
      });
    },

    // 配送先住所の設定
    setShippingAddress: (address) => {
      set((state) => {
        state.cart.shippingAddress = address;

        // 配送先に基づく配送方法の自動更新
        const availableShippingMethods =
          calculateShippingMethods(address);

        // 現在選択されている配送方法が利用できない場合は最安値を自動選択
        if (
          !availableShippingMethods.find(
            (m) => m.id === state.cart.shippingMethod?.id
          )
        ) {
          state.cart.shippingMethod =
            availableShippingMethods.reduce(
              (cheapest, method) =>
                method.cost < cheapest.cost
                  ? method
                  : cheapest
            );
        }
      });
    },

    // 注文の確定処理
    confirmOrder: () => {
      set((state) => {
        const orderData = {
          items: state.cart.items,
          appliedCoupons: state.cart.appliedCoupons,
          shippingMethod: state.cart.shippingMethod,
          billingAddress: state.cart.billingAddress,
          shippingAddress: state.cart.shippingAddress,
          total: calculateTotal(state.cart),
          orderedAt: new Date().toISOString(),
        };

        // 注文履歴に追加(実際にはAPIコール後に行う)
        if (!state.user.orderHistory) {
          state.user.orderHistory = [];
        }
        state.user.orderHistory.unshift(orderData);

        // カートをクリア
        state.cart.items = [];
        state.cart.appliedCoupons = [];
        state.cart.shippingMethod = null;
        state.cart.billingAddress = null;
        state.cart.shippingAddress = null;
      });
    },
  }))
);

フォーム状態の階層的な更新

複雑なフォームでの状態管理は、Immer が特に力を発揮する分野です:

typescriptconst useFormStore = create(
  immer((set) => ({
    formData: {
      personalInfo: {
        firstName: '',
        lastName: '',
        email: '',
        phone: '',
        birthDate: '',
      },
      address: {
        postal: '',
        prefecture: '',
        city: '',
        street: '',
        building: '',
      },
      preferences: {
        newsletter: false,
        notifications: {
          email: true,
          sms: false,
          push: true,
        },
        privacy: {
          profileVisibility: 'public',
          dataSharing: false,
        },
      },
    },
    validation: {
      errors: {},
      touched: {},
    },
    submission: {
      isSubmitting: false,
      progress: 0,
    },

    // 単一フィールドの更新
    updateField: (path, value) => {
      set((state) => {
        // lodash.setのような動作をImmerで実現
        const keys = path.split('.');
        let current = state.formData;

        for (let i = 0; i < keys.length - 1; i++) {
          current = current[keys[i]];
        }

        current[keys[keys.length - 1]] = value;

        // フィールドがタッチされたことを記録
        let touchedCurrent = state.validation.touched;
        for (let i = 0; i < keys.length - 1; i++) {
          if (!touchedCurrent[keys[i]]) {
            touchedCurrent[keys[i]] = {};
          }
          touchedCurrent = touchedCurrent[keys[i]];
        }
        touchedCurrent[keys[keys.length - 1]] = true;
      });
    },

    // セクション全体の更新
    updateSection: (section, data) => {
      set((state) => {
        Object.assign(state.formData[section], data);

        // セクション内のすべてのフィールドをタッチ済みに
        if (!state.validation.touched[section]) {
          state.validation.touched[section] = {};
        }
        Object.keys(data).forEach((key) => {
          state.validation.touched[section][key] = true;
        });
      });
    },

    // バリデーションエラーの設定
    setValidationError: (path, error) => {
      set((state) => {
        const keys = path.split('.');
        let current = state.validation.errors;

        for (let i = 0; i < keys.length - 1; i++) {
          if (!current[keys[i]]) {
            current[keys[i]] = {};
          }
          current = current[keys[i]];
        }

        if (error) {
          current[keys[keys.length - 1]] = error;
        } else {
          delete current[keys[keys.length - 1]];
        }
      });
    },

    // フォーム全体のバリデーション
    validateForm: () => {
      set((state) => {
        const errors = {};

        // 必須フィールドのチェック
        const requiredFields = [
          'personalInfo.firstName',
          'personalInfo.lastName',
          'personalInfo.email',
          'address.postal',
          'address.prefecture',
        ];

        requiredFields.forEach((fieldPath) => {
          const keys = fieldPath.split('.');
          let value = state.formData;

          for (const key of keys) {
            value = value[key];
          }

          if (!value || value.trim() === '') {
            let errorCurrent = errors;
            for (let i = 0; i < keys.length - 1; i++) {
              if (!errorCurrent[keys[i]]) {
                errorCurrent[keys[i]] = {};
              }
              errorCurrent = errorCurrent[keys[i]];
            }
            errorCurrent[keys[keys.length - 1]] =
              'このフィールドは必須です';
          }
        });

        // メールアドレスの形式チェック
        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        if (
          state.formData.personalInfo.email &&
          !emailRegex.test(
            state.formData.personalInfo.email
          )
        ) {
          if (!errors.personalInfo)
            errors.personalInfo = {};
          errors.personalInfo.email =
            '有効なメールアドレスを入力してください';
        }

        state.validation.errors = errors;
      });
    },

    // フォームのリセット
    resetForm: () => {
      set((state) => {
        // フォームデータを初期値にリセット
        state.formData = {
          personalInfo: {
            firstName: '',
            lastName: '',
            email: '',
            phone: '',
            birthDate: '',
          },
          address: {
            postal: '',
            prefecture: '',
            city: '',
            street: '',
            building: '',
          },
          preferences: {
            newsletter: false,
            notifications: {
              email: true,
              sms: false,
              push: true,
            },
            privacy: {
              profileVisibility: 'public',
              dataSharing: false,
            },
          },
        };

        // バリデーション状態もリセット
        state.validation.errors = {};
        state.validation.touched = {};

        // 送信状態もリセット
        state.submission.isSubmitting = false;
        state.submission.progress = 0;
      });
    },
  }))
);

リアルタイムチャットでのメッセージ管理

リアルタイムチャットアプリケーションでは、頻繁な状態更新と複雑なデータ構造が必要です:

typescriptconst useChatStore = create(
  immer((set) => ({
    rooms: [],
    currentRoomId: null,
    user: {
      id: '',
      name: '',
      avatar: '',
      status: 'online',
    },
    typing: {},

    // 新しいメッセージの受信
    receiveMessage: (roomId, message) => {
      set((state) => {
        const room = state.rooms.find(
          (r) => r.id === roomId
        );
        if (room) {
          room.messages.push({
            ...message,
            id: message.id || Date.now().toString(),
            timestamp:
              message.timestamp || new Date().toISOString(),
            status: 'delivered',
          });

          // 未読数の更新
          if (roomId !== state.currentRoomId) {
            room.unreadCount = (room.unreadCount || 0) + 1;
          }

          // 最後のメッセージを更新
          room.lastMessage = message;
          room.lastActivity = new Date().toISOString();
        }
      });
    },

    // メッセージの送信
    sendMessage: (roomId, content, type = 'text') => {
      set((state) => {
        const room = state.rooms.find(
          (r) => r.id === roomId
        );
        if (room) {
          const newMessage = {
            id: Date.now().toString(),
            content,
            type,
            senderId: state.user.id,
            senderName: state.user.name,
            timestamp: new Date().toISOString(),
            status: 'sending',
          };

          room.messages.push(newMessage);
          room.lastMessage = newMessage;
          room.lastActivity = new Date().toISOString();
        }
      });
    },

    // メッセージの状態更新(送信完了、既読など)
    updateMessageStatus: (roomId, messageId, status) => {
      set((state) => {
        const room = state.rooms.find(
          (r) => r.id === roomId
        );
        if (room) {
          const message = room.messages.find(
            (m) => m.id === messageId
          );
          if (message) {
            message.status = status;

            if (status === 'read') {
              message.readAt = new Date().toISOString();
            }
          }
        }
      });
    },

    // 一括既読処理
    markAllMessagesAsRead: (roomId) => {
      set((state) => {
        const room = state.rooms.find(
          (r) => r.id === roomId
        );
        if (room) {
          const now = new Date().toISOString();

          room.messages.forEach((message) => {
            if (
              message.senderId !== state.user.id &&
              message.status !== 'read'
            ) {
              message.status = 'read';
              message.readAt = now;
            }
          });

          room.unreadCount = 0;
        }
      });
    },

    // タイピング状態の管理
    setTyping: (roomId, userId, isTyping) => {
      set((state) => {
        if (!state.typing[roomId]) {
          state.typing[roomId] = {};
        }

        if (isTyping) {
          state.typing[roomId][userId] = {
            startedAt: new Date().toISOString(),
          };
        } else {
          delete state.typing[roomId][userId];
        }
      });
    },

    // ルームの参加者管理
    updateRoomParticipants: (roomId, participants) => {
      set((state) => {
        const room = state.rooms.find(
          (r) => r.id === roomId
        );
        if (room) {
          room.participants = participants;

          // オンライン状態の更新
          participants.forEach((participant) => {
            const existingParticipant =
              room.participants.find(
                (p) => p.id === participant.id
              );
            if (existingParticipant) {
              existingParticipant.status =
                participant.status;
              existingParticipant.lastSeen =
                participant.lastSeen;
            }
          });
        }
      });
    },

    // メッセージの削除
    deleteMessage: (roomId, messageId) => {
      set((state) => {
        const room = state.rooms.find(
          (r) => r.id === roomId
        );
        if (room) {
          const messageIndex = room.messages.findIndex(
            (m) => m.id === messageId
          );
          if (messageIndex !== -1) {
            room.messages.splice(messageIndex, 1);

            // 最後のメッセージが削除された場合の更新
            if (
              room.lastMessage &&
              room.lastMessage.id === messageId
            ) {
              room.lastMessage =
                room.messages[room.messages.length - 1] ||
                null;
            }
          }
        }
      });
    },

    // メッセージの編集
    editMessage: (roomId, messageId, newContent) => {
      set((state) => {
        const room = state.rooms.find(
          (r) => r.id === roomId
        );
        if (room) {
          const message = room.messages.find(
            (m) => m.id === messageId
          );
          if (message) {
            message.content = newContent;
            message.editedAt = new Date().toISOString();
            message.isEdited = true;
          }
        }
      });
    },
  }))
);

ダッシュボードでのデータ可視化状態

複雑なダッシュボードアプリケーションでの状態管理例です:

typescriptconst useDashboardStore = create(
  immer((set) => ({
    widgets: [],
    layout: {
      columns: 12,
      rowHeight: 30,
      margin: [10, 10],
    },
    filters: {
      dateRange: {
        start: null,
        end: null,
      },
      categories: [],
      status: 'all',
    },
    data: {
      metrics: {},
      charts: {},
      tables: {},
    },

    // ウィジェットの追加
    addWidget: (widgetConfig) => {
      set((state) => {
        const newWidget = {
          ...widgetConfig,
          id: Date.now().toString(),
          position: {
            x: 0,
            y: 0,
            w: widgetConfig.defaultWidth || 4,
            h: widgetConfig.defaultHeight || 4,
          },
          settings: {
            ...widgetConfig.defaultSettings,
            refreshInterval: 30000,
          },
        };

        state.widgets.push(newWidget);

        // ウィジェット用のデータ領域を初期化
        if (newWidget.type === 'chart') {
          state.data.charts[newWidget.id] = {
            data: [],
            loading: false,
            error: null,
          };
        } else if (newWidget.type === 'table') {
          state.data.tables[newWidget.id] = {
            data: [],
            pagination: {
              page: 1,
              pageSize: 10,
              total: 0,
            },
            loading: false,
          };
        }
      });
    },

    // ウィジェットの位置・サイズ更新
    updateWidgetLayout: (widgetId, position) => {
      set((state) => {
        const widget = state.widgets.find(
          (w) => w.id === widgetId
        );
        if (widget) {
          Object.assign(widget.position, position);
        }
      });
    },

    // ウィジェット設定の更新
    updateWidgetSettings: (widgetId, settings) => {
      set((state) => {
        const widget = state.widgets.find(
          (w) => w.id === widgetId
        );
        if (widget) {
          Object.assign(widget.settings, settings);

          // 設定変更に応じたデータの再読み込みフラグ
          if (
            widget.type === 'chart' &&
            state.data.charts[widgetId]
          ) {
            state.data.charts[widgetId].needsRefresh = true;
          }
        }
      });
    },

    // フィルターの適用
    applyFilters: (newFilters) => {
      set((state) => {
        Object.assign(state.filters, newFilters);

        // すべてのウィジェットにフィルター変更を通知
        state.widgets.forEach((widget) => {
          if (
            widget.type === 'chart' &&
            state.data.charts[widget.id]
          ) {
            state.data.charts[widget.id].needsRefresh =
              true;
          } else if (
            widget.type === 'table' &&
            state.data.tables[widget.id]
          ) {
            state.data.tables[widget.id].needsRefresh =
              true;
            // テーブルは最初のページに戻る
            state.data.tables[
              widget.id
            ].pagination.page = 1;
          }
        });
      });
    },

    // チャートデータの更新
    updateChartData: (widgetId, data) => {
      set((state) => {
        if (state.data.charts[widgetId]) {
          state.data.charts[widgetId].data = data;
          state.data.charts[widgetId].loading = false;
          state.data.charts[widgetId].error = null;
          state.data.charts[widgetId].lastUpdated =
            new Date().toISOString();
          state.data.charts[widgetId].needsRefresh = false;
        }
      });
    },

    // テーブルデータの更新
    updateTableData: (widgetId, data, pagination) => {
      set((state) => {
        if (state.data.tables[widgetId]) {
          state.data.tables[widgetId].data = data;
          Object.assign(
            state.data.tables[widgetId].pagination,
            pagination
          );
          state.data.tables[widgetId].loading = false;
          state.data.tables[widgetId].needsRefresh = false;
        }
      });
    },

    // ダッシュボードレイアウトの保存/復元
    saveDashboardLayout: (layoutName) => {
      set((state) => {
        if (!state.savedLayouts) {
          state.savedLayouts = {};
        }

        state.savedLayouts[layoutName] = {
          widgets: JSON.parse(
            JSON.stringify(state.widgets)
          ),
          layout: JSON.parse(JSON.stringify(state.layout)),
          savedAt: new Date().toISOString(),
        };
      });
    },

    loadDashboardLayout: (layoutName) => {
      set((state) => {
        if (
          state.savedLayouts &&
          state.savedLayouts[layoutName]
        ) {
          const saved = state.savedLayouts[layoutName];
          state.widgets = JSON.parse(
            JSON.stringify(saved.widgets)
          );
          state.layout = JSON.parse(
            JSON.stringify(saved.layout)
          );

          // データ領域の再初期化
          state.data.charts = {};
          state.data.tables = {};

          state.widgets.forEach((widget) => {
            if (widget.type === 'chart') {
              state.data.charts[widget.id] = {
                data: [],
                loading: false,
                error: null,
                needsRefresh: true,
              };
            } else if (widget.type === 'table') {
              state.data.tables[widget.id] = {
                data: [],
                pagination: {
                  page: 1,
                  pageSize: 10,
                  total: 0,
                },
                loading: false,
                needsRefresh: true,
              };
            }
          });
        }
      });
    },
  }))
);

まとめ

Immer がもたらす開発体験の向上

Immer と Zustand の組み合わせは、React アプリケーションの状態管理において革新的な開発体験をもたらします。最も顕著な改善点は、コードの直感性です。従来のスプレッド構文では、深くネストしたオブジェクトの更新に複雑な記述が必要でしたが、Immer を使用することで、まるでミュータブルなオブジェクトを操作するような自然なコードを書くことができます。

この直感性の向上は、開発速度の向上に直結します。開発者は複雑なスプレッド構文を考える時間を削減し、ビジネスロジックの実装に集中できるようになります。また、コードレビューの効率も大幅に改善されます。レビュアーは状態更新の意図を素早く理解でき、バグの発見も容易になります。

さらに、エラー削減効果も見逃せません。従来の手動的なイミュータブル更新では、スプレッドの漏れや参照の共有といったバグが頻繁に発生していました。Immer を使用することで、これらのヒューマンエラーを大幅に削減できます。

パフォーマンスとのバランス

Immer の使用には若干のパフォーマンスオーバーヘッドが伴いますが、実際のアプリケーションでは、その影響は開発体験の向上によるメリットと比較して無視できる程度です。Immer は内部的に Proxy を使用しており、変更されたプロパティのみを新しいオブジェクトとして生成するため、効率的なイミュータブル更新を実現しています。

重要なのは、パフォーマンスクリティカルな部分と開発効率を重視する部分を適切に使い分けることです。例えば、リアルタイムでの高頻度更新が必要な部分では従来の方法を使用し、複雑な業務ロジックを含む部分では Immer を活用するといった戦略的な使い分けが効果的です。

また、Immer は変更検知が高精度で行われるため、不必要な再レンダリングを防ぐ効果もあります。これにより、全体的なパフォーマンスの向上に寄与することも少なくありません。

チーム開発での導入メリット

チーム開発において、Immer の導入は特に大きなメリットをもたらします。最も重要な点は、コード品質の標準化です。Immer を使用することで、チームメンバー間での状態更新の記述方法が統一され、コードベース全体の一貫性が向上します。

新しいチームメンバーの学習コストの削減も重要なメリットです。複雑なスプレッド構文のパターンを習得する必要がなく、直感的な記述方法で状態更新を行えるため、新しいメンバーがプロジェクトに参加しやすくなります。

また、保守性の向上も見逃せません。Immer を使用したコードは可読性が高く、機能追加や修正時の影響範囲が明確になります。これにより、長期的なプロジェクトの保守コストを大幅に削減できます。

デバッグの効率性も向上します。Immer を使用した状態更新では、変更の意図が明確に表現されるため、バグの原因特定や修正が容易になります。

継続的な改善の観点でも、Immer は有効です。コードの意図が明確に表現されるため、リファクタリングや機能拡張時の安全性が向上し、技術的負債の蓄積を防ぐことができます。

Zustand と Immer の組み合わせは、現代の React 開発において、開発効率と保守性を両立させる優れたソリューションです。特に複雑な状態管理が必要なアプリケーションでは、その効果は顕著に現れます。適切に導入することで、チーム全体の生産性向上と、長期的な技術的品質の維持を実現できるでしょう。

関連リンク