@ngrx/entityのidsを倍にしてしまった日と、その原因
はじめに
最近、業務で @ngrx/entity を使ったリスト系の状態管理を書いているときに、ちょっと不思議なバグにハマりました。
なぜか
ids.lengthがentitiesの件数の 2 倍になっている 🤔
@ngrx/entity の createEntityAdapter を使っていたので、「ids は adapter がいい感じに管理してくれるだろう」とあまり深く考えていませんでした。
そこで、「そもそも @ngrx/entity の ids って、いつ・どうやって決まるんだっけ?」というのをちゃんと整理してみたくなり、この記事を書いています。
なお、実際のコードは業務のものなので、本文中のコードは構造だけを残した簡略化したサンプルです。
EntityState は最初から ids と entities を持っている
まずおさらいとして、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: {} が内部に用意されています。
その後は、addMany や upsertMany を呼ぶたびに、adapter が ids と entities の両方を更新してくれます。
バグのきっかけ:ids を手動でいじってしまった
今回ハマった原因は、ある要件によって ids の並び順を変更していたことでした。
こんな書き方をしていました。
next: ({ items }) => {
this.setState(
adapter.upsertMany(items, {
...this.get(),
ids: items.map((item) => item.id), // ← ここで手動で ids を上書き(実際は並び替えのロジックが入る)
})
);
};
問題なさそうに見えますが、結果として
-
ids.lengthがentitiesの件数の 2 倍になる -
entitiesは正しいので、一見気付きにくい
という気持ち悪い状態に。
理由はシンプルで、
upsertManyは、渡された state のidsをベースに「新規 ID だけ push する」
からです。
上のコードでは、
-
idsにすでにitems.map(item.id)を入れた state をupsertManyに渡している -
upsertManyが「この ID は新規だ」と判断して、さらにids.push(id)する - 結果として、同じ 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 つです。
-
重複判定・追加ロジックはすべて adapter に任せる
upsertManyに渡す state にはidsを含めない
こうすると、ids の二重追加が起こらない -
UI の並び順だけ、後から上書きする
これで、ids.length !== entities.length という謎状態が解消される、ことができました。
学び:ids は「自動更新」が基本で、触るなら upsert の“後”に
今回のバグから得た教訓をまとめると、こんな感じです。
-
EntityStateは最初からidsとentitiesを持っている -
createEntityAdapterが提供するメソッドが、idsの増減・重複チェックも全部やってくれる - 普段は
idsを触る必要はなく、どうしても表示順を制御したいときだけ例外的に上書きする - その場合も、
-
upsertManyに渡す state にはidsを含めない - upsert の後で
idsを上書きする
-
もし、@ngrx/entity を使っていて「なんか ids の中身がおかしい…」「ids.length が変な数字になっている…」と感じることがあれば、
その state を
upsertManyやaddManyに渡す前に、idsを手でいじっていないか?
を一度疑ってみると良いでしょう。
5 日目の記事は@nishitakuさんです!
Discussion