NgRx Entity Adapter: Normalized Collections Without the Boilerplate
NgRx Entity Adapter replaces array-based collections with a normalized ids+entities map, giving you O(1) lookups, built-in CRUD reducers, and memoized selectors — all in ~2–3 KB gzipped. Here's how to set it up, where it shines, and the trade-offs to watch for.
14 Sept 2025, 08:09 UTC

The Problem: Collections in NgRx Store Get Messy Fast
You start with a simple array of users in your feature state. Then you need to look up a user by ID in a component, so you add a find() call. Next comes a requirement to update a single user's name without replacing the whole array. Before long, your reducers are littered with map, filter, and spread operators, and every new action risks accidental mutation. The array shape that felt natural at first becomes a source of bugs and boilerplate.
Takeaway: NgRx Entity Adapter gives you a normalized state shape — an ids array plus an entities map — with built-in reducer helpers and memoized selectors. You get O(1) lookups by ID, automatic immutability, and roughly 2–3 KB gzipped overhead.
What Normalized State Actually Looks Like
Instead of users: User[], the adapter stores:
interface UserState {
ids: string[]; // ['u1', 'u2', 'u3']
entities: { [id: string]: User }; // { 'u1': {...}, 'u2': {...} }
// optional: loading, error, pagination metadata
}
The ids array preserves order (useful for server-driven sorting), while entities enables direct access by key. No more users.find(u => u.id === 'u2') — you ask the selector for selectEntity('u2') and get the object instantly.
This shape is the recommended pattern for collections in NgRx Store (Angular 14+ / NgRx 14+). It avoids nested arrays, makes reducer logic predictable, and plays nicely with Redux DevTools — you see the map and the order side by side.
Reducer Helpers and Selector Factories
The adapter generates a full set of CRUD methods you use inside on() reducers or createReducer:
addOne,addMany— insert new entities, skipping duplicates by IDupsertOne,upsertMany— insert or replaceupdateOne,updateMany— partial updates via{ id, changes }deleteOne,deleteMany,removeAll— removal by ID or predicate
Each returns a new state object; the original is never mutated. Selectors come from adapter.getSelectors(selectFeatureState) and include selectAll, selectEntities, selectIds, selectTotal, and selectEntity(id). These are memoized via createSelector, so components only re-render when the relevant slice changes.
Configuration happens at adapter creation:
const userAdapter = createEntityAdapter<User>({
selectId: (user) => user.id, // default: 'id'
sortComparer: (a, b) => a.name.localeCompare(b.name) // optional ordering
});
Both functions are pure, keeping reducers testable. sortComparer runs on every add/upsert, so keep it cheap — expensive comparers on large collections will degrade reducer performance.
Worked Example: User Feature with Effects
Here's a minimal standalone feature setup (Angular 18 / NgRx 18).
State and Adapter
// user.state.ts
import { createEntityAdapter, EntityState } from '@ngrx/entity';
import { User } from './user.model';
export interface UserState extends EntityState<User> {
loading: boolean;
error: string | null;
}
export const userAdapter = createEntityAdapter<User>({
selectId: (u) => u.id,
sortComparer: (a, b) => a.name.localeCompare(b.name)
});
export const initialUserState = userAdapter.getInitialState({
loading: false,
error: null
});
Reducers Using Adapter Methods
// user.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { userAdapter, UserState, initialUserState } from './user.state';
import * as UserActions from './user.actions';
export const userReducer = createReducer(
initialUserState,
on(UserActions.loadUsersSuccess, (state, { users }) =>
userAdapter.setAll(users, { ...state, loading: false })
),
on(UserActions.addUserSuccess, (state, { user }) =>
userAdapter.addOne(user, { ...state, loading: false })
),
on(UserActions.updateUserSuccess, (state, { user }) =>
userAdapter.upsertOne(user, { ...state, loading: false })
),
on(UserActions.deleteUserSuccess, (state, { id }) =>
userAdapter.removeOne(id, { ...state, loading: false })
),
on(UserActions.loadUsersFailure, (state, { error }) =>
({ ...state, loading: false, error }))
);
Selectors Exported for Components
// user.selectors.ts
import { createFeatureSelector } from '@ngrx/store';
import { userAdapter, UserState } from './user.state';
export const selectUserState = createFeatureSelector<UserState>('users');
export const {
selectAll: selectAllUsers,
selectEntities: selectUserEntities,
selectIds: selectUserIds,
selectTotal: selectUserCount,
selectEntity: selectUserById
} = userAdapter.getSelectors(selectUserState);
Component Consumption
// user-list.component.ts
@Component({ ... })
export class UserListComponent {
users$ = this.store.select(selectAllUsers);
constructor(private store: Store) {}
ngOnInit() { this.store.dispatch(UserActions.loadUsers()); }
}
// user-detail.component.ts
@Component({ ... })
export class UserDetailComponent {
user$ = this.store.select(selectUserById, { id: this.route.snapshot.paramMap.get('id')! });
// ...
}
Effects call the API and dispatch success/failure actions; the reducer uses setAll, addOne, etc. DevTools will show the normalized ids and entities map after each dispatch.
Trade-offs and Limitations
- Indirection in components: You must use
selectAllorselectEntity(id)instead of direct array access. This is a mental shift, not a technical blocker. - No deep cloning: Adapter methods return new state objects but do not deep-clone nested entity properties. Optimistic updates with rollback require manual cloning of the entity you intend to mutate.
- Pagination and cursors are separate: The adapter only manages the entity collection. Page metadata (current page, total pages, next cursor) lives in the same feature state but outside the adapter's shape.
- ID mismatches cause silent bugs: If an entity lacks the property defined by
selectId(defaultid), you'll get duplicate entries or lookup failures with no runtime error. - NgRx 17+ signals coexist but don't replace:
signalStoreandrxMethodcan live alongside Entity Adapter, but mixing both in one feature creates two sources of truth. Pick one pattern per feature.
When to Reach for Entity Adapter
Use it whenever you have a collection of identifiable items that you query, update, or delete individually — users, products, notifications, dashboard widgets. Skip it for tiny static lists (like a handful of status enums) or when you genuinely need a simple array with no lookups.
To verify in your own project: create a minimal Angular 18 + NgRx 18 app (ng new --standalone), add @ngrx/store @ngrx/effects @ngrx/entity, define the User entity and adapter above, then run ng build --stats-json and confirm the entity adapter chunk is ~2–3 KB gzipped. Unit test with @ngrx/store/testing's provideMockStore and assert selector output after dispatching actions.
The adapter eliminates the reducer boilerplate that makes NgRx feel heavy, and the normalized shape scales cleanly. Your future self will thank you when the next "update just this one field" ticket lands.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.