@rx-mind/data-component-store v14.0.0
@rx-mind/data-component-store
Component Store with Entity Selectors, Updaters, and Effects
Contents
- Overview
- Walkthrough
- Demo
- Installation
- Data State
- Initialization
- Data Service
- Selectors
- Updaters
- Effects
- Examples
- TODO List
Overview
DataComponentStore provides a simple way to handle common CRUD use cases.
It's inspired by @ngrx/component-store reactivity, @ngrx/data simplicity, and rtk-query flexibility.
Key Concepts
Extendable State. In addition to default state properties (
entities,ids, and pending request statuses), the state ofDataComponentStoremay contain additional properties.Customizable Effects.
DataComponentStoreprovidesload,loadById,create,update, anddeleteeffects. The default behavior of all data effects can be completely or partially changed.Built-In Entity Updaters and Selectors.
DataComponentStoreextendsEntityComponentStoreand contains all of its selectors and updaters.Fully Reactive.
DataComponentStoreprovides the reactive power ofComponentStore.Parallel Requests.
DataComponentStoresupports parallelloadById,create,update, anddeleterequests. Each type of request has its own pending status as part of the state.
Walkthrough
Defining State
Define the state type by extending DataState interface:
import { DataState } from '@rx-mind/data-component-store';
interface ProductsState extends DataState<Product, number> {
query: string;
}Create the initial state by using getInitialDataState function that accepts the initial state of additional
state properties as an input argument:
import { getInitialDataState } from '@rx-mind/data-component-store';
const initialState = getInitialDataState<ProductsState>({ query: '' });Defining Base Url
Define the base url:
const baseUrl = '/products';Creating Store
Create a store by extending DataComponentStore and pass baseUrl and initialState to the parent constructor:
import { DataComponentStore } from '@rx-mind/data-component-store';
@Injectable()
export class ProductsStore extends DataComponentStore<ProductsState> {
constructor() {
super({ baseUrl, initialState });
}
}Creating View Model
Create a view model selector by combining other selectors:
@Injectable()
export class ProductsStore extends DataComponentStore<ProductsState> {
private readonly query$ = this.select((s) => s.query);
readonly vm$ = this.select(
this.all$,
this.total$,
this.isLoadPending$,
this.query$,
(products, totalProducts, isLoading, query) => ({
products,
totalProducts,
isLoading,
query,
})
);
}Creating Container Component
Provide ProductsStore via providers array, inject it through the constructor and use the view model selector
in the template. Then define onSearch method that will patch the state with the new query value.
@Component({
selector: 'rx-mind-products',
template: `
<ng-container *ngIf="vm$ | async as vm">
<h2>Products ({{ vm.totalProducts }})</h2>
<app-search [query]="vm.query" (search)="onSearch($event)"></app-search>
<app-loading-spinner *ngIf="vm.isLoading"></app-loading-spinner>
<ul>
<li *ngFor="let product of vm.products">{{ product.name }}</li>
</ul>
</ng-container>
`,
providers: [ProductsStore],
})
export class ProductsComponent {
readonly vm$ = this.productsStore.vm$;
constructor(private readonly productsStore: ProductsStore) {}
onSearch(query: string): void {
this.productsStore.patchState({ query });
}
}Loading Data from Server
Create loadParams$ selector that contains the query parameters of the product load request.
Then call load effect in the constructor with loadParams$ as the input argument.
@Injectable()
export class ProductsStore extends DataComponentStore<ProductsState> {
private readonly query$ = this.select((s) => s.query);
private readonly loadParams$ = this.select(this.query$, (query) => ({ query }));
constructor() {
super({ baseUrl, initialState });
this.load(this.loadParams$);
}
}By passing loadParams$ Observable to the load effect, products will be re-fetched each time the query is changed.
Initially, products will be fetched with an initial query value (empty string).
Target URL will be /products?query=${value}, where value is the current query value.
Customizing Data Effects
Clear the product collection and display an error message each time the load request fails:
import { HttpErrorResponse } from '@angular/common/http';
@Injectable()
export class ProductsStore extends DataComponentStore<ProductsState> {
protected overrideDataEffects(builder: DataEffectsBuilder<Product, number>): void {
builder.loadError<HttpErrorResponse>((error) => {
this.removeAll();
this.alertService.error(error.message);
});
}
}Demo
See DataComponentStore in action on StackBlitz.
More examples are available here.
Installation
- NPM:
npm i @rx-mind/data-component-store - Yarn:
yarn add @rx-mind/data-component-store
Note:
@rx-mind/data-component-storehas@rx-mind/entity-component-storeand@ngrx/component-storeas peer dependencies.
Data State
The state of DataComponentStore is defined by extending DataState interface:
import { DataState } from '@rx-mind/data-component-store';
interface MoviesState extends DataState<Movie, string> {
selectedId: string | null;
query: string;
}DataState interface contains following properties: ids, entities, isLoadPending, isLoadByIdPending, isCreatePending,
isUpdatePending, and isDeletePending.
It extends EntityState
and accepts entity type as the first and id type as the second generic argument. The second argument is optional
and if not provided, the id type will be string | number.
To create the initial state, there is getInitialDataState function. It accepts the initial values
of additional state properties as the input argument.
import { getInitialDataState } from '@rx-mind/data-component-store';
const initialState = getInitialDataState<MoviesState>({
selectedId: null,
query: '',
});If the state doesn't contain additional properties, then the input argument should not be passed to getInitialDataState:
import { DataState, getInitialDataState } from '@rx-mind/data-component-store';
type MoviesState = DataState<Movie, string>;
const initialState = getInitialDataState<MoviesState>();Initialization
The constructor of DataComponentStore accepts a configuration object that contains one required and three optional
properties. Optional properties are initialState, selectId and sortComparer similar to the
EntityComponentStore configuration.
Required configuration property is baseUrl or dataService.
import { DataComponentStore } from '@rx-mind/data-component-store';
@Injectable()
export class MoviesStore extends DataComponentStore<MoviesState> {
constructor() {
super({ initialState, selectId, sortComparer, baseUrl: '/movies' });
}
}When baseUrl is passed, DataComponentStore will use DefaultDataService as the data resource.
If the resource is not in accordance with REST principles, or does not use HTTP at all,
then a custom data service should be provided:
import { DataComponentStore } from '@rx-mind/data-component-store';
@Injectable()
export class MoviesStore extends DataComponentStore<MoviesState> {
constructor(moviesService: MoviesService) {
super({ initialState, dataService: moviesService });
}
}Similar to ComponentStore, the state of DataComponentStore can be initialized lazily by calling setState method:
@Injectable()
export class MoviesStore extends DataComponentStore<MoviesState> {
constructor() {
super({ baseUrl: '/movies' });
}
}
@Component({
selector: 'rx-mind-movies',
templateUrl: './movies.component.html',
viewProviders: [MoviesStore],
})
export class MoviesComponent implements OnInit {
constructor(private readonly moviesStore: MoviesStore) {}
ngOnInit(): void {
this.moviesStore.setState(initialState);
}
}Also, there is an option to provide the DataComponentStore configuration via DATA_COMPONENT_STORE_CONFIG injection token:
import {
DataComponentStore,
DATA_COMPONENT_STORE_CONFIG,
DataState,
getInitialDataState,
} from '@rx-mind/data-component-store';
type MoviesState = DataState<Movies, string>;
const initialState = getInitialDataState<MoviesState>();
const baseUrl = '/movies';
@Component({
selector: 'rx-mind-movies',
templateUrl: './movies.component.html',
viewProviders: [
{ provide: DATA_COMPONENT_STORE_CONFIG, useValue: { initialState, baseUrl } },
DataComponentStore,
],
})
export class MoviesComponent {
constructor(private readonly moviesStore: DataComponentStore<MoviesState>) {}
}Data Service
DataService is an interface that contains common CRUD methods: get, getById, create, update, and delete.
get(params?: QueryParams): Observable<Entity[] | Record<string, any>>- Accepts query parameters as an optional input argument. It can return an array of entities, but also a dictionary that contains entities and additional properties. This is useful f.e. for server pagination when the total count is returned along with an array of entities.getById(id: Id): Observable<Entity>- Returns the entity by passed id.create(entity: Partial<Entity>): Observable<Entity>- Returns created entity. The partial entity should be passed as an input argument when the entity id is generated on the server. Otherwise, the complete entity should be passed.update(entityUpdate: Update<Entity, Id>): Observable<Entity>- Accepts an object of typeUpdate<Entity, Id>as the input argument and returns updated entity.Update<Entity, Id>contains two properties: id and entity changes.delete(id: Id): Observable<Entity | Id | null>- Accepts the entity id as the input argument. It can return deleted entity, its id or empty response.
Default Data Service
When baseUrl is passed as a part of DataComponentStore configuration, then DefaultDataService will be used
as the data resource. DefaultDataService implements DataService interface according to the REST principles.
Custom Data Service
If the resource is not in accordance with REST principles, or does not use HTTP at all, then a custom data service should be provided.
There are two ways to create the custom data service. The first is to extend DefaultDataService and override methods
that need to be changed:
import { DefaultDataService } from '@rx-mind/data-component-store';
@Injectable({
providedIn: 'root',
})
export class MoviesService extends DefaultDataService<Movie, string> {
constructor() {
super('/movies');
}
get(params?: QueryParams): Observable<{ movies: Movie[]; totalCount: number }> {
return this.http
.get<Movie[]>(this.baseUrl, { params, observe: 'response' })
.pipe(
map(({ body, headers }) => ({
movies: body as Movie[],
totalCount: Number(headers.get('x-total-count')),
}))
);
}
}Another way is to implement DataService interface:
import { DataService } from '@rx-mind/data-component-store';
@Injectable({
providedIn: 'root',
})
export class MoviesService implements DataService<Movie, string> {
constructor(private readonly http: HttpClient) {}
get(params?: QueryParams): Observable<Movie[]> {
return this.http
.get<{ items: Movie[] }>(`/movies`, { params })
.pipe(map(({ items }) => items));
}
getById(id: string): Observable<Movie> {
return this.http.get<Movie>(`/movies/${id}`);
}
create(movie: Movie): Observable<Movie> {
return this.http.post<null>('/movies', movie).pipe(mapTo(movie));
}
update({ id, changes }: Update<Movie, string>): Observable<Movie> {
return this.http.patch<null>(`/movies/${id}`, changes).pipe(mapTo({ id, ...changes } as Movie));
}
delete(id: string): Observable<string> {
return this.http.delete<null>(`/movies/${id}`).pipe(mapTo(id));
}
}Then instead of baseUrl, pass dataService as a part of the DataComponentStore configuration:
@Injectable()
export class MoviesStore extends DataComponentStore<MoviesState> {
constructor(moviesService: MoviesService) {
super({ initialState, dataService: moviesService });
}
}Selectors
DataComponentStore contains entity selectors: ids$, entities$, all$, and total$.
Read more about entity selectors here.
Also, it contains the following selectors:
isLoadPending$- Indicates whether a load request is in progress.isLoadByIdPending$- Indicates whether any load by id request is in progress.isCreatePending$- Indicates whether any create request is in progress.isUpdatePending$- Indicates whether any update request is in progress.isDeletePending$- Indicates whether any delete request is in progress.isPending$- Indicates whether any entity request is in progress.
Usage:
import { DataComponentStore, DataState, getInitialDataState } from '@rx-mind/data-component-store';
interface MoviesState extends DataState<Movie, string> {
query: string;
}
const initialState = getInitialDataState<MoviesState>({ query: '' });
@Injectable()
export class MoviesStore extends DataComponentStore<MoviesState> {
private readonly query$ = this.select((s) => s.query);
readonly vm$ = this.select(
this.all$,
this.total$,
this.query$,
this.isLoadPending$,
(movies, total, query, isLoading) => ({ movies, total, query, isLoading })
);
constructor(moviesService: MoviesService) {
super({ initialState, dataService: moviesService });
}
}Updaters
DataComponentStore contains entity updaters: addOne, addMany, setOne, setMany, setAll,
removeOne, removeMany, removeAll, updateOne, updateMany, upsertOne, upsertMany, mapOne, and map.
Read more about entity updaters here.
Effects
DataComponentStore contains load, loadById, create, update, and delete methods.
All of these methods are ComponentStore effects, and can accept plain value or Observable as the input argument.
load
load effect is used to load entities from a data resource. It accepts a query parameters dictionary as
an optional input argument.
@Component({
selector: 'rx-mind-movies',
templateUrl: './movies.component.html',
viewProviders: [MoviesStore],
})
export class MoviesComponent {
readonly movies$ = this.moviesStore.all$;
constructor(private readonly moviesStore: MoviesStore) {}
onLoad(): void {
// without query parameters
this.moviesStore.load();
}
onLoadByQuery(): void {
// with query parameters
this.moviesStore.load({ query: 'movie' });
}
}By passing an Observable, the entities will be reloaded each time it emits a new value:
@Injectable()
export class MoviesStore extends DataComponentStore<MoviesState> {
private readonly query$ = this.select((s) => s.query);
private readonly loadParams$ = this.select(this.query$, (query) => ({ query }));
constructor() {
super({ baseUrl, initialState });
// with query parameters as Observable
this.load(this.loadParams$);
}
}
@Component({
selector: 'rx-mind-movies',
templateUrl: './movies.component.html',
viewProviders: [MoviesStore],
})
export class MoviesComponent {
readonly movies$ = this.moviesStore.all$;
readonly isLoading$ = this.moviesStore.isLoadPending$;
constructor(private readonly moviesStore: MoviesStore) {}
onSearch(query: string): void {
this.moviesStore.patchState({ query });
}
}load effect calls get method from data service under the hood and passes provided query parameters.
When called, it will set isLoadPending to true, and move it back to false when the request is complete.
If load is called when another load request is in progress, it will cancel the previous one and send a new request.
By default, load effect expects an array of entities to be returned from the data service and replaces current collection
with a new one by using setAll entity updater.
However, the default behavior load effect can be changed. Read more here.
loadById
loadById effect is used to load entity by id from a data resource. It accepts the entity id as the input argument.
@Component({
selector: 'rx-mind-movies',
templateUrl: './movies.component.html',
viewProviders: [MoviesStore],
})
export class MoviesComponent {
readonly movies$ = this.moviesStore.all$;
constructor(private readonly moviesStore: MoviesStore) {}
onLoadById(id: string): void {
this.moviesStore.loadById(id);
}
}Also, there is a possibility to pass Observable as an input argument.
@Injectable()
export class MoviesStore extends DataComponentStore<MoviesState> {
private readonly activeId$ = this.activatedRoute.paramMap.pipe(map((params) => params.get('id')));
readonly activeMovie$ = this.select(
this.entities$,
this.activeId$,
(movies, activeId) => activeId && movies[activeId]
);
constructor(private readonly activatedRoute: ActivatedRoute) {
super({ baseUrl, initialState });
// with id as Observable
this.loadById(this.activeId$);
}
}
@Component({
selector: 'rx-mind-movie-details',
templateUrl: './movie-details.component.html',
viewProviders: [MoviesStore],
})
export class MovieDetailsComponent {
readonly movie$ = this.moviesStore.activeMovie$;
readonly isLoading$ = this.moviesStore.isLoadByIdPending$;
constructor(private readonly moviesStore: MoviesStore) {}
}loadById effect calls getById method from data service under the hood and passes provided id.
When called, it will set isLoadByIdPending to true, and move it back to false when the request is complete.
loadById effect supports parallel requests, which means that isLoadByIdPending will be true when any load by id
request is in progress.
On success, loadById effect will add or replace loaded entity in the collection by using
setOne entity updater.
However, the default behavior of loadById effect can be changed. Read more here.
create
create effect is used to create an entity. It accepts the partial entity as the input argument.
Similar to other data effects, it is possible to pass Observable as an input argument.
@Component({
selector: 'rx-mind-movies',
templateUrl: './movies.component.html',
viewProviders: [MoviesStore],
})
export class MoviesComponent {
readonly movies$ = this.moviesStore.all$;
readonly isCreating$ = this.moviesStore.isCreatePending$;
constructor(private readonly moviesStore: MoviesStore) {}
onCreate(movie: Omit<Movie, 'id'>): void {
this.moviesStore.create(movie);
}
}create effect calls create method from data service under the hood and passes provided entity.
When called, it will set isCreatePending to true, and move it back to false when the request is complete.
create effect supports parallel requests, which means that isCreatePending will be true when any create request
is in progress.
On success, create effect will add created entity to the collection by using
addOne entity updater.
However, the default behavior of create effect can be changed. Read more here.
update
update effect is used to update an entity. It accepts the object that contains the id and entity changes as the input argument.
Similar to other data effects, it is possible to pass Observable as an input argument.
@Component({
selector: 'rx-mind-movies',
templateUrl: './movies.component.html',
viewProviders: [MoviesStore],
})
export class MoviesComponent {
readonly movies$ = this.moviesStore.all$;
readonly isUpdating$ = this.moviesStore.isUpdatePending$;
constructor(private readonly moviesStore: MoviesStore) {}
onUpdate(movieUpdate: Update<Movie, string>): void {
this.moviesStore.update(movieUpdate);
}
}update effect calls update method from data service under the hood and passes provided argument.
When called, it will set isUpdatePending to true, and move it back to false when the request is complete.
update effect supports parallel requests, which means that isUpdatePending will be true when any update request
is in progress.
On success, update effect will update entity in the collection by using
updateOne entity updater.
However, the default behavior of update effect can be changed. Read more here.
delete
delete effect is used to delete an entity. It accepts the entity id as the input argument.
Similar to other data effects, it is possible to pass Observable as an input argument.
@Component({
selector: 'rx-mind-movies',
templateUrl: './movies.component.html',
viewProviders: [MoviesStore],
})
export class MoviesComponent {
readonly movies$ = this.moviesStore.all$;
readonly isDeleting$ = this.moviesStore.isDeletePending$;
constructor(private readonly moviesStore: MoviesStore) {}
onDelete(id: string): void {
this.moviesStore.delete(id);
}
}delete effect calls delete method from data service under the hood and passes provided entity id.
When called, it will set isDeletePending to true, and move it back to false when the request is complete.
delete effect supports parallel requests, which means that isDeletePending will be true when any delete request
is in progress.
On success, delete effect will remove entity from the collection by using
removeOne entity updater.
However, the default behavior of delete effect can be changed. Read more here.
overrideDataEffects
The default behavior of data effects can be changed by using overrideDataEffects method. It exposes builder object,
that contains the following methods:
loadStart(callback: (params: Params) => void)- Passed callback will be executed beforedataService.getis called. It accepts query parameters that can be passed to theloadeffect as the input argument.loadSuccess(callback: (response: Response) => void)- Passed callback will be executed whendataService.getsucceeds. It accepts the response returned from thedataService.getmethod as the input argument.loadError(callback: (error: Error) => void)- Passed callback will be executed whendataService.getfails. It accepts the error that is thrown by thedataService.getas the input argument.loadByIdStart(callback: (id: Id) => void)- Passed callback will be executed beforedataService.getByIdis called. It accepts the id that is passed to theloadByIdeffect as the input argument.loadByIdSuccess(callback: (entity: Entity) => void)- Passed callback will be executed whendataService.getByIdsucceeds. It accepts the entity returned from thedataService.getByIdmethod as the input argument.loadByIdError(callback: (error: Error) => void)- Passed callback will be executed whendataService.getByIdfails. It accepts the error that is thrown by thedataService.getByIdas the input argument.createStart(callback: (entity: Partial<Entity>) => void)- Passed callback will be executed beforedataService.createis called. It accepts the partial entity that is passed to thecreateeffect as the input argument.createSuccess(callback: (entity: Entity) => void)- Passed callback will be executed whendataService.createsucceeds. It accepts the entity returned from thedataService.createmethod as the input argument.createError(callback: (error: Error) => void)- Passed callback will be executed whendataService.createfails. It accepts the error that is thrown by thedataService.createas the input argument.updateStart(callback: (entityUpdate: Update<Entity, Id>) => void)- Passed callback will be executed beforedataService.updateis called. It accepts entity update object that is passed to theupdateeffect as the input argument.updateSuccess(callback: (entity: Entity) => void)- Passed callback will be executed whendataService.updatesucceeds. It accepts the entity returned from thedataService.updatemethod as the input argument.updateError(callback: (error: Error) => void)- Passed callback will be executed whendataService.updatefails. It accepts the error that is thrown by thedataService.updateas the input argument.deleteStart(callback: (id: Id) => void)- Passed callback will be executed beforedataService.deleteis called. It accepts the id that is passed to thedeleteeffect as the input argument.deleteSuccess(callback: (response: Response) => void)- Passed callback will be executed whendataService.deletesucceeds. It accepts the response returned from thedataService.deletemethod as the input argument.deleteError(callback: (error: Error) => void)- Passed callback will be executed whendataService.deletefails. It accepts the error that is thrown by thedataService.deleteas the input argument.error(callback: (error: Error) => void)- It has a lower priority than other error handlers. The passed callback will be executed when an effect that does not have a defined error handler fails. It accepts the error that is thrown by thedataServicemethod as the input argument.
Note:
DataComponentStorewill automatically manage the status of pending requests, and there is no need to override that part.
Changing Default Behavior
As previously described, each data effect has a predefined behavior when the request succeeds.
For example, load effect will call setAll updater to replace the current collection with a new one,
returned from the dataService.get method. However, there are several scenarios when this is not expected behavior.
Scenario 1: dataService.get method does not return an array of entities as a response.
This is the case when server pagination is used. Then the response contains the array of entities, and the total number of entities.
To handle this scenario, use loadSuccess method:
interface MoviesState extends DataState<Movie, string> {
totalCount: number;
}
const initialState = getInitialDataState<MoviesState>({ totalCount: 0 });
@Injectable()
export class MoviesStore extends DataComponentStore<MoviesState> {
constructor() {
super({ baseUrl: '/movies', initialState });
}
protected overrideDataEffects(builder: DataEffectsBuilder<Movie, string>): void {
builder.loadSuccess<{ movies: Movie[]; totalCount: number }>(({ movies, totalCount }) => {
this.setAll(movies, { totalCount });
});
}
}setAll updater will replace the current collection with a new one, but will also patch the state with provided totalCount.
Scenario 2: An array of entities returned from the dataService.get method should be appended to the current collection.
This is the case when virtual scrolling is used. Similar to the previous example, loadSuccess method should be used:
@Injectable()
export class MoviesStore extends DataComponentStore<MoviesState> {
protected overrideDataEffects(builder: DataEffectsBuilder<Movie, string>): void {
builder.loadSuccess<Movie[]>((movies) => this.addMany(movies));
}
}Error Handling
Error handling can be done by using overrideDataEffects method.
You can define a common error handler or error handler for a specific effect.
Defining a common error handler:
import { HttpErrorResponse } from '@angular/common/http';
@Injectable()
export class MoviesStore extends DataComponentStore<MoviesState> {
constructor(private readonly alertService: AlertService) {
super({ baseUrl, initialState });
}
protected overrideDataEffects(builder: DataEffectsBuilder<Movie, string>): void {
builder.error<HttpErrorResponse>((error) => this.alertService.error(error.message));
}
}Error handling for a specific effect:
@Injectable()
export class MoviesStore extends DataComponentStore<MoviesState> {
constructor(private readonly alertService: AlertService) {
super({ baseUrl, initialState });
}
protected overrideDataEffects(builder: DataEffectsBuilder<Movie, string>): void {
builder.loadError<{ message: string }>((error) => {
this.removeAll();
this.alertService.error(error.message);
});
}
}Common error handler has a lower priority than specific error handlers.
@Injectable()
export class MoviesStore extends DataComponentStore<MoviesState> {
protected overrideDataEffects(builder: DataEffectsBuilder<Movie, string>): void {
// executed when `loadById`, `update` or `delete` effect fails
builder.error<HttpErrorResponse>(({ message }) => this.alertService.error(message));
// executed when `load` effect fails
builder.loadError<{ message: string }>((error) => {
this.removeAll();
this.alertService.error(error.message);
});
// executed when `create` effect fails
builder.createError<{ message: string }>(({ message }) => {
this.alertService.error('Creation Failed! ' + message);
});
}
}Examples
TODO List
- Built-in optimistic creates, updates, and deletes
- Request caching
3 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago