Stop Hand‑Rolling Reducers for Lists: The Case for NgRx Entity Adapter
NgRx Entity Adapter replaces hand‑written CRUD reducers and selectors with a normalized state shape and immutable helpers. Here’s a worked ticket‑list example, plus the three sharp edges to decide on up front.
06 May 2026, 11:40 UTC

If your NgRx reducers contain a case that spreads the state, filters an array by ID, and splices in a changed record, you are writing code the framework already ships. Every feature that manages a list of identifiable records — users, tickets, products — tends to accumulate the same five reducer cases: add, update, remove, load‑all, and some selector that maps an ID dictionary back into an array. NgRx Entity Adapter exists specifically to delete that boilerplate, and the trade‑offs are narrow enough that the decision is usually easy.
The thesis: for any collection of records with a stable unique ID, createEntityAdapter gives you a normalized state shape, immutable CRUD helpers, and memoized selectors for free. The cost is a fixed shape you don't control and a few sharp edges around nested updates and sorting.
What the adapter actually gives you
Calling createEntityAdapter<T>() returns an adapter object. It defines your state as { ids: string[], entities: { [id: string]: T } } — a lookup dictionary plus an ordered ID list, the classic normalized pattern. On top of that shape you get two things:
- Reducer helpers:
addOne,addMany,setAll,updateOne,upsertOne,removeOne,removeMany,removeAll. Each takes the current state and returns a new state object immutably — no spread gymnastics. - Selector factories:
adapter.getSelectors()returnsselectIds,selectEntities,selectAll, andselectTotal. These are memoizedcreateSelectorresults, soselectAllonly recomputes the array when the underlying map changes.
The practical win is consistency. Every entity feature in the app has the same shape, the same reducer idioms, and the same selector names, which makes code review and onboarding measurably easier than a pile of bespoke array reducers.
A worked example: a ticket list
Assume NgRx 15+ with standalone APIs, though the adapter itself has been stable since roughly NgRx 8. Define the model and adapter:
// tickets.reducer.ts
import { createEntityAdapter, EntityState } from '@ngrx/entity';
import { createReducer, on } from '@ngrx/store';
import { TicketActions } from './tickets.actions';
export interface Ticket {
id: string; // stable, server-assigned
title: string;
status: 'open' | 'closed';
updatedAt: number;
}
export interface TicketsState extends EntityState<Ticket> {
selectedId: string | null;
}
export const adapter = createEntityAdapter<Ticket>({
// keep ids sorted by most recently updated
sortComparer: (a, b) => b.updatedAt - a.updatedAt,
});
export const initialState: TicketsState = adapter.getInitialState({
selectedId: null,
});The reducer then reads like intent rather than bookkeeping:
export const ticketsReducer = createReducer(
initialState,
on(TicketActions.loaded, (state, { tickets }) =>
adapter.setAll(tickets, state)),
on(TicketActions.statusChanged, (state, { id, status }) =>
adapter.updateOne({ id, changes: { status } }, state)),
on(TicketActions.removed, (state, { id }) =>
adapter.removeOne(id, state)),
);And selectors come from the factory, composed with your feature selector:
const { selectAll, selectEntities, selectTotal } = adapter.getSelectors();
export const selectTicketsState = createFeatureSelector<TicketsState>('tickets');
export const selectAllTickets = createSelector(selectTicketsState, selectAll);
export const selectTicketEntities = createSelector(selectTicketsState, selectEntities);To verify the behavior, a unit test with @ngrx/store/testing can dispatch updateOne and assert two things: the previous state object was not mutated (compare references), and the new state's entities['1'].status reflects the change. Also assert that ids re-sorted according to the comparer. If your entity's key isn't called id, pass selectId: (t) => t.ticketNumber to the adapter — but note the key must be stable and unique.
Where the adapter bites you
Three limitations deserve a decision, not just awareness:
- Shallow updates only.
updateOnemergeschangesat the top level of the entity. If a ticket contains a nestedassigneeobject and you need to changeassignee.name, you must build the nested object immutably yourself in thechangespayload (spread or Immer). The adapter will not save you from mutating a nested reference. - Sorting cost. With a
sortComparer, every add/set/upsert re-sorts theidsarray — O(n log n) per operation. For a few thousand records this is invisible. For tens of thousands updated frequently, consider dropping the comparer and sorting in a single selector, or paginating server-side. - No referential integrity. Notice the
selectedIdfield in the example state.removeOnewill happily delete a ticket thatselectedIdstill points at. Cleanup of cross-references is your job — handle it in the same reducer case, e.g. resetselectedIdwhen it matches the removed ID.
There's also a boundary decision: Entity Adapter manages collections, not workflows. Loading flags, error state, and pagination cursors still live as extra fields on your feature state, as selectedId does above.
Global store or ComponentStore
If the collection is local to one feature — a search-results panel, a wizard — you don't need the global store to get this pattern. @ngrx/component-store supports the same entity approach, letting a ComponentStore hold normalized entity state with the same adapter helpers, scoped to the component's lifetime. The decision rule is the usual one: shared across routes or needed after navigation → global store; ephemerally local → ComponentStore.
The actionable part
Next time you start a feature whose state is "a list of things with IDs," reach for createEntityAdapter before writing a single reducer case. Decide three things up front: what the stable ID is (supply selectId if it isn't id), whether you need deterministic ordering (and can afford the sort), and which cross-references need cleanup on delete. Then check the current API signature against the official docs at ngrx.io/guide/entity/adapter for your NgRx version — the surface has been stable for years, but generic type inference has improved, so older codebases may need explicit annotations on the updateOne payloads. You'll delete more code than you write, which is the best kind of state management.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.