👋

@ngrx/entityのidsを倍にしてしまった日と、その原因

に公開

はじめに

最近、業務で @ngrx/entity を使ったリスト系の状態管理を書いているときに、ちょっと不思議なバグにハマりました。

なぜか ids.lengthentities の件数の 2 倍になっている 🤔

@ngrx/entitycreateEntityAdapter を使っていたので、「ids は adapter がいい感じに管理してくれるだろう」とあまり深く考えていませんでした。
そこで、「そもそも @ngrx/entityids って、いつ・どうやって決まるんだっけ?」というのをちゃんと整理してみたくなり、この記事を書いています。

なお、実際のコードは業務のものなので、本文中のコードは構造だけを残した簡略化したサンプルです。

EntityState は最初から idsentities を持っている

まずおさらいとして、EntityState<T> はざっくりこんな形をしています。

簡略版
interface EntityState<T> {
  ids: Array<string | number>;
  entities: { [id: string]: T };
}
  • ids: 並び順を表す ID の配列
  • entities: ID をキーにした実データのマップ

createEntityAdapter() は、この 2 つをまとめて扱うためのヘルパーで、だいたいこんな感じで使います。

interface Todo {
  id: number;
  title: string;
}

const adapter = createEntityAdapter<Todo>();

const initialState = adapter.getInitialState({
  status: "idle" as "idle" | "loading" | "succeeded" | "failed",
});

getInitialState() の時点で ids: []entities: {} が内部に用意されています。
その後は、addManyupsertMany を呼ぶたびに、adapter が idsentities の両方を更新してくれます。

バグのきっかけ:ids を手動でいじってしまった

今回ハマった原因は、ある要件によって ids の並び順を変更していたことでした。

こんな書き方をしていました。

next: ({ items }) => {
  this.setState(
    adapter.upsertMany(items, {
      ...this.get(),
      ids: items.map((item) => item.id), // ← ここで手動で ids を上書き(実際は並び替えのロジックが入る)
    })
  );
};

問題なさそうに見えますが、結果として

  • ids.lengthentities の件数の 2 倍になる
  • entities は正しいので、一見気付きにくい

という気持ち悪い状態に。

理由はシンプルで、

upsertMany は、渡された state の ids をベースに「新規 ID だけ push する」

からです。

上のコードでは、

  1. ids にすでに items.map(item.id) を入れた state を upsertMany に渡している
  2. upsertMany が「この ID は新規だ」と判断して、さらに ids.push(id) する
  3. 結果として、同じ ID が 2 回ずつ入ってしまう

という流れになっていました。

📦 期待した配列と、実際にバグっていた配列のイメージ

例えば、API からこんな配列が返ってきたとします。

const items = [
{ id: 1, title: 'foo' },
{ id: 2, title: 'bar' },
{ id: 3, title: 'baz' },
];

本来イメージしていた ids はこんな形でした。


// ✅ 期待していた ids
ids = [1, 2, 3];

しかし実際には、upsertMany の中で再度 ids.push されてしまい、最終的にこうなっていました。

// ❌ 実際にバグっていた ids のイメージ
ids = [1, 2, 3, 1, 2, 3];

// entities 側はこうなので一見正しく見える
entities = {
1: { id: 1, title: 'foo' },
2: { id: 2, title: 'bar' },
3: { id: 3, title: 'baz' },
};

entities は ID ごとに上書きされるだけなので問題なし。
一方で ids だけが「2 周分」入ってしまうため、
ids.length === 2 \* Object.keys(entities).length という状態になっていました。

正しいパターン:まず adapter に任せて、そのあとで ids を上書きする

じゃあどうするのが良いのか?
最終的に落ち着いたパターンは、 「upsert → そのあとで ids だけ上書き」 です。

これも実コードを抽象化したサンプルですが、やっていることは同じです。

next: ({ items }) => {
  // ① まず adapter に任せて、重複のない ids / entities を作る
  const newState = adapter.upsertMany(items, {
    ...this.get(),
    status: "succeeded",
  });

  // ② 表示順だけ上書きする
  this.setState({
    ...newState,
    ids: items.map((item) => item.id), // 実際は並び替えのロジックが入る
  });
};

ポイントはこの 2 つです。

  1. 重複判定・追加ロジックはすべて adapter に任せる
    upsertMany に渡す state には ids を含めない
    こうすると、ids の二重追加が起こらない

  2. UI の並び順だけ、後から上書きする

これで、ids.length !== entities.length という謎状態が解消される、ことができました。

学び:ids は「自動更新」が基本で、触るなら upsert の“後”に

今回のバグから得た教訓をまとめると、こんな感じです。

  • EntityState は最初から idsentities を持っている
  • createEntityAdapter が提供するメソッドが、ids の増減・重複チェックも全部やってくれる
  • 普段は ids を触る必要はなく、どうしても表示順を制御したいときだけ例外的に上書きする
  • その場合も、
    • upsertMany に渡す state には ids を含めない
    • upsert の後で ids を上書きする

もし、@ngrx/entity を使っていて「なんか ids の中身がおかしい…」「ids.length が変な数字になっている…」と感じることがあれば、

その state を upsertManyaddMany に渡す前に、ids を手でいじっていないか?

を一度疑ってみると良いでしょう。

5 日目の記事は@nishitakuさんです!

Discussion