From ngrx to ngrx-entity to @ngrx/data

My journey to ngrx/data

Who am I

  • Name: Jia Li
  • Company: Sylabs.io
  • Zone.js: Code Owner
  • Angular: Collaborator
  • @JiaLiPassion

A Real Project

Step1: Traditional Angular Way

Code Sample

// Container Service
@Injectable()
export class ContainerService {
  constructor(private http: HttpClient) {}

  getAllContainers() {
    return this.http.get('http://service/containers');
  }
}

// Container Component
export class ContainerComponent implements OnInit {
  containers$: Observable<Container[]>;

  constructor(private containerService: ContainerService) {}

  ngOnInit() {
    this.containers$ = this.containerService.getAll();
  }
}

New Requirements

Text

Text

  • Be able to update container from frontend
  • Need to sync data between components

Use BehaviorSubject

// use a starred service as a bridge
export class StarredService {
  starredContainer$ = new BehaviorSubject(null);
  starContainer(container: Container) {
    this.starredContainer$.next(container);
  }
}

// ContainerComponent
export class ContainerComponent {
  ngOnInit() {
    this.containers$ = combineLatest(
      this.starredService.starredContainer$,
      this.containerService.getLatestContainers()).
        pipe([starredContainer, latestContainers] => {...})
  }
} 

// MostStarredComponent
export class ContainerComponent {
  ngOnInit() { this.containers$ = combineLatest(...)}
}

Mess....

Starred

Download Count

Description

Version/Tag Children

NgRx?

LatestUpdateComponent

MostStarredComponent

Store

Selector

So many new concepts

ngrx data

Zero Ngrx Boilerplate

 

You may never write an action, reducer, selector, effect, or HTTP dataservice again.

 

What ngrx/data provided

  • CRUD operations on Entity Collection
  • Filter, Sort
  • Loading/Loaded Status management
  • Error handling
  • Optimistic (with Undo) and Pessimistic Save
  • All the power from ngrx/entity
  • All the power from ngrx

You don't even realize you are using ngrx

getAll

// define metadata
export const entityMetadata: EntityMetadataMap = {
  Container: {}
};

// provide a facade service
export class ContainerService extends EntityCollectionServiceBase<Container> {
  constructor(serviceElementsFactory: EntityCollectionServiceElementsFactory) {
    super('Container', serviceElementsFactory);
  }
}

// call facade from component
export class ContainerComponent {
  ngOnInit() {
    // dispatch action
    this.containerService.getAll();
    // selector to get all entities
    this.containers$ = this.containerService.entities$;
  }
}

ngrx/data architecture

CRUD

export class ContainersComponent implements OnInit {
  add() {
    this.containerService.add({name: 'centos'});
  }

  delete() {
    this.containerService.delete(1);
  }

  update() {
    this.containerService.update({id: 1, name: 'ubuntu:18.03'});
  }


  getById(id: number) {
    this.containerService.getByKey(id);
  }

}

CRUD

Flow: getAll

Default behavior

Operation action.type http request url
getAll [Container] @ngrx/data/query-all GET
api/containers
add [Container] @ngrx/data/save/add-one POST
api/containers
update [Container] @ngrx/data/save/update-one PUT
api/containers
delete [Container] @ngrx/data/save/delete-one DELETE
api/containers
getByKey [Container] @ngrx/data/query-by-key GET
api/containers/${key}
getWithQuery [Container] @ngrx/data/query-many GET
api/containers?query

ngrx/entity

{ 
  entityCache: {   
    Container: {
      ids: [
        1,
        2,
        3,
        4
      ],
      entities: {
        '1': {
          id: 1,
          name: 'ubuntu'
        },
        '2': {
          id: 2,
          name: 'alpine'
        }
      }
    }
  }
}

Why ngrx/entity

Containers: [
  {
    id: 1,
    name: 'ubuntu'
  },
  {
    id: 2,
    name: 'alpine'
  }
]
Containers: {
  ids: [1, 2],
  entities: {
    1: {
      id: 1,
      name: 'ubuntu' 
    },
    2: {
      id: 2,
      name: 'alpine'
    }
  }
}

Why ngrx entity - EntityAdapter

  • Provide CRUD adapter methods
  • Provide basic selectors against entity collections
  • Provide configurable sortComparer and idSelector

ngrx/data -> ngrx/entity

Filter

// define a filter function and register it to entityMetadata

export function containerNameFilterFn(
  containers: ContainerUIModel[],
  pattern: string) {
  return PropsFilterFnFactory(['name'])(containers, pattern);
}

export const entityMetadata: EntityMetadataMap = {
  Container: {
    filterFn: containerNameFilterFn
  }
}

// Containers Component
export class ContainersComponent {
  this.filteredContainers$ = this.containerService.filteredEntities$;
} 

Sort

// define a compare function and register it to entityMetadata

export const entityMetadata: EntityMetadataMap = {
  Container: {
    sortComparer: (c1: Container, c2: Container) => c1.id - c2.id
  }
}

// Containers Component
export class ContainersComponent {
  this.containers$ = this.containerService.entities$;
} 

idSelector

// define a idSelector function and register it to entityMetadata

export const entityMetadata: EntityMetadataMap = {
  Container: {
    selectId: (c: Container) => c1.fingerprint
  }
}

Loading

// Containers Component
export class ContainersComponent {
  this.loading$ = this.containerService.loading$;
} 

// Containers Component template
<ng-container *ngIf="loading$ | async; else containers_tpl">
  Loading...
</ng-container>

Demo: Filter/Sort/Loading

Optimistic Update

export const entityMetadata: EntityMetadataMap = {
  Container: {
    entityDispatcherOptions: {
      optimisticAdd: false,
      optimisticUpdate: true,
      optimisticDelete: true
    }
  }
};

ChangeState

changeState: {
  '2': {
    changeType: 3,
    originalValue: {
      id: 2, 
      name: 'alpine'
    }
  }
}

Error Handling

@Injectable({ providedIn: 'root' })
export class ErrorService {
  constructor(private actions$: Actions) {
    actions$
      .pipe(
        ofEntityOp(),
        filter(
          (ea: EntityAction) =>
          ea.payload.entityOp.endsWith(OP_ERROR)
        )
      )
      // this service never dies so no need to unsubscribe
      .subscribe(action => {
        if (action.payload.entityOp.endsWith(OP_ERROR)) {
          this.containerService.createAndDispatch(EntityOp.UNDO_ALL);
        }
      });

   
  }
}

Demo: Update

Everything is perfect

It seems I don't need to learn a lot of ngrx and still can get all the super power from ngrx

Until we met some issues...

Customize Http Url

// create a customize http url generator
@Injectable({providedIn: 'root'})
export class CustomizeHttpUrlGenerator extends DefaultHttpUrlGenerator {
  protected getResourceUrls(
    entityName: string,
    root: string
  ): HttpResourceUrls {
    const urls = super.getResourceUrls(entityName, root);
    urls.entityResourceUrl = urls.collectionResourceUrl;
    return urls;
  }
}

// app.module.ts, then register to providers
{
  provide: HttpUrlGenerator,
  useClass: CustomizeHttpUrlGenerator
}

Map data from backend to Entity

// customize
@Injectable({ providedIn: 'root' })
export class CustomizeDataService extends DefaultDataService<Container> {
  constructor(http: HttpClient, httpUrlGenerator: HttpUrlGenerator, logger: Logger) {
    super('Container', http, httpUrlGenerator);
  }
  getAll(): Observable<Container[]> {
    return super.getAll().pipe(
      map(containers =>
        containers.map(c => ({...c, fullName: c.name + c.tag)}))
      )
    );
  }
}

Pagination

entityCache: {
  Container: {
    ids: [],
    entities: {},
    entityName: 'Container',
    filter: '',
    loaded: true,
    loading: false,
    changeState: {}
  }
}

// entity metadata.ts

export const entityMetadata: EntityMetadataMap = {
  Container: {
    additionalCollectionState: {
      page: {//...      }
    },
  }
};

Don't know how...

Finally

  • Learn ngrx
  • Re-read @ngrx/data documentation
  • Read @ngrx/data source code

ngrx/data extension points

  • Change built in behaviors
    • Customized EntityCollectionService
    • Customized EntityAction/Dispatcher
    • Add property to EntityCollection
    • Customized DataService
    • Customized merge strategy
    • Customized http url generator
    • Customized plural names
    • Customized persistentResultHandler
  • Use normal ngrx
    • Dispatch own action
    • Add own reducer/effects

EntityCollection Level Additional Property

How to customize

// entity metadata
EntityMetadataMap = {
  Container: {
    additionalCollectionState: { page: {} }
  }
};
// Custom Data Service to save page from backend to data
export class CustomizeDataService extends DefaultDataService<Container> {
  getWithQuery(params: string | QueryParams): Observable<Container[]> {
    const pageIndex = params['pageIndex'] || '1';
    return this.http.get(`api/containers?pageIndex=${pageIndex}`).
      pipe(map((data: any) => {
        const containers = data.data;
        containers.page = data.page;
        return containers as any;
    }));
  }
}
// create a page$ in service
export class ContainerService extends EntityCollectionServiceBase<Container> {
  page$: Observable<{pageIndex: number, pageCount: number}>;
  constructor(serviceElementsFactory: EntityCollectionServiceElementsFactory,
    private http: HttpClient) {
    super('Container', serviceElementsFactory);

    this.page$ = this.selectors$['page$'];
  }

}

How to customize

// persistent handler
@Injectable({ providedIn: 'root' })
export class PagePersistenceResultHandler extends DefaultPersistenceResultHandler {
  handleSuccess(originalAction: EntityAction): (data: any) => Action {
    const actionHandler = super.handleSuccess(originalAction);
    return (data: any) => {
      const action = actionHandler(data);
      if (action && data && data.page) {
         (action as any).payload.page = data.page;
      }
      return action;
    };
   
  }
}
// reducers methods.
export class EntityCollectionPageReducerMethods<T> extends EntityCollectionReducerMethods<T> {
  constructor(public entityName: string, public definition: EntityDefinition<T>) {
    super(entityName, definition);
  }

  protected queryManySuccess(collection: EntityCollection<T>,action: EntityAction<T[]>
  ): EntityCollection<T> {
    const ec = super.queryManySuccess(collection, action);
    if ((action.payload as any).page) {
      (ec as any).page = (action.payload as any).page;
    }
    return ec;
  }
}

Demo: Pagination

Conditional Error Handling

// ComponentA, want to show error dialog
this.containers$ = this.containerService.getAll().pipe(
  catchError(err => {showError(err); return of(err);})
);
// ComponentB, do not want to show error dialog
this.containers$ = this.containerService.getAll().pipe(
  catchError(err => {log.error(err); return of([])})
);
// common error service?
export class ErrorService {
  constructor(private actions$: Actions) {
    actions$
      .pipe(
        ofEntityOp(),
        filter((ea: EntityAction) =>
          ea.payload.entityOp.endsWith(OP_ERROR)
        )
      )
      .subscribe(action => {
        showError(...);
      });
  }
}

Use Tag

// ComponentA, want to show error dialog
this.containers$ = this.containerService.getAll({tag: SHOW_ERROR});
// ComponentB, do not want to show error dialog
this.containers$ = this.containerService.getAll({tag: NO_ERROR});
// common error service?
export class ErrorService {
  constructor(private actions$: Actions) {
    actions$
      .pipe(
        ofEntityOp(),
        filter(
          (ea: EntityAction) =>
          ea.payload.entityOp.endsWith(OP_ERROR)
        )
      )
      .subscribe(action => {
        if (action.payload.tag === SHOW_ERROR) {
          showError(...);
        }
      });
  }
}

Demo: Tag

Entities with relationship

User

Container

Version

@ngrx/data not support entities with relationship yet

// service
export class ContainerService {
  // action to get containers with versions
  getContainerWithVersions(containerId) {
    this.getByKey(containerId).switchMap(container => {
      this.versionService.getWithQuery({containers: containers.map(con => con.id)})
        .pipe(map(versions => ({...container, versions})))
    });
  }

  // selectors
  getContainerWithVersionsSelector(containerId) {
    return this.store.pipe(
      this.selectors.selectEntities,
      this.versionService.selectors.selectEntities,
      (containers, versions) => {
         const con = containers.find(c => c.id === containerId);
         const versions = versions.filter(v => vi.cid === containerId);
         return con ? {...con, versions} : undefined;
      }
    );
  }
}

Validation?

export const entityMetadata: EntityMetadataMap = {
  Container: {
    validations: {
      props: {
        name: ValidatorOptions.required
      },
      validator: (model: T) => ValidationResult
    }
  }
};

Server/Client Data Mapping

// server data structure
interface Container {
  name: string;
  tag: string;
  updatedTimeStamp?: string;
}

// frontend data structure
interface Container {
  name: string;
  tag: string;
  fullName: string;
}

// entity metadata
export const entityMetadata: EntityMetadataMap = {
  Container: {
    mapTo: (backendModel) => {}
  }
};

Before I learn ngrx and read ngrx/data source, ngrx/data is

  • A library to reduce boilerplate code of ngrx
  • Provide built in functionalities to handle most frequently used scenarios.

After the journey, ngrx/data is

  • A library to encapsulate the best practice of ngrx.
    • ​naming convention
    • modulation
    • great handbook of learning rxjs operators
  • If you are a ngrx expert, you may don't need ngrx/data, but if you are a ngrx beginner, use ngrx/data is a great entry point to master ngrx.
  • We still need to master ngrx even we are using ngrx/data.

Thank you for making the awesome library!

my journey to ngrx-data

By jiali

my journey to ngrx-data

  • 6,576