skip to content

In NgRx's @ngrx/entity, why does a kanban board keep its cards as an ids array plus an entities map instead of one array?

level: juniorimportance: must knowfreq 44%

answer

  1. two structures, two jobs
  2. order lives apart from content
  3. lookup by key, not by scan
  4. one stored copy per card
  5. which reference survives an edit

basics

~20 s

EntityState splits a collection into ids, which hold the order, and entities, a map keyed by id, so a card is found by key instead of a scan, is stored once, and can change without rebuilding the order.

solid answer

~30 s

`createEntityAdapter<Card>()` manages a state shaped `{ ids: [], entities: {} }`, which `getInitialState()` returns. `entities` answers "give me card c42" with a key lookup, so reducers and selectors need not scan an array with `find`, and each card is stored exactly once. `ids` carries the order separately, so an edit that keeps a card's id and position replaces only the `entities` map while `ids` keeps its reference. The adapter's methods (`addOne`, `updateOne`, `removeOne` and the rest) do the immutable copying for you. A plain array is still fine for a short, read-only list you never look up by id.

code

ts · 18 lines
ts
import { EntityState, createEntityAdapter } from '@ngrx/entity';

export interface Card {
  id: string;
  columnId: string;
  title: string;
}

export interface BoardState extends EntityState<Card> {
  selectedCardId: string | null;
}

export const cardAdapter = createEntityAdapter<Card>();

// { ids: [], entities: {}, selectedCardId: null }
export const initialState: BoardState = cardAdapter.getInitialState({
  selectedCardId: null,
});

go deeper

for a junior

Recall the two fields, ids and entities, and what each holds. Say that lookup is by key and that the order is read from ids, never from the map.

for a middle

Explain which references change on an edit: a new entities map, the same ids array when the key and position hold, and the same state object when nothing changed.

for a senior

Show judgement about cost: adapter writes shallow-copy the collection, so the gain is in reads and consistency. Name when a plain array is the better call.

for a principal

Frame the adapter as one collection's answer to normalisation, and weigh the indirection it adds against how often the app looks records up by key across features.

## The shape the adapter manages `@ngrx/entity` is NgRx's helper for keeping a **collection** of records of one type in store state. You create one adapter per entity type with `createEntityAdapter<Card>()`, and the state it manages always has the same two fields, described by the `EntityState<T>` interface: - **`ids`** — an array of keys (`string[]` or `number[]`) that fixes the order of the collection. - **`entities`** — an object used as a dictionary, mapping each key to its record. Its type is `Dictionary<Card>`, so `entities[key]` is typed `Card | undefined`: a key may point at nothing. `adapter.getInitialState()` returns `{ ids: [], entities: {} }`. Extra fields such as the selected card go in the same call, typed against your state interface: ```ts export interface BoardState extends EntityState<Card> { selectedCardKey: string | null; } export const initialState: BoardState = cardAdapter.getInitialState({ selectedCardKey: null, }); ``` ## What the split buys on a kanban board A board with a few hundred cards gets read and written constantly: a card is opened, dragged, renamed, archived. With one `Card[]` array almost every one of those starts with a scan for the right card. With the two-part shape: - **Lookup by key.** Opening card `c42` is `entities['c42']`, not `cards.find(...)`. Selectors that join a card with its comments or assignee do key lookups instead of nested scans. - **One copy per card.** The card exists once, in `entities`. Anything that needs to refer to it holds the key, so there is no second copy to forget to update. - **Order kept apart from content.** The display order lives in `ids`. When an edit keeps the card's key and position, the adapter replaces the `entities` map and hands back the **same `ids` array reference**, so anything that reads only the order sees no change. - **Immutable updates written once.** `addOne`, `setOne`, `updateOne`, `upsertOne`, `removeOne` and their `Many` variants return new state objects when something changed; you do not hand-write spreads in every `on()` handler. - **Same state when nothing changed.** An adapter call that changes nothing, such as removing a key that is not there, returns the state object you passed in. ## What it does not buy The shape is not free, and claiming it is makes a weak answer: - Each adapter write method (apart from `removeAll`, which simply empties both) **shallow-copies** `ids` and `entities` before applying the change, so a write costs time proportional to the collection size. The win is in lookups and in fewer scans, not in constant-time writes. - The object key order of `entities` is **not** the display order. Always read order from `ids`, or from `selectAll`, which maps `ids` to records. - It adds indirection. A list of twenty labels that is loaded once and never looked up by key gains nothing from it. - Reading the whole collection means mapping `ids` to records. The adapter's `selectAll` selector does that for you, but it is one more step than reading an array field. - Keys must be unique and stable. A collection whose records have no natural key is a poor fit until you give them one. ## One edit, step by step 1. The user renames card `c42`, and the reducer calls `cardAdapter.updateOne({ id: 'c42', changes: { title: 'Ship it' } }, state)`. 2. The adapter copies `ids` and `entities`, finds `c42` by key and stores a new card object built from the old card plus `changes`. 3. The key and position did not change, so it returns a new state object with the new `entities` map and the **original** `ids` array. 4. Selectors that read `ids` alone return the same value; selectors that read `entities` recompute. ## Plain array or entity state | Concern | `Card[]` in state | `EntityState<Card>` | |---|---|---| | Find one card by key | scan with `find` | `entities[key]` | | Where order lives | array position | the `ids` array | | Copies of one card | whatever your code makes | exactly one | | Immutable update code | written by hand per handler | adapter methods | | Worth it for | short, read-only lists | collections read and written by key | The kanban board sits clearly in the right-hand column: cards are fetched, pushed from the server, edited and moved by key all day. Whether to normalise a whole application's data this way is a separate design question; the adapter is NgRx's ready-made answer for one collection.

  • Why is entities[id] typed as possibly undefined?
    `entities` is a `Dictionary<T>`, whose index signature returns `T | undefined`. A key can arrive from a route or a stale selection for a card that was never loaded or was removed, so the type forces you to handle the miss instead of rendering `undefined.title`.
  • Does the adapter make every write constant-time?
    No. Before applying a change, the adapter's write methods shallow-copy the `ids` array and the `entities` map, so a write is proportional to the collection size. What you gain is key lookups instead of scans in reducers and selectors, one stored copy per record and stable references for unchanged parts. For a few hundred cards the copy is cheap.
  • Where do fields such as selectedCardId live, and what does removeAll do to them?
    Extend `EntityState<Card>` in your state interface and pass the extra fields to `getInitialState({ selectedCardId: null })`, which is type-checked against that interface. `removeAll(state)` empties `ids` and `entities` and keeps the extra fields as they were, so reset them yourself if they should clear too.

A playlist and a music library: the playlist lists song numbers in play order, while the library holds each song once under its number. Renaming a song edits the library entry and leaves the playlist exactly as it was.

saying these in an interview costs you the question

  • The entities map keeps cards in display order, so ids is redundant.
  • Entity adapter writes are constant-time because only one key changes.
  • Every edit to one card creates a new ids array.
  • Keeping any array in NgRx state is wrong; always use an adapter.