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,930