NgRx EntityAdapter vs Custom Reducers: Choosing the Right Collection Strategy
A technical decision guide for managing collection state in NgRx. Compare EntityAdapter's normalized approach against custom reducers to reduce boilerplate and improve performance.
17 Oct 2025, 11:56 UTC

The Collection State Dilemma
When managing lists of data in an Angular application, the primary challenge is maintaining immutability while ensuring high performance during updates. In NgRx, you must choose between using the EntityAdapter utility or writing a custom reducer. The wrong choice leads to either excessive boilerplate or performance bottlenecks when updating single items in large lists.
Decision Constraints
Your choice should be driven by these four technical constraints:
- Data Shape: Do your entities have a unique ID?
EntityAdapterrequires a unique identifier to normalize state. - Update Frequency: Are you performing frequent single-item updates? Normalized state (maps) is significantly faster than searching through arrays.
- Boilerplate Tolerance: Do you want to manually write logic for adding, removing, and updating items, or use pre-built methods?
- Complexity of Logic: Does your state transition require complex calculations that exceed standard CRUD (Create, Read, Update, Delete) operations?
Comparison of Approaches
| Feature | EntityAdapter | Custom Reducer |
|---|---|---|
| Boilerplate | Low (Generated methods) | High (Hand-written) |
| Performance | Optimized (O(1) lookup) | Variable (O(n) for arrays) |
| Flexibility | Medium (Standardized) | High (Any shape) |
| Learning Curve | Low | Medium-High |
Trade-offs and Engineering Impact
The EntityAdapter enforces a normalized state structure: an array of IDs and a dictionary of entities. This prevents the common mistake of mutating an array directly and ensures that updating one item doesn't require iterating through the entire collection.
However, this abstraction introduces a limitation: it only normalizes the top-level collection. If you have nested entities (e.g., a Project containing Tasks), the adapter will not automatically normalize the tasks. You must either create a second adapter for tasks or write custom reducer logic to handle the nested updates.
Custom reducers provide total control. They are necessary when dealing with composite keys or when the state must be stored in a non-normalized format for specific API requirements. The trade-off is a higher risk of mutability bugs, requiring strict use of the spread operator or libraries like Immer.
Implementation: Using EntityAdapter
This example assumes NgRx 12+ and demonstrates managing a collection of Book entities.
1. State Definition
import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
export interface Book {
id: string;
title: string;
author: string;
}
// createEntityAdapter handles the boilerplate for CRUD operations
export const bookAdapter: EntityAdapter<Book> = createEntityAdapter<Book>();
export interface BookState extends EntityState<Book> {
loading: boolean;
error: string | null;
}
export const initialState: BookState = bookAdapter.getInitialState({
loading: false,
error: null
});
2. Reducer Logic
Run these commands in your reducer file. Ensure you have @ngrx/entity and @ngrx/store installed.
import { createReducer, on } from '@ngrx/store';
import * as BookActions from './book.actions';
export const bookReducer = createReducer(
initialState,
// addOne returns a new state with the entity added
on(BookActions.addBook, (state, { book }) =>
bookAdapter.addOne(book, state)
),
// upsertMany adds new entities or updates existing ones based on ID
on(BookActions.loadBooksSuccess, (state, { books }) =>
bookAdapter.setAll(books, { ...state, loading: false })
),
on(BookActions.removeBook, (state, { id }) =>
bookAdapter.removeOne(id, state)
)
);
3. Selectors
import { createFeatureSelector, createSelector } from '@ngrx/store';
export const selectBookState = createFeatureSelector<BookState>('books');
// getSelectors provides pre-built selectors for ids, entities, and all items
const { selectAll, selectEntities } = bookAdapter.getSelectors(selectBookState);
export const selectAllBooks = selectAll;
export const selectBookById = (id: string) =>
createSelector(selectEntities, (entities) => entities[id]);
Verification and Validation
To ensure the state is behaving correctly and immutably, use these three validation methods:
- DevTools Inspection: Open NgRx DevTools. After a
addBookaction, verify the state contains anidsarray and anentitiesobject. If theentitiesobject is missing, the adapter was not initialized correctly. - Immutability Check: In a unit test, capture the state before and after an action. Assert that
stateBefore !== stateAfter. If they are the same reference, you have a mutation bug. - Performance Benchmark: Load a collection of 5,000 items. Use the
selectBookByIdselector to update a single item. Verify using Chrome DevTools Performance tab that the update does not trigger a full re-render of the entire list.
Rollback Strategy
If you migrate from a custom reducer to EntityAdapter and encounter state mismatch issues, revert the reducer to the previous array-based state and update the selectors to use state.books instead of bookAdapter.getSelectors().
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.