← Back to Index

Lesson 6

Entity Management

The Core Idea

Mental model: withEntities gives you a normalised entity collection - like classic NgRx EntityAdapter - with signals for the entity array, ID array, and entity map. Instead of writing repetitive array manipulation code (find-by-id, filter-and-replace, spread-into-new-array), you get type-safe CRUD updaters out of the box. The entities are stored internally as a map for O(1) lookups, but exposed as an array signal for easy template iteration.

In Lesson 5, the Policy Dashboard store managed a policies: Policy[] array in plain state. Every method had to manually filter, map, or spread the array. That's fine for a handful of items, but it gets repetitive and error-prone as operations grow. withEntities eliminates that boilerplate by giving you pre-built updaters for every common collection operation.

Installation

npm install @ngrx/signals

withEntities ships in a sub-entry point: @ngrx/signals/entities. No additional package needed - it's part of the same @ngrx/signals install from Lesson 5.

What withEntities Provides

When you add withEntities<Policy>() to a store, three signals are created automatically:

import { signalStore } from '@ngrx/signals';
import { withEntities } from '@ngrx/signals/entities';

type Policy = {
  id: string;
  holder: string;
  premium: number;
  status: 'active' | 'expired';
};

export const PolicyStore = signalStore(
  withEntities<Policy>()
  //           ^^^^^^^^ — this single line gives you:
  //  store.entities()  → Signal<Policy[]>
  //  store.ids()       → Signal<string[]>
  //  store.entityMap() → Signal<EntityMap<Policy>>
);

How do entities get into the store? You must use entity updaters inside patchState. This isn't a convenience layer over something you could do manually - the internal entityMap and ids state is owned by the entity system, and patchState(store, { entityMap: ... }) won't work. Entity updaters are the only API for writing to entity state.

Here's the simplest path - adding two policies one at a time:

import { patchState } from '@ngrx/signals';
import { addEntity, setAllEntities } from '@ngrx/signals/entities';

// Option A: add individually
patchState(store, addEntity({ id: 'pol-001', holder: 'Alice', premium: 1200, status: 'active' }));
patchState(store, addEntity({ id: 'pol-002', holder: 'Bob',   premium: 800,  status: 'expired' }));

// Option B: set the whole collection at once (e.g. from an API response)
patchState(store, setAllEntities([
  { id: 'pol-001', holder: 'Alice', premium: 1200, status: 'active' },
  { id: 'pol-002', holder: 'Bob',   premium: 800,  status: 'expired' },
]));

Either way, the resulting internal shape is the same:

// Internal state (what withEntities actually stores)
{
  entityMap: {
    'pol-001': { id: 'pol-001', holder: 'Alice', premium: 1200, status: 'active' },
    'pol-002': { id: 'pol-002', holder: 'Bob',   premium: 800,  status: 'expired' }
  },
  ids: ['pol-001', 'pol-002']
}

// What the three signals expose:
store.ids()        // ['pol-001', 'pol-002']           ← direct read of stored state
store.entityMap()  // { 'pol-001': { ... }, ... }      ← direct read of stored state
store.entities()   // [{ id: 'pol-001', ... }, ...]    ← computed view (not stored)

ids and entityMap are direct signals over the actual stored state - they reflect exactly what's in memory. entities is a computed signal that derives the array by mapping ids through entityMap. It doesn't store its own copy of the data; it recalculates (and caches) only when ids or entityMap change.

The collection starts empty - you populate it with entity updaters.

The id property: By default, withEntities expects each entity to have an id property of type string or number. This is the key used for lookups, updates, and removals. If your entity uses a different key (like policyRef or claimId), you need custom ID selection - covered below.

Complete @ngrx/signals/entities API

Here's everything exported from @ngrx/signals/entities. The lesson demonstrates the most common updaters in detail below, but this table serves as your complete reference.

Entity Updaters (passed to patchState)

UpdaterWhat it does
addEntity(entity)Append one entity. Ignored if ID already exists.
addEntities(entity[])Append multiple (takes an array). Skips duplicates.
prependEntity(entity)Insert one entity at the beginning. Ignored if ID exists.
prependEntities(entity[])Insert multiple at the beginning (takes an array). Skips duplicates.
setEntity(entity)Add if new, replace entirely if ID exists (upsert-replace).
setEntities(entity[])Set multiple (takes an array) - adds new, replaces existing.
setAllEntities(entity[])Replace the entire collection (clear + set). Takes an array.
upsertEntity(entity)Add if new, merge partially if ID exists (upsert-merge).
upsertEntities(entity[])Upsert-merge multiple (takes an array).
updateEntity({ id, changes })Partial update one entity by ID. changes can be an object or function.
updateEntities({ predicate, changes })Partial update all entities matching a predicate.
updateAllEntities(changes)Partial update every entity in the collection.
removeEntity(id)Remove one entity by ID.
removeEntities(predicate | id[])Remove by predicate function or array of IDs.
removeAllEntities()Clear the entire collection.
setEntity vs upsertEntity: Both handle "add or update" but differ in how they update. setEntity replaces the whole entity object. upsertEntity merges the provided fields into the existing entity, preserving properties you didn't pass.

Configuration

ExportPurpose
withEntities<T>()Add an entity collection to the store. Accepts optional entityConfig.
entityConfig({ entity, selectId?, collection? })Configure custom ID selection and/or named collections.

Type Exports

TypeWhat it represents
EntityIdstring | number - the allowed ID types.
EntityMap<T>Record<EntityId, T> - the internal map shape.
EntityState<T>The full entity state shape: { entityMap, ids }.
EntityChanges<T>Partial<T> or updater function - what updateEntity accepts as changes.
SelectEntityId<T>Signature for a custom ID selector function.
EntityProps<T>The signal properties added to the store (entities, ids, entityMap).
NamedEntityState<T>Entity state shape for named collections (prefixed properties).
NamedEntityProps<T>Signal properties for named collections (prefixed signals).

Entity Updaters

Entity updaters are functions you pass to patchState. They handle the internal normalisation - you never manually spread arrays or build maps:

import { patchState, signalStore, withMethods } from '@ngrx/signals';
import {
  addEntity,
  addEntities,
  setAllEntities,
  setEntity,
  updateEntity,
  updateAllEntities,
  removeEntity,
  removeEntities,
  withEntities,
} from '@ngrx/signals/entities';
import { inject } from '@angular/core';
import { firstValueFrom } from 'rxjs';

export const PolicyStore = signalStore(
  withEntities<Policy>(),
  withMethods((store, policyService = inject(PolicyService)) => ({

    // Replace the entire collection (initial load)
    async loadAll(): Promise<void> {
      const policies = await firstValueFrom(policyService.getAll());
      patchState(store, setAllEntities(policies));
    },

    // Add a single entity
    addPolicy(policy: Policy): void {
      patchState(store, addEntity(policy));
    },

    // Upsert - add if new, replace if existing
    upsertPolicy(policy: Policy): void {
      patchState(store, setEntity(policy));
    },

    // Partial update by ID
    updatePremium(id: string, premium: number): void {
      patchState(store, updateEntity({ id, changes: { premium } }));
    },

    // Update with a function (access current entity values)
    applyDiscount(id: string, pct: number): void {
      patchState(store, updateEntity({
        id,
        changes: (policy) => ({ premium: policy.premium * (1 - pct) }),
      }));
    },

    // Update all entities
    markAllExpired(): void {
      patchState(store, updateAllEntities({ status: 'expired' as const }));
    },

    // Remove by ID
    removePolicy(id: string): void {
      patchState(store, removeEntity(id));
    },

    // Remove by predicate
    removeExpired(): void {
      patchState(store, removeEntities(({ status }) => status === 'expired'));
    },
  }))
);

Key points:

Ad

Custom ID Selection

By default, entities must have an id property. If your domain model uses a different key, configure it with entityConfig:

import { signalStore, type } from '@ngrx/signals';
import { entityConfig, withEntities } from '@ngrx/signals/entities';

type Claim = {
  claimRef: string;
  amount: number;
  status: 'open' | 'settled' | 'denied';
  policyId: string;
};

const claimConfig = entityConfig({
  entity: type<Claim>(),
  selectId: (claim) => claim.claimRef,
});

export const ClaimsStore = signalStore(
  withEntities(claimConfig),
  withMethods((store) => ({
    removeClaim(claimRef: string): void {
      patchState(store, removeEntity(claimRef));
    },
  }))
);

selectId tells the entity system which property to use as the unique key. All updaters then use that key for lookups.

Named Collections

Named collections are mandatory when a single store has more than one withEntities call. Without names, both would create entities, ids, and entityMap signals - a collision. The alternative is separate stores (one per entity type), in which case neither needs a named collection.

Use named collections when you want a single store to own multiple entity types together:

import { signalStore, type } from '@ngrx/signals';
import { withEntities, addEntity, removeEntity } from '@ngrx/signals/entities';

export const DashboardStore = signalStore(
  withEntities({ entity: type<Policy>(), collection: 'policy' }),
  withEntities({ entity: type<Claim>(), collection: 'claim' }),
  withMethods((store) => ({
    addPolicy(policy: Policy): void {
      patchState(store, addEntity(policy, { collection: 'policy' }));
    },
    addClaim(claim: Claim): void {
      patchState(store, addEntity(claim, { collection: 'claim' }));
    },
    removeClaim(id: string): void {
      patchState(store, removeEntity(id, { collection: 'claim' }));
    },
  }))
);

Named collections create prefixed signals:

Every updater needs the { collection: 'name' } option so it knows which collection to operate on.

Combining with withComputed

Derived views over your entities work exactly like Lesson 5 - withComputed reads the entity signals:

import { computed } from '@angular/core';

export const PolicyStore = signalStore(
  withEntities<Policy>(),
  withComputed(({ entities }) => ({
    activePolicies: computed(() =>
      entities().filter(p => p.status === 'active')
    ),
    totalPremium: computed(() =>
      entities().reduce((sum, p) => sum + p.premium, 0)
    ),
    policyCount: computed(() => entities().length),
  }))
);

Computed signals derived from entities() automatically re-evaluate when any entity is added, removed, or updated. They're lazy and cached.

Full Example: Policy Dashboard Store

import { computed, inject } from '@angular/core';
import { signalStore, withState, withComputed, withMethods, withHooks, patchState } from '@ngrx/signals';
import { withEntities, setAllEntities, addEntity, removeEntity, updateEntity } from '@ngrx/signals/entities';
import { firstValueFrom } from 'rxjs';

export interface Policy {
  id: string;
  holder: string;
  premium: number;
  status: 'active' | 'expired' | 'pending';
  expiryDate: string;
}

export const PolicyDashboardStore = signalStore(
  { providedIn: 'root' },
  withEntities<Policy>(),
  withState({ isLoading: false, error: null as string | null }),
  withComputed(({ entities }) => ({
    activePolicies: computed(() => entities().filter(p => p.status === 'active')),
    totalPremium: computed(() => entities().reduce((sum, p) => sum + p.premium, 0)),
    policyCount: computed(() => entities().length),
  })),
  withMethods((store, policyService = inject(PolicyService)) => ({
    async loadPolicies(): Promise<void> {
      patchState(store, { isLoading: true, error: null });
      try {
        const policies = await firstValueFrom(policyService.getAll());
        patchState(store, setAllEntities(policies), { isLoading: false });
      } catch (e) {
        patchState(store, { isLoading: false, error: 'Failed to load policies' });
      }
    },
    addPolicy(policy: Policy): void {
      patchState(store, addEntity(policy));
    },
    removePolicy(id: string): void {
      patchState(store, removeEntity(id));
    },
    updateStatus(id: string, status: Policy['status']): void {
      patchState(store, updateEntity({ id, changes: { status } }));
    },
  })),
  withHooks({
    onInit(store) {
      store.loadPolicies();
    },
  })
);

Note how withEntities and withState coexist - entities handle the collection, withState handles the metadata (loading, error). They compose naturally.

Classic NgRx Comparison

Classic NgRx (@ngrx/entity)

// adapter, state interface, initial state, reducer, selectors - 5 files
const adapter = createEntityAdapter<Policy>();
interface PolicyState extends EntityState<Policy> { isLoading: boolean; }
const initialState = adapter.getInitialState({ isLoading: false });

const reducer = createReducer(initialState,
  on(loadSuccess, (state, { policies }) => adapter.setAll(policies, { ...state, isLoading: false })),
  on(addPolicy, (state, { policy }) => adapter.addOne(policy, state)),
  on(removePolicy, (state, { id }) => adapter.removeOne(id, state)),
);

const { selectAll, selectEntities } = adapter.getSelectors(selectPolicyState);

SignalStore (withEntities)

// Everything in one file - no adapter, no selectors, no reducer
export const PolicyStore = signalStore(
  withEntities<Policy>(),
  withState({ isLoading: false }),
  withMethods((store) => ({
    setAll(policies: Policy[]) { patchState(store, setAllEntities(policies), { isLoading: false }); },
    add(policy: Policy) { patchState(store, addEntity(policy)); },
    remove(id: string) { patchState(store, removeEntity(id)); },
  }))
);
// store.entities(), store.ids(), store.entityMap() - no selectors file

Same normalised collection, same operations. No adapter creation, no EntityState interface, no selectors file, no reducer.

Quiz

What signals does withEntities<Policy>() automatically provide?

policies(), policyIds(), policyMap()
entities(), ids(), entityMap()
items(), keys(), lookup()

How do you remove all expired policies from an entity store?

patchState(store, { entities: store.entities().filter(...) })
patchState(store, removeEntities(({ status }) => status === 'expired'))
store.entities.update(list => list.filter(...))

Your entity has policyRef instead of id. How do you configure this?

withEntities<Policy>({ idKey: 'policyRef' })
withEntities(entityConfig({ entity: type<Policy>(), selectId: (p) => p.policyRef }))
withEntities<Policy>({ selectId: (p) => p.policyRef })

Try It Yourself

Exercise 1: Claims Store

Build a ClaimsStore using withEntities. The Claim type uses claimRef as its unique key. Include a computed totalAmount and a loadAll() method:

type Claim = {
  claimRef: string;
  policyId: string;
  amount: number;
  status: 'open' | 'settled' | 'denied';
};
// TODO: Build ClaimsStore with entityConfig, computed totalAmount, loadAll()
import { computed, inject } from '@angular/core';
import { signalStore, withComputed, withMethods, patchState } from '@ngrx/signals';
import { entityConfig, type, withEntities, setAllEntities, updateEntity } from '@ngrx/signals/entities';
import { firstValueFrom } from 'rxjs';

const claimConfig = entityConfig({
  entity: type<Claim>(),
  selectId: (claim) => claim.claimRef,
});

export const ClaimsStore = signalStore(
  withEntities(claimConfig),
  withComputed(({ entities }) => ({
    totalAmount: computed(() =>
      entities().reduce((sum, c) => sum + c.amount, 0)
    ),
  })),
  withMethods((store, claimsService = inject(ClaimsService)) => ({
    async loadAll(): Promise<void> {
      const claims = await firstValueFrom(claimsService.getAll());
      patchState(store, setAllEntities(claims, claimConfig));
    },
    settleClaim(claimRef: string): void {
      patchState(store, updateEntity(
        { id: claimRef, changes: { status: 'settled' as const } },
        claimConfig
      ));
    },
  }))
);

When using entityConfig, pass it to updaters so they know which ID selector to use.

Exercise 2: Bulk Update

Add a method that sets status to 'pending' for all policies where premium exceeds a threshold:

// TODO: flagHighPremium(threshold: number) - updates matching policies
flagHighPremium(threshold: number): void {
  const toFlag = store.entities().filter(p => p.premium > threshold);
  patchState(
    store,
    ...toFlag.map(p => updateEntity({ id: p.id, changes: { status: 'pending' as const } }))
  );
}

No built-in "update where predicate" updater - filter the entities, map to updateEntity calls, spread into patchState.

Exercise 3: Multiple Collections

Create a store with named 'policy' and 'claim' collections. Add a computed totalExposure (sum of claim amounts for active policies):

// TODO: DashboardStore with two named collections and totalExposure computed
import { computed } from '@angular/core';
import { signalStore, type, withComputed } from '@ngrx/signals';
import { withEntities } from '@ngrx/signals/entities';

export const DashboardStore = signalStore(
  withEntities({ entity: type<Policy>(), collection: 'policy' }),
  withEntities({ entity: type<Claim>(), collection: 'claim' }),
  withComputed(({ policyEntities, claimEntities }) => {
    const activePolicyIds = computed(() =>
      new Set(policyEntities().filter(p => p.status === 'active').map(p => p.id))
    );
    return {
      totalExposure: computed(() =>
        claimEntities()
          .filter(c => activePolicyIds().has(c.policyId))
          .reduce((sum, c) => sum + c.amount, 0)
      ),
    };
  })
);

Named collections prefix signal names: policyEntities(), claimEntities(). The intermediate activePolicyIds computed avoids rebuilding the Set on every claim filter iteration.

Your Tangible Win

You can now manage normalised entity collections in SignalStore without writing manual array manipulation. withEntities gives you the signals (entities, ids, entityMap) and the updaters (add, remove, update, set) that replace classic NgRx's EntityAdapter. Next lesson, we'll add rxMethod - the bridge for when your store methods need long-lived RxJS pipelines rather than simple async/await.

Something unclear? Ask me follow-up questions - I'm your teacher and can clarify anything, adjust examples, or go deeper on any concept.