Adopting NgRx Entity Adapter for Normalized Entity State in Angular
Step‑by‑step guide to replace an array‑based feature slice with @ngrx/entity’s normalized shape, including reducer setup, selectors, and verification steps.
10 Feb 2026, 09:35 UTC

Desired outcome
Replace an ad‑hoc array of domain objects in a feature state slice with a normalized structure keyed by entity ID. This gives O(1) lookups via a dictionary, provides built‑in CRUD reducers, and enables memoized collection selectors without manual mapping.
Prerequisites
- Angular 17+
- @ngrx/store 17+ and @ngrx/entity 17+
- TypeScript 5.3+
- Familiarity with
createReducer/onsyntax andcreateFeatureSelector - An existing feature slice that holds an array of objects with a stable unique ID (string or number)
Procedure
- Define the entity shape and create the adapter
export interface User { id: string; name: string; email: string; } export const userAdapter = createEntityAdapter({ // Return the stable ID selectId: (user) => user.id, // Optional: sort the collection by name; set false to keep insertion order sortComparer: (a, b) => a.name.localeCompare(b.name) }); - Extend the feature state with EntityState
export interface UserState extends EntityState { loading: boolean; error: string | null; } export const initialState: UserState = userAdapter.getInitialState({ loading: false, error: null }); - Write CRUD reducers using adapter methods
import { createReducer, on } from '@ngrx/store'; import * as UserActions from './user.actions'; export const userReducer = createReducer( initialState, on(UserActions.loadUsersSuccess, (state, { users }) => userAdapter.setAll(users, { ...state, loading: false, error: null }) ), on(UserActions.addUserSuccess, (state, { user }) => userAdapter.addOne(user, state) ), on(UserActions.updateUserSuccess, (state, { user }) => userAdapter.updateOne({ id: user.id, changes: user }, state) ), on(UserActions.deleteUserSuccess, (state, { id }) => userAdapter.removeOne(id, state) ), // Optional: handle loading/error actions on(UserActions.loadUsers, (state) => ({ ...state, loading: true, error: null })), on(UserActions.loadUsersFailure, (state, { error }) => ({ ...state, loading: false, error: error })) ); - Export memoized selectors from the adapter
import { createFeatureSelector } from '@ngrx/store'; const selectUserState = createFeatureSelector('user'); export const { selectAll: selectAllUsers, selectEntities: selectUserEntities, selectIds: selectUserIds, selectTotal: selectUserTotal } = userAdapter.getSelectors(selectUserState);Use
selectAllUsersin components when you need a sorted array; useselectUserEntitiesfor O(1) lookup by ID.
Expected checks
- Unit tests: verify that after each reducer action the state contains
idsarray andentitiesdictionary with matching values. - Component test: subscribe to
store.select(selectAllUsers)and confirm the emitted array is sorted according to thesortComparer(or insertion order if set tofalse). - Redux DevTools: after dispatching
loadUsersSuccess, inspect theuserslice; you should seeids(array) andentities(object) with no duplicate IDs. - Rapid‑fire test: simulate a burst of
addUserSuccess,updateUserSuccess, anddeleteUserSuccessactions (e.g., via Jasmine fakeAsync or Jest fake timers) and assert thatids.lengthequalsObject.keys(entities).lengthat all times. - Bundle size: run
npm ls @ngrx/entityto confirm the peer dependency is satisfied; the library adds roughly 2.5 kB gzipped.
Recovery options
- If existing selectors break after migration, keep the old array selector temporarily:
export const selectOldUsers = createFeatureSelector('user').pipe(map(s => s.ids.map(id => s.entities[id])));and migrate consumers gradually. - If the sort comparer causes unwanted ordering or performance concerns, reset it:
sortComparer: false. This preserves insertion order and skips the sorting step on each state change. - If the ID type changes (e.g., from string to number), update
selectIdaccordingly and run a one‑time migration script that rebuilds theentitiesmap from the existing array.
Limitations and practical verification
The adapter methods always return a new state object; mutating the returned value directly will break immutability guarantees. Ensure you assign the result back in the reducer (as shown). The selectId function must return a stable, unique value; using an array index or a newly generated UUID on each update will cause duplicate or missing entries.
To verify that the normalized shape is being used in practice, add a simple component:
import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { selectAllUsers, User } from './+state/user.selectors';
@Component({
selector: 'app-user-list',
template: `\n \n - \n {{ user.name }} ({{ user.id }})\n
\n
\n `
})
export class UserListComponent {
users$: Observable = this.store.select(selectAllUsers);
constructor(private store: Store) {}
}
Run the application, open Redux DevTools, and confirm that the user slice shows the normalized structure while the template renders the list without an additional sort pipe.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.