NgRx Entity Adapter: Simplify CRUD State Management in Angular
Tired of writing repetitive CRUD reducers for lists? The NgRx Entity Adapter turns arrays into a normalized dictionary, gives you ready‑made reducer helpers and memoized selectors, and keeps your code type‑safe. Learn how to set it up, trade‑offs, and a real‑world example.
05 Jul 2025, 14:39 UTC

Why Normalization Matters in NgRx
When an Angular app grows, lists of items—users, products, messages—become common. A naive reducer keeps these as plain arrays and mutates them with spread operators. That leads to duplicate code, hard‑to‑track bugs, and performance hits when the array grows. The NgRx Entity Adapter solves this by storing collections in a normalized shape: a dictionary of entities keyed by id and a parallel array of ids that preserves order.
What the Adapter Gives You
- Automatic
idsandentitiesstate shape ({ ids: string[], entities: Record }) - Ready‑made reducer helpers:
addOne,addMany,updateOne,deleteOne, etc. - Memoized selectors:
selectAll,selectEntities,selectEntity(id),selectTotal - Optional
sortComparerto keep the id array sorted automatically - Strong TypeScript inference that catches mismatched ids or entity shapes at compile time
Setting Up a Feature with the Adapter
Install the packages (if not already present):
ng add @ngrx/store @ngrx/entityDefine the entity interface and create the adapter.
import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity'; export interface User { id: string; name: string; email: string; } export interface UserState extends EntityState { // additional feature state can go here } export const userAdapter: EntityAdapter = createEntityAdapter<User>({ selectId: (user) => user.id, sortComparer: (a, b) => a.name.localeCompare(b.name) });Define the initial state using the adapter’s
getInitialState.export const initialState: UserState = userAdapter.getInitialState({ // any non‑entity state });Create a reducer that delegates CRUD actions to the adapter.
import { createReducer, on } from '@ngrx/store'; import { loadUsersSuccess, addUser, updateUser, deleteUser } from './user.actions'; export const userReducer = createReducer( initialState, on(loadUsersSuccess, (state, { users }) => userAdapter.setAll(users, state)), on(addUser, (state, { user }) => userAdapter.addOne(user, state)), on(updateUser, (state, { user }) => userAdapter.updateOne({ id: user.id, changes: user }, state)), on(deleteUser, (state, { id }) => userAdapter.removeOne(id, state)) );Expose selectors that automatically memoize.
import { createFeatureSelector, createSelector } from '@ngrx/store'; export const selectUserState = createFeatureSelector<UserState>('users'); export const { selectAll, selectEntities, selectIds, selectTotal, selectEntity } = userAdapter.getSelectors(selectUserState);Use the selectors in a component.
import { Component } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { User } from './user.model'; import { selectAll } from './user.selectors'; @Component({ selector: 'app-user-list', template: `- {{ user.name }} ({{ user.email }})
Performance in Action
Because the adapter keeps an ids array, lookups are O(1): selectEntity(id) simply returns state.entities[id]. Adding or deleting an item updates only the affected parts of the state, producing a new ids array and a new entities map without copying the whole list. Memoized selectors mean components re‑render only when the slice they consume changes.
Trade‑Offs and Limitations
- Large Lists: The
sortComparerruns on every mutation that changes order. For 10k+ items, consider disabling the comparer and sorting lazily in a selector or using virtual scrolling. - Multiple Sort Orders: The adapter supports a single
sortComparer. If you need different orders (e.g., by date for one view, by name for another), create separate adapters or custom selectors. - Pagination & Filtering: The adapter does not handle server‑side pagination or filtering. You still need to write effects and actions for those concerns.
- Migration Effort: Moving an existing reducer that stores a plain array into the adapter’s normalized shape requires a one‑time transformation script or manual code changes.
Beyond the Store: ComponentStore Compatibility
The same API works with ComponentStore, letting you normalize a list inside a single component. That keeps the feature encapsulated while still benefiting from the adapter’s immutability and memoization.
Actionable Next Steps
- Identify a list feature that currently uses a plain array reducer.
- Replace the array with an Entity Adapter following the steps above.
- Run
ng build --configuration productionto ensure no type errors surface. - Profile state changes in DevTools to verify that only the minimal slices update.
- Document the new selector names for future developers.
Once in place, the adapter reduces boilerplate, improves performance, and gives you a type‑safe foundation for future feature growth.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.