Simplifying Large Todo Lists with NgRx Entity Adapter
When a Todo app grows, managing a list of items can become a nightmare. NgRx’s Entity Adapter normalizes the state, slashes boilerplate, and keeps selectors fast. This blog walks through a concrete example, highlights trade‑offs, and shows how to verify the results.
14 Dec 2025, 23:05 UTC

Concrete Problem: The Todo List Bloat
In a typical Angular Todo application, each new item adds a row to a table. If you keep all items in a simple array inside the NgRx store, you end up with:
- O(n) lookups when you need to find an item by ID.
- Re‑creating the whole array for every add, update, or delete.
- Selectors that are hard to write and maintain.
- Unit tests that have to manually spread arrays and objects.
When the list grows to hundreds or thousands of items, these problems become performance bottlenecks and code maintenance nightmares.
Thesis: Use NgRx Entity Adapter to Normalize and Streamline CRUD
The Entity Adapter turns a flat array into a normalized shape: a map of entities keyed by ID and a parallel array of IDs. This structure gives you O(1) lookups, eliminates the need to copy arrays, and provides a set of ready‑made reducer and selector helpers. The result is declarative code that is easier to read, test, and debug.
Section 1 – Normalizing State with the Adapter
Define the Todo model and feature state interface first:
export interface Todo {
id: string;
title: string;
completed: boolean;
}
export interface TodosState {
ids: string[];
entities: { [id: string]: Todo };
loading: boolean;
}
Instantiate the adapter and the initial state:
import { createEntityAdapter, EntityState, EntityAdapter } from '@ngrx/entity';
export const adapter: EntityAdapter<Todo> = createEntityAdapter<Todo>();
export const initialState: TodosState = adapter.getInitialState({
loading: false,
});
Notice how the adapter’s getInitialState already supplies the ids array and entities map. This eliminates manual initialisation.
Section 2 – Declarative Reducers with Adapter API
Instead of writing a reducer that manually spreads arrays, call the adapter’s CRUD methods. For example:
import { createReducer, on } from '@ngrx/store';
import { addTodo, updateTodo, deleteTodo } from './todos.actions';
export const todosReducer = createReducer(
initialState,
on(addTodo, (state, { todo }) => adapter.addOne(todo, state)),
on(updateTodo, (state, { todo }) => adapter.updateOne({ id: todo.id, changes: todo }, state)),
on(deleteTodo, (state, { id }) => adapter.removeOne(id, state))
);
Each adapter method returns a new state with the change applied. No manual object spreads, no risk of accidental mutation, and the code reads like a plain English description of the operation.
Section 3 – Ready‑Made Selectors and Memoization
The adapter also supplies selectors that work out of the box. Create a feature selector, then compose the adapter selectors:
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { adapter } from './todos.state';
export const selectTodosState = createFeatureSelector<TodosState>('todos');
export const { selectAll, selectEntities, selectIds } = adapter.getSelectors(selectTodosState);
// Custom selector: completed todos
export const selectCompletedTodos = createSelector(
selectAll,
(todos: Todo[]) => todos.filter(t => t.completed)
);
Because the state is normalized, selectAll returns a plain array of Todo objects, and selectEntities gives a map for O(1) lookup. Store DevTools will show the state shape as:
{
"todos": {
"ids": ["1", "2", "3"],
"entities": {
"1": { "id": "1", "title": "Buy milk", "completed": false },
"2": { "id": "2", "title": "Write blog", "completed": true },
"3": { "id": "3", "title": "Plan trip", "completed": false }
},
"loading": false
}
}
Section 4 – Trade‑Offs and Limitations
- Small Lists: If you only ever have a handful of items, the overhead of a map and ID array may feel unnecessary.
- Deeply Nested State: The adapter is designed for flat collections. If your Todo items contain nested structures that also need normalization, you may need multiple adapters or custom reducers.
- Adapter‑State Mismatch: The adapter’s type must match the state interface. A typo in the interface can silently corrupt the normalized shape because the adapter will still return an object that passes type checks.
- Learning Curve: New developers may find the normalized shape unintuitive compared to a simple array.
Verification Checklist
- Unit test the reducer:
it('should add a todo', () => { const state = todosReducer(initialState, addTodo({ todo: { id: 'a', title: 'Test', completed: false } })); expect(state.ids).toContain('a'); expect(state.entities['a'].title).toBe('Test'); }); - Test the selectors with a mock store:
const store = mockStore({ todos: state }); expect(store.selectSnapshot(selectAll)).toEqual([/* expected array */]); - Use Store DevTools to confirm the state shape after dispatching actions. The
entitiesmap should update instantly, and selectors should recompute only when necessary.
Actionable Closing – Start Using the Adapter Today
1. Create an adapter for each feature that deals with a list.
2. Replace manual reducers with the adapter’s CRUD methods.
3. Replace manual selectors with the adapter’s getSelectors helpers.
4. Write a couple of unit tests to confirm the normalized shape.
5. Observe performance improvements in the browser dev tools – especially with large lists.
By following these steps, you’ll turn a fragile, array‑centric state into a robust, testable, and performant foundation for your Angular application.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.