@ngrx/signals/entitiesで識別子と並び順をどう決めるか

withEntities を積むと、コレクションを配列で持つ必要が無くなる。

一覧を entities で持つ
export const BookStore = signalStore(
  { providedIn: 'root' },
  withEntities<Book>(),
  withMethods((store) => ({
    load(books: Book[]): void {
      patchState(store, setAllEntities(books));
    },
  })),
);

配列の便利な包み紙として読むと、この feature の挙動は追えない。@ngrx/signals 22.0.1 が state に足すのは2つの別の値で、識別子の決め方もそこに記録されない。

stateに足される2つの値

実装は短い。

22.0.1 の withEntities
function withEntities(config) {
  const { entityMapKey, idsKey, entitiesKey } = getEntityStateKeys(config);
  return signalStoreFeature(withState({
    [entityMapKey]: {},
    [idsKey]: [],
  }), withComputed((store) => ({
    [entitiesKey]: computed(() => {
      const entityMap = store[entityMapKey]();
      const ids = store[idsKey]();
      return ids.map((id) => entityMap[id]);
    }),
  })));
}

state は entityMap と ids の2つで、entities は ids.map(...) の computed である。配列は派生なので、patchState で直接書くことはできない。並び順を持っているのは ids のほうである。

collection を渡すとキーに接頭辞が付く。

22.0.1 の getEntityStateKeys
function getEntityStateKeys(config) {
  const collection = config?.collection;
  const entityMapKey = collection === undefined ? 'entityMap' : `${collection}EntityMap`;
  const idsKey = collection === undefined ? 'ids' : `${collection}Ids`;
  const entitiesKey = collection === undefined ? 'entities' : `${collection}Entities`;
  return { entityMapKey, idsKey, entitiesKey };
}

したがって withEntities を collection 無しで2回積むと、2回目の withState が同じ3つのキーを対象にする。衝突は assertUniqueStoreMembers が検査するが、呼び出し側が ngDevMode で囲われているため、本番ビルドでは走らない。開発時には SignalStore members cannot be overridden. が出力に残るだけで、例外にはならず、後から積んだ側の定義が使われる。この性質はNgRx SignalStoreのsignalStoreFeatureとwithFeatureの使い分けでも同じ形で出てくる。警告の見落としを前提に、2つ目以降のコレクションには必ず collection を付ける。

selectIdが記録される位置

id 以外をキーにするコレクションでは selectId を渡す。entityConfig でまとめる書き方が案内されている。

設定をまとめた形
const bookConfig = entityConfig({
  entity: type<Book>(),
  collection: 'book',
  selectId: (book) => book.isbn,
});

export const BookStore = signalStore(
  withEntities(bookConfig),
  // ...
);

entityConfig は何もしない。

22.0.1 の entityConfig
function entityConfig(config) {
  return config;
}

渡した値をそのまま返す恒等関数で、型の上で collection と selectId を固定するためだけに置かれている。そして上の withEntities は getEntityStateKeys(config) しか呼ばず、config.selectId を読む箇所が無い。.d.ts 側の宣言も withEntities のオーバーロードは { entity, collection } と { entity } の2つだけで、selectId を受ける形が無い。余分なプロパティを持つ変数は構造的に代入できるので型検査は通るが、記録はされない。

記録されないことの帰結は、updater 側に出る。識別子を決めるのは各 updater である。

22.0.1 の getEntityIdSelector と既定値
const defaultSelectId = (entity) => entity.id;
function getEntityIdSelector(config) {
  return config?.selectId ?? defaultSelectId;
}

addEntity も setAllEntities も updateEntity も、自分が受けた config から selectId を取り、無ければ entity.id を読む。つまり withEntities(bookConfig) を積んでも、updater に bookConfig を渡し忘れた呼び出しは entity.id を使う。

設定を渡し忘れた呼び出し
patchState(store, setAllEntities(books, bookConfig)); // isbn がキーになる
patchState(store, addEntity(book, { collection: 'book' })); // id がキーになる

2行目は型検査を通る。collection が合っているので NamedEntityState<Book, 'book'> を返す overload が選ばれ、selectId の有無は型に現れない。Book に id が無ければ entity.id は undefined で、entityMap[undefined] というキーに入り、ids には undefined が積まれる。以後 addEntity で足した書籍はすべてこの1つのキーを共有するため、2件目以降は既存あり扱いで捨てられる(後述)。Book に使っていない id が残っていれば、isbn で入れた分と id で入れた分が別の要素として並ぶ。

どちらの場合も例外は出ない。レビューで見るのは、selectId を持つコレクションに対する updater の呼び出しが、例外なく同じ設定オブジェクトを受けているかである。設定を定数に出し、呼び出し側が毎回それを渡す形に揃えると差分で確かめやすい。

Comment
@Reviewer: `bookConfig` に `selectId` を書いていますが、`withEntities` は `collection` しか読まないため、updater に渡さないとキーが `entity.id` に戻ります。この `addEntity` は第2引数が `{ collection: 'book' }` のみなので、`isbn` ではなく `id` でキーが決まります。`bookConfig` をそのまま渡してください。

並び順を変える操作と変えない操作

entities の順序は ids の順序である。どの updater が ids をどう触るかで並びが決まる。

22.0.1 の addEntityMutably
function addEntityMutably(state, entity, selectId, prepend = false) {
  const id = selectId(entity);
  if (state.entityMap[id]) {
    return DidMutate.None;
  }
  state.entityMap[id] = entity;
  if (prepend) {
    state.ids.unshift(id);
  }
  else {
    state.ids.push(id);
  }
  return DidMutate.Both;
}

addEntity は末尾に足し、prependEntity は先頭に足す(prepend = true で呼ぶ違いだけである)。新しい投稿を上に出す一覧で addEntity を使うと、追加した行は最下部に出る。

setAllEntities は ids を作り直す。

22.0.1 の setAllEntities
function setAllEntities(entities, config) {
  const selectId = getEntityIdSelector(config);
  const stateKeys = getEntityStateKeys(config);
  return () => {
    const state = { entityMap: {}, ids: [] };
    setEntitiesMutably(state, entities, selectId);
    return {
      [stateKeys.entityMapKey]: state.entityMap,
      [stateKeys.idsKey]: state.ids,
    };
  };
}

元の state を見ないので、渡した配列の順序がそのまま ids になる。並び替えをサーバー側で決めているなら、応答の順序を保つのはこの updater である。逆に、setEntities は既存の ids を保ったまま entityMap を更新するため、並び替えの指示は反映されない。全置換と部分更新の違いが、そのまま並び順の違いになる。

この feature に sortComparer は無い。@ngrx/entity の createEntityAdapter が持っていた自動の並び替えは @ngrx/signals/entities に入っていないため、表示の並びを state から独立させたいなら withComputed で entities() を並べ替えた派生を足す。ids を並び順の真実として使い続けるのか、派生で決めるのかを1つに決めておかないと、追加した行がどこに出るかが呼び出し箇所ごとに変わる。

Comment
@Reviewer: この一覧は更新日の降順で出す仕様ですが、`entities()` をそのまま描画しているため並びは `ids` の順、つまり追加した順になります。`@ngrx/signals/entities` に `sortComparer` は無いので、`withComputed` で並べ替えた `sortedBooks` を足してテンプレート側をそれに変えてください。

既存のidと衝突したときに何も起きない操作

追加系の updater は、同じキーがすでにあると何もしない。上の addEntityMutably の先頭が DidMutate.None を返し、getEntityUpdaterResult が空のオブジェクトを返すため、patchState は state を変えない。

22.0.1 の getEntityUpdaterResult(既定の分岐)
default: {
  return {};
}

「追加したのに増えない」という症状は、ここに由来する。既存の有無が不定な登録には upsertEntity を使う。setEntity との差は置き換えか合成かである。

22.0.1 の setEntityMutably
function setEntityMutably(state, entity, selectId, replace = true) {
  const id = selectId(entity);
  if (state.entityMap[id]) {
    state.entityMap[id] = replace
      ? entity
      : { ...state.entityMap[id], ...entity };
    return DidMutate.Entities;
  }
  state.entityMap[id] = entity;
  state.ids.push(id);
  return DidMutate.Both;
}

setEntity は replace = true で呼ばれるので、渡した要素で丸ごと置き換える。upsertEntity は false で呼ばれるので、既存の要素に展開で重ねる。部分的な応答を受けて更新するなら upsertEntity が合い、完全な要素を受けているなら setEntity が合う。部分的な応答を setEntity に渡すと、応答に含まれないプロパティが消える。型は Entity を要求するので、サーバーの応答型が Entity と宣言されている限りコンパイルでは止まらない。

idを書き換える更新で崩れる対応

updateEntity の changes が識別子に使うプロパティを含むと、キーの付け替えが起きる。

22.0.1 の updateEntitiesMutably(抜粋)
const updatedEntity = { ...entity, ...changesRecord };
didMutate = DidMutate.Entities;
const newId = selectId(updatedEntity);
if (newId !== id) {
  delete state.entityMap[id];
  state.entityMap[newId] = updatedEntity;
  newIds = newIds || {};
  newIds[id] = newId;
}
else {
  state.entityMap[id] = updatedEntity;
}

付け替えのあと ids は state.ids.map((id) => newIds[id] ?? id) で置き換わるので、要素の位置は保たれる。ここまでは期待どおりである。

崩れるのは、新しいキーが別の要素のキーと一致した場合である。entityMap からは1つ消えて1つ入るので件数が1つ減り、ids は同じキーを2つ持つ。entities は ids.map なので、同じ要素が2回並ぶ。

検査はある。

22.0.1 の updateEntitiesMutably(末尾)
if (typeof ngDevMode !== 'undefined' &&
    ngDevMode &&
    state.ids.length !== Object.keys(state.entityMap).length) {
    console.warn('@ngrx/signals/entities: Entities with IDs:', ids, 'are not updated correctly.', 'Make sure to apply valid changes when using `updateEntity`,', '`updateEntities`, and `updateAllEntities` updaters.');
}

ngDevMode の中なので、本番ビルドでは何も出ない。重複した行が描画されるだけで、原因を示すものは残らない。selectId に使うプロパティを changes に含める更新があるなら、そのプロパティを変える操作は removeEntity と addEntity の組に分けるか、識別子を変えない設計に直す。自然キー(isbn や email)を selectId に選んだコレクションでは、この更新が業務上の修正として現れる。

レビュー観点チェックリスト

識別子と並び順の前提を確かめる項目
  • 2つ目以降の withEntities に collection が付いているか(無いとキーが衝突し、本番では無言で後勝ちになる)
  • selectId を持つコレクションの updater 呼び出しが、例外なく同じ設定オブジェクトを受けているか
  • entity.id 以外をキーにしているのに、id プロパティが型に残っていないか
  • 追加の位置が意味を持つ一覧で、addEntity と prependEntity を取り違えていないか
  • 「追加したのに増えない」症状のある箇所が、既存キーで addEntity を呼んでいないか
  • 既存の有無が不定な登録に upsertEntity を使っているか
  • 部分的な応答を setEntity に渡していないか(応答に無いプロパティが消える)
  • 並び順の真実を ids と派生の computed のどちらに置くか決まっているか
  • サーバーの並びを反映したい更新が、setEntities ではなく setAllEntities になっているか
  • selectId に使うプロパティを changes で書き換える更新が無いか(本番では警告も出ない)

おわりに

withEntities が state に足すのは entityMap と ids で、entities はその派生である。並び順は ids が持ち、sortComparer に相当する仕組みは無い。したがって表示の並びは、どの updater を選んだかと、派生を足したかで決まる。

識別子の決め方は feature に残らない。entityConfig は恒等関数で、withEntities は collection しか読まない。selectId は updater ごとの引数として届くため、渡し忘れた呼び出しだけが entity.id に戻る。型検査は collection の一致しか見ないので、この取り違えはコンパイルで止まらない。

残りの失敗も、ほとんどが例外を伴わない。既存キーへの addEntity は何もせず、部分的な応答への setEntity はプロパティを消し、識別子を変える updateEntity は開発モードの警告だけを出して重複した行を作る。レビューで確かめるのは、更新の種類と識別子の決め方が呼び出し箇所すべてで揃っているかである。

コレクションの観点の全体はNgRx SignalStoreの状態設計をレビューする100観点の「withEntities とコレクション」の群に並べてある。