In an NgRx SignalStore that uses withEntities for products keyed by sku, how do addEntity, setEntity and upsertEntity differ, and where does selectId go?
answer
- ids, entityMap, computed entities
- standalone updaters inside patchState
- add keeps, set replaces, upsert merges
- selector goes to the updater
- entityConfig bundles it once
basics
~20 sFor an existing id, addEntity keeps the stored product, setEntity replaces it and upsertEntity merges the given properties. withEntities takes no id selector; pass selectId to each add, set, upsert and update call, or bundle it with entityConfig.
solid answer
~30 s`withEntities<Product>()` adds `ids` and `entityMap` state plus a computed `entities` array, and changes go through standalone updaters passed to `patchState`. For an id already in the collection, `addEntity` keeps the existing entity and throws nothing, `setEntity` replaces it wholesale, and `upsertEntity` merges only the given properties; all three append when the id is new. With a `sku` key, `withEntities` itself stays the same and the selector goes into the config of every `add*`, `set*`, `upsert*` and `update*` call, while `remove*` calls take ids and need none. `entityConfig({ entity: type<Product>(), selectId: (p) => p.sku })` bundles it once for both.
code
ts · 30 linesimport { patchState, signalStore, type, withMethods } from '@ngrx/signals';
import {
entityConfig, removeEntity, setAllEntities, updateEntity, upsertEntities, withEntities,
} from '@ngrx/signals/entities';
type Product = { sku: string; name: string; stock: number; description?: string };
const productConfig = entityConfig({
entity: type<Product>(),
selectId: (p) => p.sku,
});
export const ProductsStore = signalStore(
withEntities(productConfig),
withMethods((store) => ({
loaded(products: Product[]): void {
patchState(store, setAllEntities(products, productConfig));
},
listRefreshed(products: Product[]): void {
// list items omit description; upsert keeps the one loaded earlier
patchState(store, upsertEntities(products, productConfig));
},
stockChanged(sku: string, stock: number): void {
patchState(store, updateEntity({ id: sku, changes: { stock } }, productConfig));
},
discontinued(sku: string): void {
patchState(store, removeEntity(sku));
},
}))
);go deeper
Recall that withEntities adds ids, entityMap and entities, and that you change the collection by passing updaters such as addEntity to patchState.
Explain add versus set versus upsert for an existing id, which updaters need selectId for a custom key, and why remove calls do not.
Pick updaters by payload shape: upsert or update for partial server data, set for full replacements, and entityConfig so a custom key is never forgotten.
Decide how entity collections are split across stores, when named or private collections are justified, and how to keep the id strategy consistent across the codebase.
## What withEntities adds `withEntities` comes from the `@ngrx/signals/entities` plugin and adds a normalized entity collection to a SignalStore. `signalStore(withEntities<Product>())` gives the store three members: - `ids` — a **state** slice, the ordered array of entity ids (`EntityId`, a `string` or a `number`); - `entityMap` — a **state** slice, an object from id to entity; - `entities` — a **computed** signal that maps `ids` over `entityMap` to produce the array a template iterates. By default an entity must have an `id` property. Lookup by id is a property read on `entityMap()`, and the list keeps its order through `ids`. ## Entity updaters The plugin does not add methods to the store. It ships **standalone updaters** — functions that return a partial-state updater — which you pass to `patchState`: | Updater | Existing id | Missing id | |---|---|---| | `addEntity` / `addEntities` | left unchanged, no error | appended | | `prependEntity` / `prependEntities` | left unchanged, no error | inserted at the start | | `setEntity` / `setEntities` | **replaced** by the new object | appended | | `upsertEntity` / `upsertEntities` | **merged**: given properties overwrite, others stay | appended | | `updateEntity` / `updateEntities` / `updateAllEntities` | partial `changes` applied (object or function) | no-op, no error | | `setAllEntities` | whole collection replaced | — | | `removeEntity` / `removeEntities` / `removeAllEntities` | removed (by id or predicate) | no-op, no error | Because they are plain functions, several updaters combine in one call — `patchState(store, setAllEntities(products), { loading: false })` — and the store changes once. When an updater changes nothing, such as `addEntity` for an id that already exists, it contributes an empty partial state, so no signal is set. ## Products keyed by sku: where selectId goes A product catalogue often identifies items by `sku`, not `id`. `withEntities` itself takes **no** id selector; it only creates the slices. The selector goes to the updaters: 1. Declare `const selectId: SelectEntityId<Product> = (p) => p.sku;`. 2. Pass it in the config of every `add*`, `prepend*`, `set*`, `upsert*` and `update*` call: `addEntities(products, { selectId })`. 3. Omit it on `remove*` calls — they take ids (or a predicate), so no selector is needed. Forgetting it is usually caught at compile time: the updater overloads without a config require the entity type to have an `id: EntityId` property, which `Product` does not. `entityConfig({ entity: type<Product>(), selectId: (p) => p.sku })` bundles the entity type, the selector and an optional collection name into one constant you pass both to `withEntities` and to each updater, so the selector cannot be forgotten. ## Named and private collections `withEntities({ entity: type<Product>(), collection: 'product' })` prefixes the members: `productIds`, `productEntityMap` and `productEntities`. Every updater then needs `{ collection: 'product' }` too. Named collections let one store hold several collections, although the docs recommend a dedicated store per entity type in most cases. A collection name starting with `_` (for example `'_product'`) makes the members **private** to the store, which you then expose selectively through `withComputed`. ## Reading the collection - **Lookup by id** is a property read: `store.entityMap()['A-100']`. It is the natural base for a selected-product computed built from a `selectedSku` state slice. - **Derived lists** are ordinary computeds over `entities()`, such as `lowStock: computed(() => store.entities().filter((p) => p.stock < 5))`. - **Order** comes from `ids`, so `setAllEntities` keeps the server's order, `addEntity` appends, and `prependEntity` puts a fresh item first, for example a newly created product at the top of an admin list. - **Unchanged slices stay untouched.** Each updater returns only the slices it actually changed (`entityMap`, `ids` or both) and an empty partial state when nothing changed, so `patchState` sets no signal and nothing that reads the collection recomputes. ## What interviewers listen for - The difference between **set** (replace) and **upsert** (merge) for an existing id — the most common mix-up, because it decides whether properties missing from a partial server payload survive. - That **add** never overwrites and never throws, so "adding" a fresher copy of a product silently keeps the stale one. - That `entities` is **computed**, so you never patch it directly; you patch `ids` and `entityMap` through updaters. - That custom ids are handled per updater call, or once through `entityConfig`.
- What does withEntities({ entity: type<Product>(), collection: 'product' }) change about the store and its updaters?The members get a prefix: `productIds`, `productEntityMap` and `productEntities` instead of `ids`, `entityMap` and `entities`. Every updater for that collection must then receive `{ collection: 'product' }`, or the bundled `entityConfig`. Named collections let one store hold several entity types, although the docs recommend a dedicated store per type in most cases.
- Why can a server's partial product payload lose fields when you apply it with setEntity?`setEntity` replaces the stored object with the one you pass, so any property the payload omits is gone afterwards. `upsertEntity` merges instead: properties present in the payload overwrite, properties absent from it stay. For partial updates by id, `updateEntity({ id, changes })` is the other option, and it does nothing when the id is missing.
saying these in an interview costs you the question
- addEntity overwrites the stored entity when the id already exists
- setEntity and upsertEntity both merge properties into the existing entity
- The custom selectId is configured once on withEntities and applies everywhere
- The entities array is state you patch directly
- removeEntity needs the selectId config to find a custom key