In NgRx, what do createEntityAdapter's selectId and sortComparer options control for kanban cards, and what does a sortComparer cost?
answer
- which field is the key
- default reads entity.id
- false means insertion order
- comparator runs on every write
- cheaper to sort for display
basics
~10 sselectId tells the adapter which value is a card's key and defaults to entity.id; sortComparer, when given, keeps ids sorted on every write, while the default false keeps insertion order and does less work.
solid answer
~50 s`selectId` maps an entity to its key. Leave it out and the adapter reads `entity.id`; pass it when the key lives elsewhere, such as `(card) => card.cardKey`. The key must be unique and stable, and in dev mode the adapter logs a warning if `selectId` returns `undefined`. `sortComparer` defaults to `false`, so `ids` keeps insertion order and an update never moves a card. Pass a comparer, say by `position`, and every add, set, update and upsert merges the touched cards back into sorted order, so `selectAll` comes out ordered and a card whose `position` changed moves. The cost is the comparator merge on every write, which is why the docs call `false` the more performant choice; if order only matters for display, sorting in a selector is the alternative. In NgRx 22 the key type is inferred as `string` or `number` from `selectId`.
code
ts · 21 linesimport { EntityState, createEntityAdapter } from '@ngrx/entity';
export interface Card {
cardKey: string;
columnId: string;
position: number;
title: string;
}
export interface BoardState extends EntityState<Card> {
selectedCardKey: string | null;
}
export const cardAdapter = createEntityAdapter<Card>({
selectId: (card) => card.cardKey,
sortComparer: (a, b) => a.position - b.position,
});
export const initialState: BoardState = cardAdapter.getInitialState({
selectedCardKey: null,
});go deeper
Remember the default key is entity.id and the default sortComparer is false, which means insertion order.
Explain that sorting happens at write time: every add, set, update and upsert merges cards back into order, so a changed sort field moves the card.
Decide between a stored order and a view order, cost the comparator on write-heavy collections, and keep keys stable so re-keying never orphans references.
Set a team convention for where ordering lives, in the adapter or in selectors, so features do not mix both and disagree about what selectAll means.
## What selectId decides Every `@ngrx/entity` operation needs to know which value identifies a record. That is the job of **`selectId`**, a function from entity to key: - **Default.** Omit it and the adapter uses `(entity) => entity.id`. A `Card` with an `id` field needs no option at all. - **Custom key.** When the key has another name, pass it: `selectId: (card) => card.cardKey`. - **Stable and unique.** The key is where the card sits in `entities` and what `ids` stores. Two cards with the same key cannot coexist: depending on the method, the second is ignored or replaces the first. A key that changes on edit re-keys the card. - **Undefined keys.** If `selectId` returns `undefined`, the adapter does not throw. In dev mode it logs a console warning suggesting you provide your own `selectId`, and the record is stored under the key `undefined`. - **Typing in NgRx 22.** The adapter now infers the key type from the configuration: `adapter.selectId(card)` is typed `string` or `number` depending on your function, where earlier majors typed it `string | number`. ## What sortComparer decides **`sortComparer`** is either `false` (the default) or a comparison function with the same contract as `Array.prototype.sort`'s comparator. It decides the order of `ids`, and therefore the order `selectAll` returns: - **`false`** — `ids` keeps **insertion order**. `addOne` appends, and `updateOne` leaves a card in its slot even if a field you would sort by changed. - **A comparer** — the adapter keeps `ids` **sorted at write time**. Adds, sets, updates and upserts all merge the affected cards back into place. ## How the sorted adapter keeps order When a comparer is set, each write that touches cards follows the same steps: 1. Copy `ids` and `entities`, as the adapter's write methods do. 2. Take the cards being written out of the order: for an update, or a set of a card that already exists, its old slot is removed first. 3. Sort the incoming cards with the comparer. 4. Merge them with the existing ordered `ids`, one comparison at a time; on a tie the incoming card is placed before the existing one. 5. Return new state, with a new `ids` array when any position or key changed. So a drag that sets `position: 3` on a card moves it to the right slot on the next `updateOne`, with no reordering code in the reducer. ## Cost and the alternative | | `sortComparer: false` | a comparer | |---|---|---| | Order of `ids` | insertion order | always sorted | | Work per write | copy `ids` and `entities` | copy plus sort-and-merge | | An update to the sort field | card stays put | card moves | | `selectAll` order | as inserted | sorted | The official docs say to set it to `false` when the collection does not need sorting because it is more performant during CRUD operations. Both variants copy on write; the comparer adds the merge. The practical rule for a board: - If the stored order **is** the product (cards within a column ordered by `position`), a comparer keeps every reader consistent with no extra code. - If order is a **view concern** (a "sort by due date" toggle), keep `false` and sort inside a selector, which runs only when its inputs change and can differ per view. ## Choosing a comparer A comparer runs on every write, so it should be cheap and consistent: - Return a negative number, zero or a positive number, exactly as for `Array.prototype.sort`. `a.position - b.position` does that for numbers; `a.title.localeCompare(b.title)` does it for text. - Keep it **pure and deterministic**: the same two cards must always compare the same way, or the merge produces an order no one can predict. - Add a **tie-breaker** when the main field can repeat, such as comparing `cardKey` when two cards share a `position`, so ties do not depend on write order. - Sort by fields the reducer actually writes. A comparer over a field only the server changes will not move a card until that field arrives in a write. ## Putting it together ```ts export const cardAdapter = createEntityAdapter<Card>({ selectId: (card) => card.cardKey, sortComparer: (a, b) => a.position - b.position, }); ``` This adapter keys cards by `cardKey`, keeps `ids` ordered by `position`, and returns cards in that order from `selectAll`. Filtering that list by `columnId` in a selector then yields each column already sorted.
- What happens if updateOne changes a card's cardKey when selectId reads cardKey?The adapter re-keys the card: it deletes the old key from `entities`, stores the merged card under the new key and replaces the old key in `ids` (the sorted adapter re-merges it into place). Anything still holding the old key, such as `selectedCardKey`, now points at nothing, so treat keys as immutable where you can.
- Why might you sort in a selector instead of with sortComparer?A comparer fixes one order for the whole collection and pays for it on every write. A selector sorts only when its inputs change and each view can choose its own order, such as by due date or by title. Keep the comparer for the order the product itself stores, like position within a column.
saying these in an interview costs you the question
- selectId is required on every createEntityAdapter call.
- Without a sortComparer, ids is sorted by key.
- With a sortComparer, updating the sorted field leaves the card in its old slot.
- sortComparer is free because sorting only happens when selectAll runs.
- A selectId that returns undefined makes the adapter throw.