Why @ngrx/entity Makes Collection State a Breeze (and How to Use It Right)
Managing lists in NgRx can be tedious—array copies, manual indexing, boilerplate reducers. @ngrx/entity normalizes collections into an ids/objects shape, providing ready‑made CRUD reducers and memoized selectors, simplifying state updates and lookup.
12 Feb 2026, 15:26 UTC

Concrete Problem: Lists in NgRx Are Hard to Maintain
When you store an array of items in a Redux‑style store, every mutation requires a full array copy. Adding, updating, or removing an item means you write a reducer that spreads the old array, finds an index, and returns a new array. This boilerplate grows with every collection and is error‑prone if you forget to keep immutability.
Consider a simple Todo list. The naive reducer might look like this:
export const todoReducer = createReducer(
initialState, // { todos: Todo[] }
on(addTodo, (state, { todo }) => ({
...state,
todos: [...state.todos, todo]
}))
);
Now you need to add updateTodo, removeTodo, and so on. Each action introduces new lines and a full copy of the array. If you later decide to switch to a map or a different shape, you’ll have to refactor every reducer.
Solution: @ngrx/entity Normalizes Collections
The @ngrx/entity package supplies createEntityAdapter, which turns any collection into a { ids: string[], entities: Record } shape. Lookups become O(1), updates are simple, and the adapter ships CRUD reducers that keep the reducer file to a single line per operation.
Key features:
- Normalization:
idskeeps order,entitiesstores objects by key. - CRUD methods:
addOne,addMany,updateOne,upsertMany,removeOne,setAll. - Custom
selectIdandsortCompareroptions to handle non‑idkeys or stable sorting. - Built‑in selectors:
selectAll,selectEntities,selectIds,selectTotal.
Worked Example: A Todo Feature with @ngrx/entity
Below is a minimal, verifiable example using NgRx 15+ functional API. Adjust imports if you’re on an older major version.
// todo.model.ts
export interface Todo {
id: string;
title: string;
completed: boolean;
}
// todo.state.ts
import { createEntityAdapter, EntityState } from '@ngrx/entity';
import { Todo } from './todo.model';
export interface TodoState extends EntityState<Todo> {
loading: boolean;
}
export const todoAdapter = createEntityAdapter<Todo>({
selectId: (todo) => todo.id,
// Keep items sorted by title alphabetically
sortComparer: (a, b) => a.title.localeCompare(b.title)
});
export const initialTodoState: TodoState = todoAdapter.getInitialState({
loading: false
});
// todo.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { todoAdapter, initialTodoState } from './todo.state';
import { addTodo, loadTodosSuccess, updateTodo, deleteTodo } from './todo.actions';
export const todoReducer = createReducer(
initialTodoState,
on(addTodo, (state, { todo }) => {
return todoAdapter.addOne(todo, state);
}),
on(loadTodosSuccess, (state, { todos }) => {
return todoAdapter.setAll(todos, state);
}),
on(updateTodo, (state, { changes }) => {
return todoAdapter.updateOne(changes, state);
}),
on(deleteTodo, (state, { id }) => {
return todoAdapter.removeOne(id, state);
})
);
// todo.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { todoAdapter, TodoState } from './todo.state';
export const selectTodoState = createFeatureSelector<TodoState>('todos');
const { selectAll, selectEntities, selectIds, selectTotal } = todoAdapter.getSelectors();
export const selectAllTodos = createSelector(selectTodoState, selectAll);
export const selectTodoEntities = createSelector(selectTodoState, selectEntities);
export const selectTodoIds = createSelector(selectTodoState, selectIds);
export const selectTodoCount = createSelector(selectTodoState, selectTotal);
// todo.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { map, mergeMap, catchError } from 'rxjs/operators';
import { TodoService } from './todo.service';
import { loadTodos, loadTodosSuccess, loadTodosFailure } from './todo.actions';
@Injectable()
export class TodoEffects {
loadTodos$ = createEffect(() =>
this.actions$.pipe(
ofType(loadTodos),
mergeMap(() =>
this.todoService.getAll().pipe(
map((todos) => loadTodosSuccess({ todos })),
catchError((err) => of(loadTodosFailure({ error: err })))
)
)
)
);
constructor(private actions$: Actions, private todoService: TodoService) {}
}
Key take‑aways from the code:
- The adapter’s
addOne,setAll,updateOne, andremoveOnereplace manual array manipulation. - Selectors are memoized;
selectAllTodosrecomputes only when the collection mutates. - The
sortComparerkeeps theidsarray sorted by title, so components can render a sorted list without extra logic. - The effects load data once and dispatch
loadTodosSuccess; the reducer appliessetAllto replace the entire collection.
When to Use the Adapter (and When Not to)
While the adapter is powerful, it’s not a silver bullet. Consider the following trade‑offs:
- Complexity vs. Simplicity: For a small component that only needs a handful of items, a plain service with RxJS or
@ngrx/component-storemay be lighter. The adapter shines when many components read or mutate the same collection across the app. - Over‑Normalization: Normalizing deeply nested graphs (e.g., a user with posts and comments) can lead to many selectors and make debugging harder. It’s best for flat lists.
- Learning Curve: New team members must understand the
{ ids, entities }shape and how to use adapter methods. Provide clear documentation and unit tests. - Version Sensitivity: The adapter API has changed across NgRx major releases. Verify that your project’s
@ngrx/*packages match the example (e.g., NgRx 15+). The functionalcreateFeatureAPI can reduce boilerplate further, but the adapter remains compatible.
Practical Checklist for Adoption
- Audit Existing State: Identify collections that grow or are shared across components.
- Define an Adapter: Use
createEntityAdapterwithselectIdand optionalsortComparer. - Replace Reducer Logic: Swap array spreads for adapter CRUD calls.
- Update Selectors: Use
adapter.getSelectors()to exposeselectAlland others. - Test Immutability: In unit tests, assert that previous state references remain unchanged after an action.
- Monitor Performance: Verify memoization by ensuring selectors don’t recompute on unrelated state changes.
Conclusion: Clean, Predictable Collection State
The @ngrx/entity adapter removes the repetitive, error‑prone parts of list state management. By normalizing data into { ids, entities }, you get O(1) lookups, ready‑made CRUD reducers, and memoized selectors—all without writing boilerplate. Use it when your app has shared, mutable lists that grow over time, and keep an eye on complexity for deeply nested data or very small components.
Next steps: try adding a selectById selector, experiment with upsertMany for optimistic updates, and measure selector churn in your devtools to confirm the performance gains.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.