| |

Angular 83 🅰️ NgRx Effects and Entity

⏱️ Estimated read: 20 min | |

The previous chapter introduced NgRx and covered the core building blocks: store, actions, reducers, and selectors. Effects were introduced briefly — enough to show how HTTP calls are wired into the store. This chapter goes deeper. Effects are where NgRx handles everything that a reducer cannot: asynchronous operations, retries, cancellation, race conditions, and coordination between multiple actions. Entity is where NgRx handles collections: lists of users, orders, products, or any other array of objects with IDs.

Effects and Entity together cover the two concerns that make large NgRx applications manageable. Effects centralize the imperative logic — the side effects — so that components remain declarative. Entity centralizes the collection logic — the CRUD operations — so that every feature does not reimplement the same add, update, and remove patterns.

Key point: An effect is an injectable class that listens for actions, performs side effects, and dispatches new actions. It is the only place in an NgRx application where impurity is allowed. Entity is an adapter that manages a collection of entities with standard CRUD operations and a normalized state shape: { ids: [], entities: {} }. The adapter provides the reducer helpers, the selectors, and the mutations. Together, they turn a feature from dozens of hand-written reducer cases into a few effects and a single entity adapter .


Why effects and entity matter

The reducer is a pure function. It cannot call an HTTP service. It cannot read from local storage. It cannot wait for a timer. Anything that the reducer cannot do is the responsibility of an effect. Anything that manages a collection of items is the responsibility of Entity.

The side-effect problem. Every feature that loads data from a server needs an effect. The effect listens for a load action, calls the service, and dispatches a loadSuccess or loadFailure action. Without effects, this logic lives in components or services, and the store becomes inconsistent. An effect centralizes the pattern so that every feature follows the same structure.

The concurrency problem. When a user types in a search box, each keystroke triggers a new search. Without careful handling, the responses can arrive out of order and the store can end up with stale data. switchMap cancels the previous request when a new one arrives. concatMap queues them. exhaustMap ignores new ones while one is running. The choice of operator is the concurrency policy, and it belongs in the effect.

The collection problem. Almost every feature stores a list of items. Loading the list, adding an item, updating an item, removing an item, selecting a subset by ID, tracking the loading state, tracking the error state — these are the same operations in every feature. Entity provides the adapter that generates the reducer cases, the selectors, and the mutations for free. The state shape { ids: [], entities: {} } is normalized: lookups by ID are O(1) instead of O(n) .

The testing problem. Effects are tested with marble diagrams, which let you describe the input actions and the expected output actions as timelines. Entity’s adapter methods are pure functions and can be tested without setup. Both are more testable than hand-written imperative code.

The trade-off. Effects add indirection. A component dispatches an action, an effect picks it up, an HTTP call is made, a success action is dispatched, a reducer updates the state, a selector emits the new value, and the component re-renders. That is seven steps for a single data load. The indirection is the point — it makes the flow traceable — but it is also the cost. For simple features, the service-and-signals pattern is faster to write and easier to follow.


a. Effects in Depth

An effect is an injectable class with one or more properties created by createEffect. Each effect listens to the Actions observable, filters for specific action types with ofType, performs a side effect, and returns an observable of new actions to dispatch.

The basic effect for a data load:

import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { map, mergeMap, catchError } from 'rxjs/operators';
import * as UserActions from './user.actions';
import { UserService } from './user.service';

@Injectable()
export class UserEffects {
  private actions$ = inject(Actions);
  private userService = inject(UserService);

  loadUsers$ = createEffect(() =>
    this.actions$.pipe(
      ofType(UserActions.loadUsers),
      mergeMap(() =>
        this.userService.getAll().pipe(
          map((users) => UserActions.loadUsersSuccess({ users })),
          catchError((error) =>
            of(UserActions.loadUsersFailure({ error: error.message }))
          )
        )
      )
    )
  );
}

The loadUsers$ effect listens for the loadUsers action. When it fires, the effect calls the service’s getAll() method, maps the response to a loadUsersSuccess action, and handles errors by returning a loadUsersFailure action. The mergeMap operator flattens the inner observable and allows concurrent loads.

The choice of flattening operator is the concurrency policy:

mergeMap runs all inner observables in parallel. Use it for independent operations where order does not matter and all requests should complete.

concatMap runs inner observables sequentially, waiting for each to complete before starting the next. Use it for operations that must happen in order, like saving a series of changes.

switchMap cancels the previous inner observable when a new one arrives. Use it for search-as-you-type, where only the latest request matters.

exhaustMap ignores new outer emissions while an inner observable is active. Use it for login forms and other actions that should not be triggered twice.

An effect that does not dispatch an action uses the { dispatch: false } option:

logActions$ = createEffect(
  () =>
    this.actions$.pipe(
      tap((action) => console.log('Action:', action.type))
    ),
  { dispatch: false }
);

Non-dispatching effects are used for logging, analytics, and side effects that should not modify the store. The tap operator is the typical choice because it observes without transforming.

Effects can listen to multiple action types by passing multiple actions to ofType:

clearOnLogout$ = createEffect(() =>
  this.actions$.pipe(
    ofType(UserActions.logout, UserActions.sessionExpired),
    map(() => UserActions.clearUsers())
  )
);

This effect fires on either logout or sessionExpired and dispatches clearUsers. It is a single effect that handles two related triggers.

The concatLatestFrom operator lets an effect read from the store without subscribing to it:

saveUser$ = createEffect(() =>
  this.actions$.pipe(
    ofType(UserActions.saveUser),
    concatLatestFrom(() => this.store.select(selectCurrentUser)),
    mergeMap(([action, currentUser]) =>
      this.userService.save(action.user, currentUser.id).pipe(
        map(() => UserActions.saveUserSuccess()),
        catchError((error) =>
          of(UserActions.saveUserFailure({ error: error.message }))
        )
      )
    )
  )
);

The concatLatestFrom reads the current value from the store at the moment the action fires. The [action, currentUser] tuple in the mergeMap contains both the original action and the store value. This is the standard way for effects to access state without creating a dependency that causes the effect to re-run on every state change.


b. Entity: The Collection Adapter

The @ngrx/entity package provides an adapter that manages a collection of entities with a normalized state shape. The state is { ids: EntityId[]; entities: Record<EntityId, T> }. The ids array preserves order; the entities object provides O(1) lookups by ID .

The adapter is created with createEntityAdapter. It takes an optional configuration with selectId (defaults to id) and sortComparer (defaults to no sorting):

import { createEntityAdapter, EntityState } from '@ngrx/entity';
import { User } from './user.model';

export const userAdapter = createEntityAdapter<User>({
  selectId: (user) => user.id,
  sortComparer: (a, b) => a.name.localeCompare(b.name),
});

export interface UserState extends EntityState<User> {
  loading: boolean;
  error: string | null;
  selectedId: string | null;
}

export const initialUserState: UserState = userAdapter.getInitialState({
  loading: false,
  error: null,
  selectedId: null,
});

The getInitialState method creates the initial state with the ids and entities properties plus the additional feature-specific properties. The returned state has the shape { ids: [], entities: {}, loading: false, error: null, selectedId: null }.

The adapter provides methods for every CRUD operation. These methods are called from the reducer:

import { createReducer, on } from '@ngrx/store';
import { userAdapter, initialUserState } from './user.state';
import * as UserActions from './user.actions';

export const userReducer = createReducer(
  initialUserState,
  on(UserActions.loadUsers, (state) => ({
    ...state,
    loading: true,
    error: null,
  })),
  on(UserActions.loadUsersSuccess, (state, { users }) =>
    userAdapter.setAll(users, { ...state, loading: false })
  ),
  on(UserActions.loadUsersFailure, (state, { error }) => ({
    ...state,
    loading: false,
    error,
  })),
  on(UserActions.addUser, (state, { user }) =>
    userAdapter.addOne(user, state)
  ),
  on(UserActions.updateUser, (state, { update }) =>
    userAdapter.updateOne(update, state)
  ),
  on(UserActions.removeUser, (state, { id }) =>
    userAdapter.removeOne(id, state)
  ),
  on(UserActions.selectUser, (state, { id }) => ({
    ...state,
    selectedId: id,
  }))
);

The adapter methods are:

MethodPurpose
addOne(entity, state)Add one entity
addMany(entities, state)Add multiple entities
setOne(entity, state)Add or replace one entity
setMany(entities, state)Add or replace multiple
setAll(entities, state)Replace the entire collection
updateOne(update, state)Update one entity
updateMany(updates, state)Update multiple entities
upsertOne(entity, state)Add or update one entity
upsertMany(entities, state)Add or update multiple
removeOne(id, state)Remove one entity
removeMany(ids, state)Remove multiple entities
removeAll(state)Clear the collection
mapOne(map, state)Transform one entity
map(collection, state)Replace with a transformed collection

The updateOne and updateMany methods take an Update<T> object with an id and a changes partial. The upsertOne method takes a complete entity and adds it if it does not exist, updates it if it does.

The adapter also provides selectors that read from the entity state:

export const {
  selectIds,
  selectEntities,
  selectAll,
  selectTotal,
} = userAdapter.getSelectors();

// Usage with the feature selector
export const selectUserState = createFeatureSelector<UserState>('users');

export const selectAllUsers = createSelector(
  selectUserState,
  selectAll
);

export const selectUserEntities = createSelector(
  selectUserState,
  selectEntities
);

export const selectUserById = (id: string) =>
  createSelector(selectUserEntities, (entities) => entities[id]);

export const selectSelectedUser = createSelector(
  selectUserState,
  selectAllUsers,
  (state, users) => users.find(u => u.id === state.selectedId) ?? null
);

The selectAll selector returns the entities in the order specified by ids. The selectEntities selector returns the raw dictionary. The selectTotal selector returns the count. The selectIds selector returns the ID array.


c. Combining Effects and Entity

The complete pattern combines the entity adapter in the reducer with effects that handle the async operations. The effects dispatch success actions that carry the entities, and the reducer uses the adapter to update the state.

// user.actions.ts
import { createAction, props } from '@ngrx/store';
import { Update } from '@ngrx/entity';
import { User } from './user.model';

export const loadUsers = createAction('[User] Load Users');
export const loadUsersSuccess = createAction(
  '[User] Load Users Success',
  props<{ users: User[] }>()
);
export const loadUsersFailure = createAction(
  '[User] Load Users Failure',
  props<{ error: string }>()
);

export const addUser = createAction(
  '[User] Add User',
  props<{ user: User }>()
);
export const updateUser = createAction(
  '[User] Update User',
  props<{ update: Update<User> }>()
);
export const removeUser = createAction(
  '[User] Remove User',
  props<{ id: string }>()
);
export const selectUser = createAction(
  '[User] Select User',
  props<{ id: string }>()
);

The Update<User> type from @ngrx/entity is { id: string; changes: Partial<User> }. The reducer uses userAdapter.updateOne(update, state) to apply the change.

The effect for adding a user calls the service, receives the created user with the server-assigned ID, and dispatches the addUser action with the full entity:

addUser$ = createEffect(() =>
  this.actions$.pipe(
    ofType(UserActions.addUserRequest),
    mergeMap(({ user }) =>
      this.userService.create(user).pipe(
        map((created) => UserActions.addUser({ user: created })),
        catchError((error) =>
          of(UserActions.addUserFailure({ error: error.message }))
        )
      )
    )
  )
);

The pattern is consistent: an action requests the operation, the effect performs the HTTP call, and a success action carries the result to the reducer. The reducer uses the adapter to update the state. The selectors emit the new values. The component re-renders.


Complete Example Session

This session builds a complete user management feature with effects and entity.

// ============================================
// PART 1: THE MODEL
// ============================================

// user.model.ts
export interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

// ============================================
// PART 2: THE ENTITY ADAPTER AND STATE
// ============================================

// user.state.ts
import { createEntityAdapter, EntityState } from '@ngrx/entity';
import { User } from './user.model';

export const userAdapter = createEntityAdapter<User>({
  selectId: (user) => user.id,
  sortComparer: (a, b) => a.name.localeCompare(b.name),
});

export interface UserState extends EntityState<User> {
  loading: boolean;
  error: string | null;
  selectedId: string | null;
}

export const initialUserState: UserState = userAdapter.getInitialState({
  loading: false,
  error: null,
  selectedId: null,
});

// ============================================
// PART 3: THE ACTIONS
// ============================================

// user.actions.ts
import { createAction, props } from '@ngrx/store';
import { Update } from '@ngrx/entity';
import { User } from './user.model';

export const loadUsers = createAction('[User] Load Users');
export const loadUsersSuccess = createAction(
  '[User] Load Users Success',
  props<{ users: User[] }>()
);
export const loadUsersFailure = createAction(
  '[User] Load Users Failure',
  props<{ error: string }>()
);

export const addUserRequest = createAction(
  '[User] Add User Request',
  props<{ user: Omit<User, 'id'> }>()
);
export const addUser = createAction(
  '[User] Add User',
  props<{ user: User }>()
);

export const updateUser = createAction(
  '[User] Update User',
  props<{ update: Update<User> }>()
);

export const removeUser = createAction(
  '[User] Remove User',
  props<{ id: string }>()
);

export const selectUser = createAction(
  '[User] Select User',
  props<{ id: string }>()
);

// ============================================
// PART 4: THE REDUCER
// ============================================

// user.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { userAdapter, initialUserState } from './user.state';
import * as UserActions from './user.actions';

export const userReducer = createReducer(
  initialUserState,
  on(UserActions.loadUsers, (state) => ({
    ...state,
    loading: true,
    error: null,
  })),
  on(UserActions.loadUsersSuccess, (state, { users }) =>
    userAdapter.setAll(users, { ...state, loading: false })
  ),
  on(UserActions.loadUsersFailure, (state, { error }) => ({
    ...state,
    loading: false,
    error,
  })),
  on(UserActions.addUser, (state, { user }) =>
    userAdapter.addOne(user, state)
  ),
  on(UserActions.updateUser, (state, { update }) =>
    userAdapter.updateOne(update, state)
  ),
  on(UserActions.removeUser, (state, { id }) =>
    userAdapter.removeOne(id, state)
  ),
  on(UserActions.selectUser, (state, { id }) => ({
    ...state,
    selectedId: id,
  }))
);

// ============================================
// PART 5: THE SELECTORS
// ============================================

// user.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { userAdapter, UserState } from './user.state';

export const selectUserState = createFeatureSelector<UserState>('users');

const { selectAll, selectEntities, selectTotal, selectIds } =
  userAdapter.getSelectors();

export const selectAllUsers = createSelector(selectUserState, selectAll);
export const selectUserEntities = createSelector(selectUserState, selectEntities);
export const selectUserTotal = createSelector(selectUserState, selectTotal);
export const selectUserLoading = createSelector(selectUserState, (s) => s.loading);
export const selectUserError = createSelector(selectUserState, (s) => s.error);

export const selectUserById = (id: string) =>
  createSelector(selectUserEntities, (entities) => entities[id]);

export const selectSelectedUser = createSelector(
  selectUserState,
  selectUserEntities,
  (state, entities) => state.selectedId ? entities[state.selectedId] ?? null : null
);

export const selectAdminUsers = createSelector(
  selectAllUsers,
  (users) => users.filter(u => u.role === 'admin')
);

// ============================================
// PART 6: THE SERVICE
// ============================================

// user.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { User } from './user.model';

@Injectable({ providedIn: 'root' })
export class UserService {
  private http = inject(HttpClient);
  private baseUrl = '/api/users';

  getAll(): Observable<User[]> {
    return this.http.get<User[]>(this.baseUrl);
  }

  create(user: Omit<User, 'id'>): Observable<User> {
    return this.http.post<User>(this.baseUrl, user);
  }

  update(id: string, changes: Partial<User>): Observable<User> {
    return this.http.patch<User>(`${this.baseUrl}/${id}`, changes);
  }

  remove(id: string): Observable<void> {
    return this.http.delete<void>(`${this.baseUrl}/${id}`);
  }
}

// ============================================
// PART 7: THE EFFECTS
// ============================================

// user.effects.ts
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { map, mergeMap, catchError, concatMap } from 'rxjs/operators';
import * as UserActions from './user.actions';
import { UserService } from './user.service';

@Injectable()
export class UserEffects {
  private actions$ = inject(Actions);
  private userService = inject(UserService);

  loadUsers$ = createEffect(() =>
    this.actions$.pipe(
      ofType(UserActions.loadUsers),
      mergeMap(() =>
        this.userService.getAll().pipe(
          map((users) => UserActions.loadUsersSuccess({ users })),
          catchError((error) =>
            of(UserActions.loadUsersFailure({ error: error.message }))
          )
        )
      )
    )
  );

  addUser$ = createEffect(() =>
    this.actions$.pipe(
      ofType(UserActions.addUserRequest),
      concatMap(({ user }) =>
        this.userService.create(user).pipe(
          map((created) => UserActions.addUser({ user: created })),
          catchError(() => of(UserActions.loadUsersFailure({ error: 'Add failed' })))
        )
      )
    )
  );

  removeUser$ = createEffect(() =>
    this.actions$.pipe(
      ofType(UserActions.removeUser),
      concatMap(({ id }) =>
        this.userService.remove(id).pipe(
          map(() => UserActions.removeUser({ id })),
          catchError(() => of(UserActions.loadUsersFailure({ error: 'Remove failed' })))
        )
      )
    ),
    { dispatch: false }
  );
}

// ============================================
// PART 8: THE COMPONENT
// ============================================

// user-list.component.ts
import { Component, inject, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import * as UserActions from './user.actions';
import {
  selectAllUsers,
  selectUserLoading,
  selectUserError,
  selectUserTotal,
} from './user.selectors';

@Component({
  selector: 'user-list',
  template: `
    <p>Total users: {{ total() }}</p>
    @if (loading()) {
      <p>Loading...</p>
    }
    @if (error()) {
      <p class="error">{{ error() }}</p>
    }
    <ul>
      @for (user of users(); track user.id) {
        <li>
          {{ user.name }} ({{ user.role }})
          <button (click)="remove(user.id)">Delete</button>
        </li>
      }
    </ul>
  `,
})
export class UserListComponent implements OnInit {
  private store = inject(Store);

  users = this.store.selectSignal(selectAllUsers);
  total = this.store.selectSignal(selectUserTotal);
  loading = this.store.selectSignal(selectUserLoading);
  error = this.store.selectSignal(selectUserError);

  ngOnInit() {
    this.store.dispatch(UserActions.loadUsers());
  }

  remove(id: string) {
    this.store.dispatch(UserActions.removeUser({ id }));
  }
}

// ============================================
// PART 9: THE APPLICATION CONFIG
// ============================================

// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { userReducer } from './user.reducer';
import { UserEffects } from './user.effects';

export const appConfig: ApplicationConfig = {
  providers: [
    provideStore({ users: userReducer }),
    provideEffects([UserEffects]),
  ],
};

// ============================================
// PART 10: THE TEST
// ============================================

// user.reducer.spec.ts
import { userReducer } from './user.reducer';
import { initialUserState } from './user.state';
import * as UserActions from './user.actions';
import { User } from './user.model';

describe('userReducer', () => {
  const mockUser: User = { id: '1', name: 'Alice', email: 'a@b.c', role: 'admin' };

  it('loads users with setAll', () => {
    const action = UserActions.loadUsersSuccess({ users: [mockUser] });
    const state = userReducer(initialUserState, action);
    expect(state.ids).toEqual(['1']);
    expect(state.entities['1']).toEqual(mockUser);
  });

  it('adds one user', () => {
    const action = UserActions.addUser({ user: mockUser });
    const state = userReducer(initialUserState, action);
    expect(state.ids).toContain('1');
  });

  it('updates one user', () => {
    const loaded = userReducer(
      initialUserState,
      UserActions.loadUsersSuccess({ users: [mockUser] })
    );
    const action = UserActions.updateUser({
      update: { id: '1', changes: { name: 'Alicia' } },
    });
    const state = userReducer(loaded, action);
    expect(state.entities['1']?.name).toBe('Alicia');
  });

  it('removes one user', () => {
    const loaded = userReducer(
      initialUserState,
      UserActions.loadUsersSuccess({ users: [mockUser] })
    );
    const action = UserActions.removeUser({ id: '1' });
    const state = userReducer(loaded, action);
    expect(state.ids).not.toContain('1');
  });
});

The ten parts cover the model, the entity adapter and state, the actions, the reducer, the selectors, the service, the effects, the component, the application config, and the reducer test.


Quick Reference

The Effect Operators

OperatorConcurrencyUse Case
mergeMapParallelIndependent loads
concatMapSequentialOrdered saves
switchMapCancel previousSearch-as-you-type
exhaustMapIgnore while busyLogin, submit
catchErrorError handlingEvery effect
mapTransformResponse → action

The Entity Adapter Methods

MethodPurpose
addOneAdd one
addManyAdd many
setOneAdd or replace one
setAllReplace all
updateOneUpdate one
updateManyUpdate many
upsertOneAdd or update one
removeOneRemove one
removeManyRemove many
removeAllClear
mapOneTransform one
mapTransform all

The Entity Selectors

SelectorReturns
selectIdsEntityId[]
selectEntitiesRecord<EntityId, T>
selectAllT[]
selectTotalnumber

The Effect Patterns

PatternPurpose
ofType(action)Filter by action
concatLatestFrom(() => store.select(sel))Read store value
{ dispatch: false }Non-dispatching effect
of(successAction)Emit an action
catchError(() => of(failureAction))Convert error to action

Best Practices

✅ Do This:

// Use switchMap for search
ofType(search), switchMap(({ query }) => this.api.search(query))       // ✅
// Use concatMap for ordered operations
ofType(save), concatMap(({ data }) => this.api.save(data))             // ✅
// Use the entity adapter for collections
userAdapter.setAll(users, state)                                       // ✅
// Use getSelectors for standard selections
const { selectAll } = userAdapter.getSelectors();                      // ✅
// Use concatLatestFrom to read store in effects
concatLatestFrom(() => this.store.select(selectCurrentUser))            // ✅
// Use { dispatch: false } for logging effects
createEffect(() => ..., { dispatch: false })                            // ✅

❌ Don’t Do This:

// Don't use mergeMap for saves
ofType(save), mergeMap(...)  // parallel saves                          // ❌
// Don't mutate entity state manually
state.entities[id] = user;  // use adapter                             // ❌
// Don't subscribe to the store inside effects
this.store.subscribe(...)  // use concatLatestFrom                     // ❌
// Don't dispatch success actions from components
this.store.dispatch(loadUsersSuccess({ users }));  // use effects       // ❌
// Don't forget catchError in effects
ofType(load), mergeMap(...)  // unhandled error breaks the stream      // ❌

Common Pitfalls

PitfallWhy It HappensFix
Effect stream breaks on errorMissing catchErrorAdd catchError returning an observable
Duplicate API callsEffect dispatches action it listens toUse separate request/success actions
Search returns stale resultsmergeMap runs parallelUse switchMap
Entity not sortedNo sortComparerAdd to adapter config
selectAll returns emptyState key wrongCheck createFeatureSelector key
Out-of-order savesmergeMap for sequential opsUse concatMap

Real-World Examples

1. Basic Load Effect

loadUsers$ = createEffect(() => this.actions$.pipe(
  ofType(UserActions.loadUsers),
  mergeMap(() => this.userService.getAll().pipe(
    map(users => UserActions.loadUsersSuccess({ users })),
    catchError(err => of(UserActions.loadUsersFailure({ error: err.message })))
  ))
));

2. Search Effect with switchMap

search$ = createEffect(() => this.actions$.pipe(
  ofType(searchUsers),
  switchMap(({ query }) => this.userService.search(query).pipe(
    map(users => searchUsersSuccess({ users }))
  ))
));

3. Save with concatMap

save$ = createEffect(() => this.actions$.pipe(
  ofType(saveUser),
  concatMap(({ user }) => this.userService.save(user).pipe(
    map(() => saveUserSuccess())
  ))
));

4. Read Store in Effect

concatLatestFrom(() => this.store.select(selectCurrentUser))

5. Non-Dispatching Effect

log$ = createEffect(() => this.actions$.pipe(tap(a => console.log(a))), { dispatch: false });

6. Entity Adapter

export const userAdapter = createEntityAdapter<User>({ sortComparer: ... });

7. Entity Reducer Method

on(addUser, (state, { user }) => userAdapter.addOne(user, state))

8. Entity Selectors

const { selectAll, selectEntities, selectTotal } = userAdapter.getSelectors();

9. Selected Entity

export const selectSelectedUser = createSelector(selectUserState, selectUserEntities, ...);

10. Update with Update

on(updateUser, (state, { update }) => userAdapter.updateOne(update, state))

Visual

The Effect Flow

┌──────────────────────────────────────────────┐
│  EFFECT FLOW                                 │
│                                              │
│  Component ──dispatch(loadUsers)──> Store    │
│                                       │      │
│                                       ▼      │
│  Effect: ofType(loadUsers)                   │
│    │                                         │
│    ├──> HTTP call                            │
│    │                                         │
│    ├──> Success → dispatch(loadSuccess)      │
│    │       │                                 │
│    │       ▼                                 │
│    │     Reducer → new state                 │
│    │                                         │
│    └──> Failure → dispatch(loadFailure)      │
│            │                                 │
│            ▼                                 │
│          Reducer → error state               │
│                                              │
└──────────────────────────────────────────────┘

The Entity State Shape

┌──────────────────────────────────────────────┐
│  ENTITY STATE                                │
│                                              │
│  {                                           │
│    ids: ['1', '2', '3'],       ← order       │
│    entities: {                 ← O(1) lookup │
│      '1': { id: '1', name: 'A' },            │
│      '2': { id: '2', name: 'B' },            │
│      '3': { id: '3', name: 'C' }             │
│    },                                        │
│    loading: false,                           │
│    error: null,                              │
│    selectedId: '2'                           │
│  }                                           │
│                                              │
│  selectAll     → sorted array of entities    │
│  selectEntities → raw dictionary             │
│  selectTotal   → count of entities           │
│  selectIds     → array of IDs                │
│                                              │
└──────────────────────────────────────────────┘

The Effect Operator Decision

┌──────────────────────────────────────────────┐
│  CHOOSING AN OPERATOR                        │
│                                              │
│  Load a list?      → mergeMap                │
│  Save in order?    → concatMap               │
│  Search live?      → switchMap               │
│  Login button?     → exhaustMap              │
│                                              │
│  Wrong operator = real bugs.                 │
│  mergeMap on save = out-of-order writes.     │
│  mergeMap on search = stale results win.     │
│                                              │
└──────────────────────────────────────────────┘

The Entity Adapter Flow

┌──────────────────────────────────────────────┐
│  ENTITY ADAPTER FLOW                         │
│                                              │
│  createEntityAdapter<User>({                 │
│    selectId: u => u.id                       │
│  })                                          │
│       │                                      │
│       ├──> getInitialState()                 │
│       ├──> addOne, setAll, updateOne, etc.   │
│       └──> getSelectors()                    │
│                                              │
│  Reducer uses methods:                       │
│    userAdapter.setAll(users, state)          │
│                                              │
│  Selectors use generated functions:          │
│    createSelector(selectUserState, selectAll)│
│                                              │
└──────────────────────────────────────────────┘

Summary

ItemValue
EffectInjectable class that handles side effects
Effect operatormergeMap, concatMap, switchMap, exhaustMap
Effect registrationprovideEffects([Effects])
Non-dispatching{ dispatch: false }
Store read in effectconcatLatestFrom(() => store.select(sel))
Entity adaptercreateEntityAdapter<T>()
Entity state{ ids: [], entities: {} }
Entity methodsaddOne, setAll, updateOne, removeOne
Entity selectorsselectAll, selectEntities, selectTotal, selectIds
Sort comparatorsortComparer in adapter config

Key takeaways:

  • Effects handle everything the reducer cannot. HTTP calls, local storage, router navigation, logging, WebSocket messages. An effect listens for actions with ofType, performs the side effect, and dispatches a success or failure action .
  • The flattening operator is the concurrency policy. mergeMap runs in parallel. concatMap runs in order. switchMap cancels the previous. exhaustMap ignores new while busy. Choosing the wrong operator causes real bugs: out-of-order saves, stale search results, duplicate logins.
  • catchError is required in every effect that can fail. Without it, an error in the inner observable terminates the effect’s stream, and the effect stops responding to actions. catchError(() => of(failureAction)) converts the error into a dispatched action and keeps the stream alive.
  • concatLatestFrom reads the store at the moment the action fires. It does not subscribe to the store or cause the effect to re-run on state changes. The tuple [action, storeValue] is destructured in the effect body .
  • Entity provides the adapter for collections. createEntityAdapter<T>() returns methods for every CRUD operation and selectors for reading the state. The state shape is normalized: { ids: [], entities: {} }, with O(1) lookups by ID.
  • The adapter’s methods are called from the reducer. userAdapter.setAll(users, state) replaces the collection. userAdapter.addOne(user, state) adds one. userAdapter.updateOne(update, state) updates one. Each returns a new state; the reducer does not mutate.
  • Entity selectors are generated by the adapter. selectAll, selectEntities, selectTotal, and selectIds are derived from the adapter. They are memoized and can be composed with feature selectors to derive domain-specific views.

Remember: Effects are the imperative edge of NgRx. They exist so the store can stay pure. Every HTTP call, every local storage read, every external interaction flows through an effect, and every effect dispatches an action to tell the store what happened. Entity is the collection manager. It replaces hand-written reducer cases with adapter methods and standardizes the state shape. Together, they turn the hard parts of state management — async operations and collections — into patterns that are applied the same way in every feature.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!