Choosing Between NgRx Entity Adapter and Custom Reducers for Entity State
A decision guide that compares NgRx Entity Adapter with hand‑written reducers, shows a compact trade‑off table, and provides a concrete implementation example with verification steps.
22 Jul 2026, 23:48 UTC

Decision and Constraints
When managing a collection of entities in an NgRx store, you must decide whether to rely on the built‑in EntityAdapter utilities or to write a custom reducer from scratch. This guide assumes you are working with Angular 15+, NgRx 15+, TypeScript 4.5+, and that each entity possesses a unique id property. The state should be immutable and normalized (flat map of entities by id).
Option Comparison
| Feature | Entity Adapter | Custom Reducer |
|---|---|---|
| Boilerplate | Low – CRUD operations generated automatically | High – you write every switch case |
| Built‑in Selectors | Yes – selectAll, selectIds, selectEntities etc. | No – you must create your own selectors |
| Performance | Optimized immutable updates via map‑based structure | Depends on your implementation; easy to miss optimizations |
| Flexibility | Limited to predefined CRUD actions | Full control – any business logic, complex updates, or non‑standard shapes |
| Learning Curve | Low – adapter API is small and well‑documented | Moderate to High – requires deeper reducer design knowledge |
Trade‑offs
The Entity Adapter reduces repetitive code and gives you reliable, memoized selectors out of the box. Its downside is that you are confined to the operations it exposes (addOne, upsertOne, removeOne, etc.). If your domain requires actions that do not map cleanly to these primitives – for example, batch updates that depend on other entities, or transformations that need to compute derived values during the reducer step – a custom reducer provides the necessary flexibility, albeit at the cost of more code and a higher chance of introducing bugs.
Concrete Implementation Using Entity Adapter
Below is a typical setup for a Hero entity. The code illustrates the pattern; it is not claimed to have been executed in a specific project.
import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
import { Hero } from '../models/hero.model';
import * as HeroActions from './hero.actions';
// 1. Define the adapter – tells NgRx how to identify entities
const adapter: EntityAdapter<Hero> = createEntityAdapter<Hero>({
// By default assumes a field named `id`; you can override with `selectId`
selectId: (hero) => hero.id
});
// 2. Shape of the slice managed by this reducer
export interface HeroState extends EntityState<Hero> {
loading: boolean;
error: string | null;
}
// 3. Initial state – combines adapter defaults with UI flags
export const initialState: HeroState = adapter.getInitialState({
loading: false,
error: null
});
// 4. Reducer – delegates CRUD actions to the adapter
export function heroReducer(
state = initialState,
action: HeroActions.HeroActionsUnion
): HeroState {
switch (action.type) {
case HeroActions.addHero:
return adapter.addOne(action.payload, state);
case HeroActions.upsertHero:
return adapter.upsertOne(action.payload, state);
case HeroActions.removeHero:
return adapter.removeOne(action.payload.id, state);
case HeroActions.loadHeroes:
return adapter.setAll(action.payload, state);
case HeroActions.setLoading:
return { ...state, loading: action.payload };
case HeroActions.setError:
return { ...state, error: action.payload };
default:
return state;
}
}
// 5. Export memoized selectors for UI consumption
export const {
selectAll: selectAllHeroes,
selectIds: selectHeroIds,
selectEntities: selectHeroEntities,
selectTotal: selectHeroTotal
} = adapter.getSelectors();
Key points:
- The adapter handles immutable updates; you never mutate
statedirectly. - UI‑specific flags (
loading,error) are kept alongside the normalized entity map. - Selectors are created once and reused, guaranteeing reference equality unless the underlying entity map changes.
Validation and Verification
To confirm that the adapter‑based reducer behaves as expected, you can perform the following checks:
- Unit test – Dispatch
addHero,upsertHero, andremoveHeroactions in a test harness and assert that the resulting state matchesadapter.getInitialState()expectations (e.g., entity count changes correctly, IDs are present). - StoreDevtools – Run the application, open NgRx StoreDevtools, and inspect the state slice after each action. Verify that the entity map updates immutably and that selectors return the expected arrays.
- Selector memoization – In a test, call
selectAllHeroestwice with the same state object and assert that the returned reference is identical (expect(selectAllHeroes(state)).toBe(selectAllHeroes(state))). Change the state (e.g., add an entity) and confirm that a new reference is produced.
When to Choose a Custom Reducer Instead
If your use case involves any of the following, consider a custom reducer:
- Complex updates that need to read multiple entities simultaneously (e.g., re‑ordering a list based on a sibling’s value).
- Actions that produce side‑effects inside the reducer (though ideally side‑effects belong in effects, some legacy code may still require them).
- Non‑CRUD operations such as computing aggregates, applying business rules that depend on external services, or merging nested relational data.
- Situations where you want to keep the entity shape denormalized for performance reasons and the adapter’s map‑based structure would add unnecessary indirection.
In those scenarios, you would write a reducer similar to:
export function heroReducer(state = initialState, action: HeroActions) {
switch (action.type) {
case HeroActions.reorderHeroes:
const newOrder = [...state.ids];
// custom logic to move an item
return { ...state, ids: newOrder };
default:
return state;
}
}
Notice the increase in boilerplate and the responsibility to guarantee immutability yourself.
Limitations and Practical Checks
The Entity Adapter works best with flat, normalized state. If your entities contain nested objects that also need to be treated as separate store slices, you will need additional adapters or a custom reducer to handle those relationships. A quick way to verify normalization is to inspect the state in StoreDevtools: each entity should appear only once under the entities map, and no entity should be duplicated elsewhere in the state tree.
Finally, always ensure that every entity possesses a unique id. Duplicate IDs cause the adapter’s internal map to overwrite entries, leading to silent data loss. You can guard against this in development by adding a simple check:
function assertUniqueIds(entities: { [id: string]: Hero }): void { const ids = Object.keys(entities); if (new Set(ids).size !== ids.length) { console.warn('Duplicate IDs detected in entity state'); } }Calling this assertion in a reducer or selector during development helps catch misconfigurations early.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.