Managing Normalized Collections with the @ngrx/entity Adapter: An Architecture Note
An architecture note on using @ngrx/entity's createEntityAdapter for normalized collection state: requirements, minimal reducer and selector setup, server-data trust boundaries, tests, and the conditions that outgrow it.
09 Sept 2025, 14:57 UTC

Your Angular feature needs a collection — say, todos — that components can look up by id, update optimistically, and roll back when the server rejects a change. The hand‑rolled alternative is a reducer full of spread operators over arrays, plus O(n) find calls in every selector. The smallest design that covers these requirements without that boilerplate is @ngrx/entity's createEntityAdapter. This note walks through when it fits, where the trust boundaries sit, and what would make you outgrow it.
Requirements the design must cover
- Fast id‑based lookup from components and selectors.
- Add, update, remove, and bulk‑load operations with immutable state transitions.
- Optimistic updates: the UI reflects a change immediately, and a failure dispatches a compensating action.
- Selector memoization so list rendering does not recompute on unrelated state changes.
If your entities lack stable unique ids, or you need nested relational data and server‑driven pagination, stop here — the flat adapter is the wrong tool (see the failure modes section).
The smallest suitable design
The adapter generates the reducer logic, the state shape ({ ids: [], entities: {} }), and memoized selectors. You write actions and wire adapter methods into createReducer. Assumption: NgRx 15+ standalone APIs with Angular 15+; the same pattern works with NgModule‑based setup.
// state/todo.model.ts
export interface Todo { id: string; title: string; done: boolean; }
// state/todo.reducer.ts
import { createEntityAdapter, EntityState } from '@ngrx/entity';
import { createReducer, on } from '@ngrx/store';
import { Todo } from './todo.model';
import * as TodoActions from './todo.actions';
export interface TodoState extends EntityState<Todo> {}
export const adapter = createEntityAdapter<Todo>();
// Default: uses entity.id. Override with selectId if your key differs.
export const initialState: TodoState = adapter.getInitialState();
export const todoReducer = createReducer(
initialState,
on(TodoActions.loadSuccess, (state, { todos }) => adapter.setAll(todos, state)),
on(TodoActions.addOne, (state, { todo }) => adapter.addOne(todo, state)),
on(TodoActions.upsertOne, (state, { todo }) => adapter.upsertOne(todo, state)),
on(TodoActions.updateOne, (state, { update }) => adapter.updateOne(update, state)),
on(TodoActions.removeOne, (state, { id }) => adapter.removeOne(id, state))
);
Selectors come from the adapter once, at module scope — do not call getSelectors() inside a component or a factory that runs per render, or you lose memoization:
// state/todo.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { adapter, TodoState } from './todo.reducer';
const selectTodoState = createFeatureSelector<TodoState>('todos');
const { selectIds, selectEntities, selectAll, selectTotal } =
adapter.getSelectors();
export const selectAllTodos = createSelector(selectTodoState, selectAll);
export const selectTodoEntities = createSelector(selectTodoState, selectEntities);
export const selectTodoById = (id: string) =>
createSelector(selectTodoEntities, (entities) => entities[id]);Trust and data boundaries
Treat UI‑originated actions (a checkbox toggle, a delete click) as trusted: the payload came from your own typed code. Treat anything arriving from the network as untrusted. Before an effect dispatches loadSuccess or upsertOne with server data, validate minimally: every entity has a non‑empty unique id and the required fields. A duplicate or missing id will silently overwrite an existing entry or produce a corrupt collection, and the adapter will not warn you in production builds.
Keep this validation in the effect layer, not the reducer. Reducers should stay pure and dumb; effects are the boundary where untrusted data enters the store. A typical flow:
- UI dispatches
toggleDone({ id }). - Reducer applies
updateOneoptimistically. - Effect calls the API. On success, nothing more is needed (or dispatch a confirm action). On failure, dispatch
updateOnewith the previous value captured viaconcatLatestFrom/withLatestFromreadingselectTodoEntitiesbefore the optimistic change — or snapshot it in the original action's payload.
Operational checks
- Reducer unit tests: for each action, assert the resulting state against
adapter.getInitialState()plus the expected mutation. Test the duplicate‑id case explicitly: dispatchingaddOnewith an existing id should leave state unchanged (the adapter ignores it), whileupsertOneoverwrites — confirm whichever behavior you rely on. - Selector memoization: call
selectAllTodos.projector(state)twice with the same state reference and assert reference equality of results; then change an unrelated slice and assert the result is still the same reference. - Effect tests: mock the HTTP layer, emit a success and assert
upsertOne; emit an error and assert the rollback action carries the pre‑update entity. - Runtime inspection: open Redux DevTools and confirm the slice is a flat
{ ids, entities }map and that unrelated actions do not touch it.
Failure modes and what would change the design
- Unstable or duplicated ids. The adapter keys everything by
selectId. If the server can return the same id twice in one payload, later entries silently win. Fix at the validation boundary, or dedupe before dispatch. - Relational data. A todo with an embedded
userobject stored flat will go stale when the user updates. At that point normalize across multiple entity slices (todos, users) with ids as foreign keys, or evaluate@ngrx/data, which bundles this pattern with built‑in HTTP handling. - Server‑side pagination or partial collections.
setAllreplaces the whole collection;addManymerges but has no notion of \"page 3 of 9.\" If the collection is a window into a larger dataset, you need a custom reducer tracking page metadata alongside the adapter state — the adapter can still hold the entities, but it cannot be the whole design. - Direct mutation. Never write
state.entities[id].done = truein a reducer or effect. It breaks immutability, defeatsOnPushchange detection, and makes memoized selectors return stale results. Adapter methods always return new state.
The decision rule: if your collection is flat, id‑keyed, and fully loaded (or append‑only), the adapter is the smallest correct design. The moment you need relationships, pagination windows, or server‑defined ordering beyond a simple sortComparer, keep the adapter for storage but plan a custom layer around it — or step up to @ngrx/data.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.