NgRx Entity — Normalised Collection State

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)
Note: NgRx Entity stores data in two parallel structures: 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.
Tip: Use 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.
Warning: 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.

🧠 Test Yourself

The store has 50 posts (from infinite scroll). adapter.setAll(newPagePosts, state) is called with 10 new posts. How many posts are in the store after the call?