NgRx Entity solves the performance problem with collection state. Storing posts as an array (PostSummaryDto[]) means finding a specific post requires an O(n) scan. With normalised entity state, posts are stored in a dictionary keyed by ID (Record<number, PostSummaryDto>) plus an ordered IDs array — lookups are O(1). createEntityAdapter() generates CRUD operations that maintain this normalised structure automatically, and provides ready-made selectors for the collection.
NgRx Entity
import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
import { createFeature, createReducer, createSelector, on } from '@ngrx/store';
// ── Entity state extends EntityState which adds entities{} and ids[] ───────
interface PostsEntityState extends EntityState<PostSummaryDto> {
// id: { [id: number]: PostSummaryDto } ← automatically provided
// ids: number[] ← automatically provided
loading: boolean;
total: number;
error: string | null;
selectedId: number | null;
}
// ── Adapter — manages the normalised collection ────────────────────────────
const adapter: EntityAdapter<PostSummaryDto> = createEntityAdapter<PostSummaryDto>({
selectId: post => post.id, // which property is the ID
sortComparer: (a, b) => // optional: keep array sorted
new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(),
});
const initialState: PostsEntityState = adapter.getInitialState({
loading: false,
total: 0,
error: null,
selectedId: null,
});
// ── Reducer using adapter methods ─────────────────────────────────────────
export const postsEntityFeature = createFeature({
name: 'postsEntity',
reducer: createReducer(
initialState,
on(PostsActions.loadPostsSuccess, (state, { posts, total }) =>
adapter.setAll(posts, { ...state, loading: false, total })
// ↑ replaces all entities, maintaining normalised structure
),
on(PostsActions.createPostSuccess, (state, { post }) =>
adapter.addOne(post as unknown as PostSummaryDto, { ...state, total: state.total + 1 })
),
on(PostsActions.updatePostSuccess, (state, { post }) =>
adapter.updateOne(
{ id: post.id, changes: post as unknown as Partial<PostSummaryDto> },
state
)
),
on(PostsActions.deletePostSuccess, (state, { id }) =>
adapter.removeOne(id, { ...state, total: state.total - 1 })
),
on(PostsActions.upsertPosts, (state, { posts }) =>
adapter.upsertMany(posts, state) // insert or update many at once
),
),
extraSelectors: ({ selectPostsEntityState }) => ({
// Merge adapter selectors with custom ones
...adapter.getSelectors(selectPostsEntityState),
// selectAll, selectEntities, selectIds, selectTotal auto-generated ↑
// Add custom derived selectors:
selectPublished: createSelector(
adapter.getSelectors(selectPostsEntityState).selectAll,
posts => posts.filter(p => p.isPublished)
),
}),
});
// Usage: postsEntityFeature.selectAll → PostSummaryDto[]
// postsEntityFeature.selectEntities → Record<number, PostSummaryDto>
// postsEntityFeature.selectTotal → number (entity count, not API total)
// postsEntityFeature.selectIds → number[]
// postsEntityFeature.selectPublished → PostSummaryDto[] (custom)
entities (a dictionary for O(1) lookups by ID) and ids (an array maintaining the order). selectAll combines both — it maps the ids array through the entities dictionary to produce an ordered array. This gives you both fast random access (state.entities[42]) and ordered iteration (selectAll). The sortComparer option keeps ids sorted; without it, entities appear in insertion order.upsertOne and upsertMany when the insert/update distinction is not known (e.g., receiving data from a WebSocket that might be a new post or an update to an existing one). upsert checks if the ID exists — if it does, it updates; if not, it inserts. This is perfect for real-time data where the client may receive updates for entities it already has cached.selectTotal from NgRx Entity counts the entities currently in the store — it is not the server-side total count. For a paginated API returning 1,000 total posts with 10 per page, after loading page 1, selectTotal returns 10 (entities in store) not 1,000 (server total). Store the server-side total separately in the state (total: number) and select it with the feature’s auto-generated selector. Use setAll() to replace all entities per page load, or upsertMany() for infinite scroll accumulation.Adapter Methods Reference
| Method | What it Does |
|---|---|
| addOne(entity, state) | Add one entity (skip if exists) |
| addMany(entities, state) | Add multiple (skip existing) |
| setOne(entity, state) | Add or replace one entity |
| setAll(entities, state) | Replace entire collection |
| updateOne({id, changes}, state) | Merge partial changes into entity |
| upsertOne(entity, state) | Add or update one entity |
| removeOne(id, state) | Remove entity by ID |
| removeAll(state) | Clear entire collection |
Common Mistakes
Mistake 1 — Confusing selectTotal (entity count) with server total (API total)
❌ Wrong — showing selectTotal as “1,000 posts available”; actually shows 10 (current page in store).
✅ Correct — store server total in separate state property; use EntityAdapter’s selectTotal only for store-count checks.
Mistake 2 — Using addOne when setOne is needed (existing entity not updated)
❌ Wrong — adapter.addOne(updatedPost, state); if post already exists, add is skipped; stale data remains.
✅ Correct — use adapter.upsertOne(updatedPost, state) to add or replace regardless of existence.