Tuesday, 29 April 2025

angular NGRX with adapter

 EntityAdapter

The Entity Adapter in the example serves as a boilerplate-killer and state normalizer for your collection of Item entities. Its main purposes are:

  1. Normalize your state shape
    Instead of hand-rolling

    ts

    { ids: string[]; entities: { [id: string]: Item }; loading: boolean; error: string | null; }

    the adapter gives you that structure automatically (with its ids array and entities lookup map), plus whatever extra flags you pass in (loading, error).

  2. Immutable update helpers
    addOne, addMany, updateOne, removeOne, removeAll, etc.
    Each method takes your current state and returns a brand-new state with exactly the right pieces changed (and never mutates the old state). In the example:

    • On createItemSuccess, itemAdapter.addOne(item, state) inserts the new item into both ids and entities in one call.

    • On clearItems, itemAdapter.removeAll(state) wipes out every item but lets you preserve or explicitly reset your extra flags (loading, error).

  3. Selectors out of the box
    You don’t have to write boilerplate selectors to read allItems, entitiesById, or totalCount. A single call to itemAdapter.getSelectors() gives you typed, memoized selectors like selectAll and selectEntities.

  4. Consistency & performance
    By centralizing all add/update/remove logic in one tested library, you avoid subtle bugs (forgot to update one part of the state) and get optimized updates (e.g. quick lookups via the map rather than array scans).


Effects code explanation
createItem$ = createEffect(() =>
  this.actions$.pipe(
    ofType(ItemsActions.createItem),
    mergeMap(({ item }) =>
      this.itemService.create(item).pipe(
        map(created => ItemsActions.createItemSuccess({ item: created })),
        catchError(err =>
          of(ItemsActions.createItemFailure({ error: err.message }))
        )
      )
    )
  )
);
  • Declares an Effect

    • createEffect(() => …) tells NgRx “here is a stream of work I want you to run whenever actions flow through.”

    • The returned observable (the inner this.actions$.pipe(…)) is subscribed by the Effects system.

  • Listens to the Actions stream

    • this.actions$ is an injected stream of every action dispatched in your app.

    • You pipe it into RxJS operators to filter and transform.

  • Filters for the createItem action

    • ofType(ItemsActions.createItem) lets only createItem actions through.

    • It also types the payload so that downstream you can destructure { item }.

  • Performs an asynchronous side-effect

    • mergeMap(({ item }) => this.itemService.create(item).pipe(…))

      • For each incoming createItem action, it calls your HTTP service method itemService.create(item), which returns an Observable<Item>.

      • mergeMap ensures that multiple simultaneous create requests can all run in parallel (as opposed to switchMap, which would cancel previous requests).

  • Maps the HTTP result back into a new action

    • On success, .pipe(map(created => ItemsActions.createItemSuccess({ item: created })))

      • Wraps the newly created item in a createItemSuccess action, which will get dispatched automatically by NgRx Effects.

  • Handles errors by dispatching a failure action

    • .pipe(catchError(err => of(ItemsActions.createItemFailure({ error: err.message }))))

      • Catches any HTTP or network error, wraps it in a createItemFailure action, and emits that instead.


  • Full example

    1. Model & Adapter

    ts
    // items.model.ts export interface Item { id: string; name: string; description: string; } // items.adapter.ts import { createEntityAdapter, EntityState } from '@ngrx/entity'; import { Item } from './items.model'; export const itemAdapter = createEntityAdapter<Item>(); export interface ItemsState extends EntityState<Item> { loading: boolean; error: string | null; } // Initialize with empty collection + flags export const initialItemsState: ItemsState = itemAdapter.getInitialState({ loading: false, error: null, });

    2. Actions

    ts
    // items.actions.ts import { createAction, props } from '@ngrx/store'; import { Item } from './items.model'; // Create flow export const createItem = createAction('[Items] Create Item', props<{ item: Partial<Item> }>()); export const createItemSuccess = createAction('[Items API] Create Item Success', props<{ item: Item }>()); export const createItemFailure = createAction('[Items API] Create Item Failure', props<{ error: string }>()); // Get flow (will check cache first) export const getItem = createAction('[Items] Get Item', props<{ id: string }>()); export const getItemSuccess = createAction('[Items API] Get Item Success', props<{ item: Item }>()); export const getItemFailure = createAction('[Items API] Get Item Failure', props<{ error: string }>()); // Clear all export const clearItems = createAction('[Items] Clear All Items');

    3. Reducer

    ts
    // items.reducer.ts import { createReducer, on } from '@ngrx/store'; import { itemAdapter, initialItemsState, ItemsState } from './items.adapter'; import * as ItemsActions from './items.actions'; export const itemsReducer = createReducer<ItemsState>( initialItemsState, // — Create on(ItemsActions.createItem, state => ({ ...state, loading: true, error: null })), on(ItemsActions.createItemSuccess, (state, { item }) => itemAdapter.addOne(item, { ...state, loading: false }) ), on(ItemsActions.createItemFailure, (state, { error }) => ({ ...state, loading: false, error, })), // — Get (success upserts, so it adds or updates) on(ItemsActions.getItem, state => ({ ...state, loading: true, error: null })), on(ItemsActions.getItemSuccess, (state, { item }) => itemAdapter.upsertOne(item, { ...state, loading: false }) ), on(ItemsActions.getItemFailure, (state, { error }) => ({ ...state, loading: false, error, })), // — Clear on(ItemsActions.clearItems, state => itemAdapter.removeAll({ ...state, loading: false, error: null }) ) );

    4. Selectors

    ts
    // items.selectors.ts import { createFeatureSelector, createSelector } from '@ngrx/store'; import { itemAdapter, ItemsState } from './items.adapter'; export const selectItemsState = createFeatureSelector<ItemsState>('items'); const { selectAll: selectAllItems, selectEntities: selectItemEntities, selectIds: selectItemIds, selectTotal: selectItemsCount, } = itemAdapter.getSelectors(selectItemsState); export { selectAllItems, selectItemEntities, selectItemIds, selectItemsCount, }; // single-item selector factory export const selectItemById = (id: string) => createSelector(selectItemEntities, entities => entities[id]);

    5. Effects (with cache-check)

    ts
    // items.effects.ts import { Injectable } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import * as ItemsActions from './items.actions'; import { ItemService } from './items.service'; import { selectItemEntities } from './items.selectors'; import { mergeMap, map, catchError, take, switchMap } from 'rxjs/operators'; import { of } from 'rxjs'; @Injectable() export class ItemsEffects { constructor( private actions$: Actions, private store: Store, private itemService: ItemService ) {} // Create: POST → success/failure createItem$ = createEffect(() => this.actions$.pipe( ofType(ItemsActions.createItem), mergeMap(({ item }) => this.itemService.create(item).pipe( map(created => ItemsActions.createItemSuccess({ item: created })), catchError(err => of(ItemsActions.createItemFailure({ error: err.message })) ) ) ) ) ); // Get: check cache, else GET → upsert or failure getItem$ = createEffect(() => this.actions$.pipe( ofType(ItemsActions.getItem), // for each getItem, grab current entities once switchMap(({ id }) => this.store.select(selectItemEntities).pipe( take(1), // only one emission mergeMap(entities => { const cached = entities[id]; if (cached) { // already in store → emit success immediately return of(ItemsActions.getItemSuccess({ item: cached })); } // not found → fetch from API return this.itemService.get(id).pipe( map(item => ItemsActions.getItemSuccess({ item })), catchError(err => of(ItemsActions.getItemFailure({ error: err.message })) ) ); }) ) ) ) ); }

    6. HTTP Service

    ts
    // items.service.ts import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Item } from './items.model'; import { Observable } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class ItemService { constructor(private http: HttpClient) {} create(item: Partial<Item>): Observable<Item> { return this.http.post<Item>('/api/items', item); } get(id: string): Observable<Item> { return this.http.get<Item>(`/api/items/${id}`); } }

    7. Module Registration

    ts
    // items.module.ts import { NgModule } from '@angular/core'; import { StoreModule } from '@ngrx/store'; import { itemsReducer } from './items.reducer'; import { EffectsModule } from '@ngrx/effects'; import { ItemsEffects } from './items.effects'; @NgModule({ imports: [ StoreModule.forFeature('items', itemsReducer), EffectsModule.forFeature([ItemsEffects]), ], }) export class ItemsModule {}

    8. Component Usage

    ts
    // items.component.ts import { Component } from '@angular/core'; import { Store } from '@ngrx/store'; import * as ItemsActions from './items.actions'; import * as ItemsSelectors from './items.selectors'; import { Observable } from 'rxjs'; import { Item } from './items.model'; @Component({ /* ... */ }) export class ItemsComponent { loading$: Observable<boolean>; allItems$ = this.store.select(ItemsSelectors.selectAllItems); // get one by ID itemById$(id: string): Observable<Item|undefined> { return this.store.select(ItemsSelectors.selectItemById(id)); } constructor(private store: Store) { this.loading$ = this.store.select(state => state.items.loading); } addNew() { const newItem = { name: 'Foo', description: 'Bar' }; this.store.dispatch(ItemsActions.createItem({ item: newItem })); } loadOne(id: string) { this.store.dispatch(ItemsActions.getItem({ id })); } clearAll() { this.store.dispatch(ItemsActions.clearItems()); } }

    Flow summary

    1. Dispatch getItem({ id }).

    2. Effect checks selectItemEntities once:

      • If found, it emits getItemSuccess({ item: cached }) → reducer upserts (no-op change).

      • If not, it calls /api/items/:id → on success emits getItemSuccess({ item }) → reducer upserts new item.

    3. Reducer on success runs itemAdapter.upsertOne(...), merging into the normalized store.

    This ensures you only hit the API when you truly need to, and always keep your cache in sync via the Entity Adapter.


    No comments:

    Post a Comment