NgRx Entity Adapter: Normalize, CRUD, and Performance in 2026 Angular Apps
NgRx Entity Adapter normalizes arrays into a dictionary for O(1) lookups, provides ready-made CRUD reducers and selectors, and cuts boilerplate by 60–80%. Includes a 10k-record demo, performance caveats, and migration tips.
19 Apr 2026, 15:01 UTC

1. The Collection Problem in NgRx
When an Angular app starts to display thousands of items—think a user directory, product catalog, or chat history—the store quickly turns into a tangled web of arrays, manual updates, and duplicated logic. A naive reducer that mutates an array looks like this:
function usersReducer(state: User[] = [], action: Action) {
switch (action.type) {
case '[User] Add':
return [...state, action.payload];
case '[User] Update':
return state.map(u => u.id === action.payload.id ? action.payload : u);
// …more cases
}
}
Every time you add or update, you create a new array. If you need to find a user by ID you end up looping through the array, which is O(n). With 10,000+ users, that becomes a performance bottleneck and a source of bugs (duplicate IDs, out‑of‑sync order, etc.).
2. What the Entity Adapter Does
The NgRx @ngrx/entity package ships an EntityAdapter that automatically turns an array into a normalized structure:
entities– a dictionary keyed by ID (O(1) lookup).ids– an array of IDs preserving order.- Convenient CRUD reducers:
addOne,addMany,upsertOne,updateOne,deleteOne. - Selectors:
selectAll,selectEntities,selectIds,selectTotal.
Because the adapter uses Immer internally (since v12), it guarantees immutability: every operation returns a fresh EntityState instance, so OnPush change detection can skip unchanged components.
3. A Concrete 10k‑Record Demo
Below is a minimal Angular 17+ setup that demonstrates:
- Creating a
Userentity adapter. - Dispatching
addManywith 10,000 users. - Measuring
selectEntityperformance.
3.1. Model and Adapter
// user.model.ts
export interface User {
id: string; // string or number
name: string;
email: string;
}
// user.actions.ts
import { createAction, props } from '@ngrx/store';
export const addUsers = createAction('[User] Add Many', props<{ users: User[] }>());
export const updateUser = createAction('[User] Update', props<{ user: User }>());
export const deleteUser = createAction('[User] Delete', props<{ id: string }>());
// user.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
import { User } from './user.model';
import * as UserActions from './user.actions';
export interface UserState extends EntityState<User> {
loading: boolean;
error: string | null;
}
export const adapter: EntityAdapter<User> = createEntityAdapter<User>({
selectId: user => user.id,
sortComparer: false // preserve insertion order for virtualization
});
export const initialState: UserState = adapter.getInitialState({
loading: false,
error: null
});
export const userReducer = createReducer(
initialState,
on(UserActions.addUsers, (state, { users }) => adapter.addMany(users, state)),
on(UserActions.updateUser, (state, { user }) => adapter.updateOne({ id: user.id, changes: user }, state)),
on(UserActions.deleteUser, (state, { id }) => adapter.removeOne(id, state))
);
export const { selectAll, selectEntities, selectIds, selectTotal } = adapter.getSelectors();
3.2. Store Setup and Dispatch
// app.module.ts (excerpt)
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { userReducer } from './user.reducer';
@NgModule({
imports: [
StoreModule.forRoot({ users: userReducer }),
EffectsModule.forRoot([])
]
})
export class AppModule {}
In a component or service you can now dispatch a bulk load:
// user.service.ts
@Injectable()
export class UserService {
constructor(private store: Store) {}
loadTenThousand() {
const users: User[] = [];
for (let i = 0; i < 10000; i++) {
users.push({ id: i.toString(), name: `User ${i}`, email: `user${i}@example.com` });
}
this.store.dispatch(UserActions.addUsers({ users }));
}
}
3.3. Performance Check
With the adapter, selectEntities returns a dictionary. Looking up a single user is a simple object property access, O(1). In a quick benchmark you can time the selector:
const start = performance.now();
const user = store.select(selectEntities).pipe(take(1)).subscribe(entities => {
console.log(entities['1234']); // fast, no loop
});
const elapsed = performance.now() - start;
console.log(`lookup took ${elapsed.toFixed(2)} ms`);
In practice you'll see a few microseconds, even with 10k items, compared to a 10‑millisecond loop in a plain array.
4. Trade‑offs & Caveats
- sortComparer cost: When you provide a
sortComparer, everyaddOne/upsertOneruns the comparison to keepidssorted. With 10k+ items this can block the main thread. If you only need insertion order, omitsortCompareror memoize heavy comparisons. - Duplicate IDs: The adapter silently overwrites existing entries if the same ID is added again. Validation should happen in the effect or service before dispatching.
- Immer and DevTools: The adapter uses Immer to produce new state objects. Mutating the returned state in a reducer will break DevTools' time‑travel debugging and may lead to subtle bugs.
- Migration from v11: Import paths changed from
import { createEntityAdapter } from '@ngrx/entity';and the API switched to Immer. If you had custom reducers, update them to use the newadapter.addOnestyle.
5. Takeaway & Next Steps
For any Angular application that stores a sizable collection of entities, the NgRx Entity Adapter is a first‑class tool:
- It cuts boilerplate by 60–80 % compared to manual reducers.
- It guarantees immutability and O(1) lookups.
- It integrates cleanly with selectors, effects, and dev‑tools.
Next steps:
- Replace existing array reducers with an adapter.
- Use
selectAllfor lists,selectEntitiesfor lookup tables, andselectTotalfor pagination. - Profile sorting with
sortCompareron large data sets and consider memoization or server‑side ordering. - Add validation logic in effects to guard against duplicate IDs.
- Run a quick unit test using
runReducerfrom@ngrx/store/testingto confirm thatupsertOnepreserves non‑updated fields.
With these steps you'll have a robust, performant, and maintainable state layer that scales with your data size.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.