T-CREATOR

Zustand subscribeWithSelector で発生する古い参照問題:メモ化と equalityFn の落とし穴

Zustand subscribeWithSelector で発生する古い参照問題:メモ化と equalityFn の落とし穴

Zustand の subscribeWithSelector ミドルウェアは、状態の特定部分だけを監視できる便利な機能です。しかし、この機能を使う際に「古い参照が残り続ける」という予期しない挙動に遭遇することがあります。

この問題は、メモ化された selector 関数と equalityFn の組み合わせによって引き起こされるもので、開発者が意図しない動作につながることも少なくありません。本記事では、この問題の仕組みを丁寧に解説し、実践的な解決策をご紹介いたします。

背景

subscribeWithSelector の基本的な役割

Zustand は React のための軽量な状態管理ライブラリです。subscribeWithSelector ミドルウェアを使うと、ストア全体の変更ではなく、特定の値だけを監視できるようになります。

これにより、不要な再レンダリングを防ぎ、パフォーマンスを最適化することができるでしょう。

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

// subscribeWithSelector を適用したストアの定義
const useStore = create(
  subscribeWithSelector((set) => ({
    user: { name: 'Alice', age: 25 },
    theme: 'light',
    // state を更新する関数
    setUser: (user) => set({ user }),
    setTheme: (theme) => set({ theme }),
  }))
);

上記のコードは、subscribeWithSelector ミドルウェアを適用したストアの例です。このストアには usertheme という 2 つの状態が含まれています。

typescript// user.name の変更だけを監視する subscription
const unsubscribe = useStore.subscribe(
  (state) => state.user.name,
  (userName) => {
    console.log('User name changed:', userName);
  }
);

この subscription では、state.user.name が変更されたときだけコールバック関数が実行されます。theme が変わっても反応しません。

selector とは何か

selector は、ストアの状態から必要な値だけを取り出す関数のことです。上記の例では (state) => state.user.name が selector に該当します。

この selector 関数が返す値が変更されたときだけ、subscription のコールバックが呼び出される仕組みになっています。

以下の図は、subscribeWithSelector の基本的な動作フローを示したものです。

mermaidflowchart TB
  stateChange["ストアの状態変更"]
  selector["Selector 実行<br/>(state) => state.user.name"]
  compare["前回の値と比較<br/>equalityFn"]
  callback["コールバック実行<br/>console.log(userName)"]
  skip["コールバックスキップ"]

  stateChange --> selector
  selector --> compare
  compare -->|値が変更された| callback
  compare -->|値が同じ| skip

図で理解できる要点:

  • 状態が変更されると、まず selector が実行される
  • selector の返り値を前回の値と比較する
  • 値が変わっていればコールバックを実行、同じならスキップする

メモ化の基本概念

React や JavaScript の世界では、パフォーマンス最適化のために「メモ化」という手法がよく使われます。

メモ化とは、関数の計算結果をキャッシュし、同じ入力があった場合は再計算せずにキャッシュした結果を返す技術のことです。useMemouseCallback などがこの代表例ですね。

typescriptimport { useMemo } from 'react';

function UserProfile({ user }) {
  // user が同じオブジェクトなら、メモ化された値を返す
  const formattedName = useMemo(
    () => `${user.firstName} ${user.lastName}`,
    [user] // 依存配列
  );

  return <div>{formattedName}</div>;
}

この例では、user が変わらない限り、formattedName の計算は行われません。依存配列に指定した値が変わったときだけ、再計算されます。

課題

古い参照が残り続ける問題の発生

subscribeWithSelector でメモ化された selector を使うと、意図しない挙動が発生することがあります。具体的には、状態が更新されているのにコールバックが呼ばれないという問題です。

typescriptfunction MyComponent() {
  // useMemo でメモ化された selector
  const userSelector = useMemo(
    () => (state) => state.user,
    [] // 空の依存配列 = 初回レンダリング時のみ生成
  );

  useEffect(() => {
    // メモ化された selector で subscribe
    const unsubscribe = useStore.subscribe(
      userSelector,
      (user) => {
        console.log('User changed:', user);
      }
    );

    return unsubscribe;
  }, [userSelector]);

  return <div>...</div>;
}

このコードでは、userSelector が空の依存配列でメモ化されているため、コンポーネントのライフサイクル全体で同じ関数参照が保持されます。

一見問題なさそうに見えますが、実は内部で古いクロージャが保持され続けるのです。

クロージャによる古い参照の保持

JavaScript のクロージャは、関数が定義されたときのスコープを記憶します。メモ化された selector は、作成時点の state への参照を保持し続けることになるでしょう。

typescript// 問題のあるパターン
const userSelector = useMemo(() => {
  let cachedUser = null; // このクロージャ内の変数が問題

  return (state) => {
    if (!cachedUser) {
      cachedUser = state.user; // 初回だけ state.user を保存
    }
    return cachedUser; // 常に初回の値を返す
  };
}, []);

この例では、cachedUser が初回の値を保持し続けるため、state が更新されても古い値が返され続けます。

実際のコードではここまで明示的ではありませんが、メモ化と組み合わせると似たような状況が起こり得るのです。

equalityFn の比較ロジックの落とし穴

subscribeWithSelector には、値の比較方法を指定する equalityFn というオプションがあります。デフォルトでは Object.is による厳密等価比較が使われますが、これが問題を引き起こすこともあります。

typescript// オブジェクトの参照比較
const obj1 = { name: 'Alice' };
const obj2 = { name: 'Alice' };

console.log(Object.is(obj1, obj2)); // false(参照が違う)
console.log(Object.is(obj1, obj1)); // true(同じ参照)

オブジェクトや配列の場合、内容が同じでも参照が異なれば「変更あり」と判定されます。

逆に、メモ化によって同じ参照が返され続けると、実際には内容が変わっているのに「変更なし」と判定されてしまう可能性があるのです。

以下の図は、この問題のメカニズムを示したものです。

mermaidflowchart TB
  render1["初回レンダリング"]
  memo1["useMemo で selector 作成<br/>参照A"]
  sub1["subscribe 実行<br/>selector参照Aを登録"]

  stateChange["ストアの状態更新<br/>user が変更される"]

  render2["再レンダリング"]
  memo2["useMemo の依存配列確認<br/>[]空配列のため再作成しない"]
  returnA["既存の selector参照A を返す"]

  selectorExec["subscribeで参照Aを実行"]
  closure["クロージャが古いstateを参照"]
  oldValue["古い値を返す"]

  compare["equalityFn で比較<br/>Object.is(古い値, 古い値)"]
  noChange["値が同じと判定"]
  skip["コールバックスキップ<br/>更新を検知できない"]

  render1 --> memo1 --> sub1
  sub1 --> stateChange
  stateChange --> render2 --> memo2 --> returnA
  returnA --> selectorExec --> closure --> oldValue
  oldValue --> compare --> noChange --> skip

  style skip fill:#ffcccc
  style closure fill:#ffffcc

図で理解できる要点:

  • 空の依存配列でメモ化された selector は再作成されない
  • 古い selector のクロージャが古い state を参照し続ける
  • 結果として、新しい state が反映されずコールバックが呼ばれない

実際に起こる症状

この問題が発生すると、以下のような症状が現れます。

#症状説明
1コールバックが呼ばれない状態が更新されているのに subscription のコールバックが実行されない
2古いデータが表示されるUI に表示される値が最新の状態と一致しない
3デバッグが困難console.log で確認すると値は更新されているように見える
4再マウント時だけ更新コンポーネントを unmount/mount すると正しい値になる

特に、開発者ツールで state を確認すると正しく更新されているように見えるため、原因の特定が難しくなります。

解決策

解決策 1:メモ化を避ける

最もシンプルな解決策は、selector 関数をメモ化しないことです。毎回新しい関数を作成すれば、クロージャによる古い参照の問題は発生しません。

typescriptfunction MyComponent() {
  useEffect(() => {
    // メモ化せずに直接 selector を定義
    const unsubscribe = useStore.subscribe(
      (state) => state.user, // 毎回新しい関数が作られる
      (user) => {
        console.log('User changed:', user);
      }
    );

    return unsubscribe;
  }, []); // 依存配列は空でOK

  return <div>...</div>;
}

この方法では、useEffect の依存配列が空でも問題ありません。subscribe 内部で渡される selector は、常に最新の state にアクセスできるためです。

ただし、この方法には注意点があります。

typescript// 注意が必要なパターン
useEffect(() => {
  const unsubscribe = useStore.subscribe(
    (state) => state.user,
    (user) => {
      // このコールバックも毎回新しい関数
      handleUserChange(user);
    }
  );

  return unsubscribe;
}, []); // handleUserChange が外部変数だと古い参照になる可能性

コールバック関数内で外部の変数や関数を参照する場合、その変数が更新されても古い参照のままになることがあります。その場合は依存配列に追加するか、次の解決策を検討しましょう。

解決策 2:適切な依存配列の設定

メモ化を使う場合は、依存配列に必要な値をすべて含めることが重要です。

typescriptfunction MyComponent() {
  const userId = useStore((state) => state.userId);

  // userId が変わったら selector を再作成
  const userSelector = useMemo(
    () => (state) => state.users[userId],
    [userId] // userId を依存配列に追加
  );

  useEffect(() => {
    const unsubscribe = useStore.subscribe(
      userSelector,
      (user) => {
        console.log('User changed:', user);
      }
    );

    return unsubscribe;
  }, [userSelector]);

  return <div>...</div>;
}

この方法では、userId が変更されるたびに新しい selector が作成されます。これにより、常に最新の userId を参照できるようになるでしょう。

依存配列を正しく設定することで、ESLint の exhaustive-deps ルールにも準拠できます。

解決策 3:shallow や custom equalityFn の活用

オブジェクトや配列を比較する場合、参照ではなく内容を比較する equalityFn を使うことで、意図しない subscription のトリガーを防げます。

typescriptimport { shallow } from 'zustand/shallow';

// shallow 比較の利用
useEffect(() => {
  const unsubscribe = useStore.subscribe(
    (state) => state.user,
    (user) => {
      console.log('User changed:', user);
    },
    {
      equalityFn: shallow, // 浅い比較を使用
    }
  );

  return unsubscribe;
}, []);

shallow は、オブジェクトの第一階層のプロパティを比較する関数です。以下のように動作します。

typescriptimport { shallow } from 'zustand/shallow';

const obj1 = { name: 'Alice', age: 25 };
const obj2 = { name: 'Alice', age: 25 };
const obj3 = { name: 'Bob', age: 25 };

console.log(shallow(obj1, obj2)); // true(内容が同じ)
console.log(shallow(obj1, obj3)); // false(name が違う)

この方法を使えば、オブジェクトの参照が変わっても、内容が同じなら「変更なし」と判定されます。

カスタムの equalityFn を作ることもできます。

typescript// カスタム比較関数の例
const customEqual = (a, b) => {
  // ID だけを比較する
  return a?.id === b?.id;
};

useEffect(() => {
  const unsubscribe = useStore.subscribe(
    (state) => state.user,
    (user) => {
      console.log('User ID changed:', user.id);
    },
    {
      equalityFn: customEqual, // カスタム比較関数
    }
  );

  return unsubscribe;
}, []);

解決策 4:Zustand の useStore フックを活用

コンポーネント内で状態を購読するだけなら、subscribe を直接使うよりも Zustand の useStore フックを使う方が安全です。

typescriptfunction MyComponent() {
  // Zustand のフックで状態を購読
  const user = useStore((state) => state.user);

  useEffect(() => {
    // user が変更されたときの処理
    console.log('User changed:', user);
  }, [user]); // user を依存配列に追加

  return <div>{user.name}</div>;
}

useStore フックは内部で適切に状態管理を行うため、古い参照の問題が起こりません。selector も毎回新しい関数として扱われ、最新の state にアクセスできます。

さらに、shallow を組み合わせることもできます。

typescriptimport { shallow } from 'zustand/shallow';

function MyComponent() {
  // 複数の値を取得し、shallow 比較
  const { user, theme } = useStore(
    (state) => ({ user: state.user, theme: state.theme }),
    shallow
  );

  return <div className={theme}>{user.name}</div>;
}

以下の図は、各解決策のアプローチを比較したものです。

mermaidflowchart TB
  problem["古い参照問題"]

  solution1["解決策1<br/>メモ化を避ける"]
  solution2["解決策2<br/>適切な依存配列"]
  solution3["解決策3<br/>custom equalityFn"]
  solution4["解決策4<br/>useStore フック"]

  result1["毎回新しい関数<br/>クロージャ問題なし"]
  result2["必要時に再作成<br/>最新値を参照"]
  result3["内容で比較<br/>参照問題を回避"]
  result4["フックが自動管理<br/>安全な購読"]

  problem --> solution1 --> result1
  problem --> solution2 --> result2
  problem --> solution3 --> result3
  problem --> solution4 --> result4

  style result1 fill:#ccffcc
  style result2 fill:#ccffcc
  style result3 fill:#ccffcc
  style result4 fill:#ccffcc

図で理解できる要点:

  • 4 つの解決策はそれぞれ異なるアプローチで問題に対処する
  • すべての解決策が「最新の値を参照できる」という結果につながる
  • 状況に応じて最適な解決策を選択することが重要

具体例

ケース 1:ユーザー情報の監視(問題のあるコード)

実際のアプリケーションで、ユーザー情報の変更を監視するシナリオを見てみましょう。まずは問題のあるコードから解説します。

typescriptimport { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import { useEffect, useMemo } from 'react';

// ストアの定義
interface UserState {
  currentUser: {
    id: string;
    name: string;
    email: string;
  } | null;
  setCurrentUser: (user: UserState['currentUser']) => void;
}

上記のコードでは、ユーザー情報を管理するストアの型定義を行っています。currentUser にはユーザーオブジェクトか null が入ります。

typescriptconst useUserStore = create<UserState>()(
  subscribeWithSelector((set) => ({
    currentUser: null,
    setCurrentUser: (user) => set({ currentUser: user }),
  }))
);

subscribeWithSelector ミドルウェアを適用したストアの作成例です。

typescript// 問題のあるコンポーネント
function UserNotifier() {
  // メモ化された selector(問題の原因)
  const userSelector = useMemo(
    () => (state: UserState) => state.currentUser,
    [] // 空の依存配列 = 再作成されない
  );

  useEffect(() => {
    console.log('Setting up subscription...');

    const unsubscribe = useUserStore.subscribe(
      userSelector, // メモ化された古い selector
      (user) => {
        // ユーザーが変更されたときの通知
        if (user) {
          console.log('User logged in:', user.name);
          // 実際のアプリでは通知 API を呼ぶ
        } else {
          console.log('User logged out');
        }
      }
    );

    return () => {
      console.log('Cleaning up subscription...');
      unsubscribe();
    };
  }, [userSelector]); // userSelector は再作成されないため実質 []

  return null; // UI は持たない
}

このコードの問題点を整理すると以下のようになります。

#問題点影響
1空の依存配列でメモ化selector が初回レンダリング時の参照を保持し続ける
2クロージャの古い参照状態が更新されても古い currentUser を参照する
3コールバックが呼ばれないログイン・ログアウトが検知されない

実際に動かすとどうなるでしょうか。

typescriptfunction App() {
  const setCurrentUser = useUserStore(
    (state) => state.setCurrentUser
  );

  return (
    <div>
      <UserNotifier />
      <button
        onClick={() => {
          // ユーザーをログイン状態にする
          setCurrentUser({
            id: '123',
            name: 'Alice',
            email: 'alice@example.com',
          });
        }}
      >
        Login
      </button>
      <button
        onClick={() => {
          // ユーザーをログアウト状態にする
          setCurrentUser(null);
        }}
      >
        Logout
      </button>
    </div>
  );
}

このコードを実行して Login ボタンをクリックしても、UserNotifier のコールバックは実行されません。なぜなら、メモ化された selector が古い null を返し続けるためです。

ケース 1 の解決:メモ化を削除

最もシンプルな解決策は、useMemo を削除することです。

typescriptfunction UserNotifier() {
  useEffect(() => {
    console.log('Setting up subscription...');

    const unsubscribe = useUserStore.subscribe(
      // メモ化せず直接 selector を定義
      (state) => state.currentUser,
      (user) => {
        if (user) {
          console.log('User logged in:', user.name);
        } else {
          console.log('User logged out');
        }
      }
    );

    return () => {
      console.log('Cleaning up subscription...');
      unsubscribe();
    };
  }, []); // 依存配列は空でOK

  return null;
}

この修正により、selector は常に最新の currentUser を参照できるようになります。Login や Logout ボタンをクリックすると、正しくコールバックが実行されるでしょう。

ケース 2:フィルター条件付きリスト監視(複雑なケース)

次に、より複雑なケースを見てみましょう。フィルター条件によって異なるユーザーリストを監視するシナリオです。

typescript// ストアの定義
interface UserListState {
  users: Array<{
    id: string;
    name: string;
    department: string;
  }>;
  addUser: (user: UserListState['users'][0]) => void;
}

const useUserListStore = create<UserListState>()(
  subscribeWithSelector((set) => ({
    users: [],
    addUser: (user) =>
      set((state) => ({
        users: [...state.users, user],
      })),
  }))
);

このストアには、ユーザーの配列と追加機能が含まれています。

typescript// フィルター機能を持つコンポーネント
function DepartmentNotifier({
  department,
}: {
  department: string;
}) {
  // department でフィルターされたユーザーを返す selector
  const filteredUsersSelector = useMemo(
    () => (state: UserListState) =>
      state.users.filter(
        (user) => user.department === department
      ),
    [department] // department を依存配列に含める(重要)
  );

  useEffect(() => {
    console.log(`Subscribing to ${department} users...`);

    const unsubscribe = useUserListStore.subscribe(
      filteredUsersSelector,
      (users) => {
        console.log(
          `${department} users updated:`,
          users.length
        );
      }
    );

    return () => {
      console.log(
        `Unsubscribing from ${department} users...`
      );
      unsubscribe();
    };
  }, [filteredUsersSelector, department]);

  return null;
}

このコードでは、department が変更されると新しい selector が作成されます。これにより、常に正しい部署のユーザーをフィルタリングできるでしょう。

ただし、このコードには別の問題があります。

typescript// 使用例
function App() {
  const addUser = useUserListStore(
    (state) => state.addUser
  );

  return (
    <div>
      <DepartmentNotifier department='Engineering' />
      <button
        onClick={() => {
          addUser({
            id: '1',
            name: 'Alice',
            department: 'Engineering',
          });
        }}
      >
        Add Engineering User
      </button>
    </div>
  );
}

このコードを実行すると、ボタンをクリックするたびにコールバックが呼ばれます。しかし、配列の参照が毎回変わるため、内容が同じでも「変更あり」と判定されてしまうのです。

ケース 2 の解決:shallow 比較の導入

配列の内容で比較するため、shallow を使います。

typescriptimport { shallow } from 'zustand/shallow';

function DepartmentNotifier({
  department,
}: {
  department: string;
}) {
  const filteredUsersSelector = useMemo(
    () => (state: UserListState) =>
      state.users.filter(
        (user) => user.department === department
      ),
    [department]
  );

  useEffect(() => {
    console.log(`Subscribing to ${department} users...`);

    const unsubscribe = useUserListStore.subscribe(
      filteredUsersSelector,
      (users) => {
        console.log(
          `${department} users updated:`,
          users.length
        );
      },
      {
        equalityFn: shallow, // shallow 比較を追加
      }
    );

    return () => {
      console.log(
        `Unsubscribing from ${department} users...`
      );
      unsubscribe();
    };
  }, [filteredUsersSelector, department]);

  return null;
}

shallow を使うことで、配列の要素が実際に変わったときだけコールバックが呼ばれるようになります。

ただし、shallow は浅い比較なので、配列内のオブジェクトの参照が変わると「変更あり」と判定されます。より厳密な比較が必要な場合は、カスタム equalityFn を作りましょう。

typescript// カスタム比較関数:ユーザー ID の配列で比較
const userIdsEqual = (
  prevUsers: UserListState['users'],
  nextUsers: UserListState['users']
) => {
  if (prevUsers.length !== nextUsers.length) {
    return false;
  }

  const prevIds = prevUsers.map((u) => u.id).sort();
  const nextIds = nextUsers.map((u) => u.id).sort();

  return prevIds.every(
    (id, index) => id === nextIds[index]
  );
};

このカスタム関数は、ユーザーの ID のみを比較します。名前や部署が変わっても、ID が同じなら「変更なし」と判定されるでしょう。

typescriptuseEffect(() => {
  const unsubscribe = useUserListStore.subscribe(
    filteredUsersSelector,
    (users) => {
      console.log(
        `${department} users updated:`,
        users.length
      );
    },
    {
      equalityFn: userIdsEqual, // カスタム比較関数を使用
    }
  );

  return unsubscribe;
}, [filteredUsersSelector, department]);

ケース 3:useStore フックを使った安全な実装

最後に、subscribe を直接使わず、Zustand の useStore フックを活用する方法をご紹介します。

typescriptfunction DepartmentUsers({
  department,
}: {
  department: string;
}) {
  // useStore フックで状態を購読
  const filteredUsers = useUserListStore(
    (state) =>
      state.users.filter(
        (user) => user.department === department
      ),
    shallow // shallow 比較を指定
  );

  // filteredUsers が変更されたときの処理
  useEffect(() => {
    console.log(
      `${department} users updated:`,
      filteredUsers.length
    );
    // 実際のアプリでは通知を送るなどの処理
  }, [filteredUsers, department]);

  return (
    <div>
      <h3>{department} Department</h3>
      <ul>
        {filteredUsers.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

この実装では、useStore が自動的に状態の購読と更新を管理してくれます。メモ化や subscribe の管理を手動で行う必要がなく、より安全で読みやすいコードになりますね。

以下の図は、問題のあるコードと解決後のコードの動作フローを比較したものです。

mermaidsequenceDiagram
  participant C as Component
  participant M as useMemo
  participant S as Selector
  participant Store as Zustand Store

  Note over C,Store: 問題のあるコード

  C->>M: useMemo(() => selector, [])
  M->>S: 初回:selector 作成
  S-->>M: selector 参照A
  M-->>C: selector 参照A

  C->>Store: subscribe(selector参照A)
  Store-->>C: unsubscribe 関数

  Note over Store: 状態が更新される
  Store->>S: selector参照A を実行
  S-->>Store: 古い値を返す
  Store->>Store: Object.is(古い値, 古い値)
  Note over Store: 変更なしと判定<br/>コールバックスキップ

  Note over C,Store: 解決後のコード(メモ化削除)

  C->>Store: subscribe((state) => state.user)
  Store-->>C: unsubscribe 関数

  Note over Store: 状態が更新される
  Store->>Store: selector を実行
  Store-->>Store: 最新の値を返す
  Store->>Store: Object.is(古い値, 新しい値)
  Store->>C: コールバック実行
  Note over C: 正しく通知を受け取る

図で理解できる要点:

  • 問題のあるコードでは、メモ化された selector が古い値を返し続ける
  • 解決後のコードでは、selector が常に最新の値にアクセスできる
  • 結果として、状態変更を正しく検知できるようになる

まとめ

本記事では、Zustand の subscribeWithSelector で発生する古い参照問題について、その仕組みと解決策を詳しく解説いたしました。

この問題の根本原因は、メモ化された selector 関数が作成時のクロージャを保持し続けることにあります。JavaScript のクロージャは便利な機能ですが、状態管理と組み合わせると予期しない挙動を引き起こすことがあるのです。

解決策としては、以下の 4 つの方法をご紹介しました。

#解決策適用場面メリット
1メモ化を避けるシンプルな selector最もシンプルで確実、コードが短くなる
2適切な依存配列外部変数を参照する selectorESLint ルールに準拠、必要時だけ再作成
3custom equalityFnオブジェクト・配列の比較不要な再実行を防げる、柔軟な比較ロジック
4useStore フックコンポーネント内の購読React の流儀に沿っている、自動管理で安全

どの解決策を選ぶかは、状況によって異なります。単純なケースではメモ化を避けるのが最も簡単ですし、複雑な条件でフィルタリングする場合は適切な依存配列と equalityFn の組み合わせが効果的でしょう。

コンポーネント内で状態を使うだけなら、useStore フックを使うのが最も安全で React らしい方法です。

Zustand は軽量でシンプルな状態管理ライブラリですが、JavaScript の基本的な仕組み(クロージャ、参照、メモ化)を理解していないと、思わぬ問題に遭遇することがあります。本記事が、そうした問題の理解と解決の一助になれば幸いです。

状態管理は現代の Web アプリケーション開発において欠かせない要素ですね。正しい知識を持って、より堅牢で保守性の高いコードを書いていきましょう。

関連リンク