You still there? Great. As promised, the local state and "has more" feature:
Challenge: local state
Small and medium scale apps, usually display the list of data once in an app, and in almost the same shape, but the case of the params is different. If you update the param state with a new page of the employees list, you do not expect it to update for companies list as well. So the Param State is actually always local, and should not be provided in root. This is to prevent developers from overlapping params. It should be provided locally, and here is how.
@Injectable() // remove provided in root
export class ParamState extends StateService<any> {}
And in the component
@Component({
templateUrl: './list.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [ParamState] // provide state here
})
This, as it is, should work in our current component. Any changes to Param State in any other component will not affect it. But what about child components?
// Child component that does something to params
@Component({
selector: 'cr-tx-category',
templateUrl: './category.partial.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TxCategoryPartialComponent {
constructor(
// inject paramState
private paramState: ParamState) {
}
next() {
// let's update the page param of the parent component
this.paramState.UpdateState({page: this.paramState.currentItem.page + 1});
}
}
And in the child template
<button (click)="next()" class="btn">Next page of parent</button>
Without the need to provide the paramState
in child component, that actually does work. The main component will receive the next page event.
So what if the child component has a list of a different parameter? say filtered for category? (Note: this is bad design all together but what if!)
// in child component OnInit
this.paramState.SetState({
page: 1,
size: 5,
total: 0,
category: 'eatingout' // added a category to the child component
});
Running the page, the parent component had the first win, the list was unfiltered on both components, any subsequent pagination resulted in "eatingout" transactions on both components. So what can we do? Instantiate all states locally, so they don't get fed from the global states.
// in child component
paramState: ParamState
txState: TransactionState;
constructor(
// only the service is injected
private txService: TransactionService
) {
// instantiate
this.paramState = new ParamState();
this.txState = new TransactionState();
}
// then go on creating a list of transactions just as the parent component
ngOnInit(): void {
this.tx$ = this.paramState.stateItem$.pipe(
distinctUntilKeyChanged('page'),
switchMap((state) => this.txService.GetTransactions(state)),
switchMap((txs) => {
this.paramState.UpdateState({total: txs.total});
return this.txState.appendList(txs.matches)}),
);
// txState is local, no need to empty it
this.paramState.SetState({
page: 1,
size: 5,
total: 0,
category: 'eatingout'
});
}
There is really nothing else we need to do. Notice that we did not need to stop providing transactionState
in root, nor did we need to provide it locally. The catch is, those are local states, only in effect during the life cycle of the child component, as I said, for the current example, it is sort of bad design.
If we run into a situation where we need a different filtered list of the same artefacts (like filter for type of users) we are better off creating multiple states, like admin-user-state and viewer-user-state. The decision to do that however, should be handled case by case.
What if, we want to delete a transaction in the child component, and make it reflect on the parent, is that doable?
In child component, inject the transaction global state
constructor(
private txService: TranscationService,
// new state
private txParentState: TranscationState
) {
this.paramState = new ParamState();
// local state
this.txState = new TranscationState();
}
// ....
delete(tx: ITranscation): void {
this.txService.DeleteTransaction(tx).subscribe({
next: () => {
this.paramState.UpdateState({total: this.paramState.currentItem.total-1});
this.txState.removeItem(tx);
// what about parent state? let's update that too
this.txParentState.removeItem(tx);
},
error: (er) => {
console.log(er);
},
});
}
That works fine. There is the "total" that also need to be tracked, and a whole lot of baggage, so don't do it❗The page you are showing to the user should reflect one stop in the journey of state, not many, it's too noisy.
Pagination: extra param for "has more"
Last time we stopped at ParamState
service with "any", let's tidy up that to have params have their own model
export interface IParams {
page?: number;
size?: number;
category?: string;
total?: number;
// new parameter for has more
hasMore?: boolean;
}
@Injectable()
export class ParamState extends StateService<IParams> {}
In the template, we want to show "more" in case we think there will be more.
// somewhere above, the params is async pipe-d holding everything
<div class="txt-c" *ngIf="params.hasMore">
<button class="btn" (click)="next()">More</button>
</div>
And in the component, we want to achieve this:
this.tx$ = this.paramState.stateItem$.pipe(
distinctUntilKeyChanged('page'),
switchMap((state) => this.txService.GetTransactions(state)),
switchMap((txs) => {
const _hasMore = doWePossiblyHaveMore();
// update state
this.paramState.UpdateState({total: txs.total, hasMore: _hasMore});
return this.txState.appendList(txs.matches)}),
);
this.txState.SetList([]);
this.paramState.SetState({
page: 1,
size: 5,
total: 0,
hasMore: false // initialize
});
Some API designs return if there is more, in addition to total
and matches
, but for this simple case, we count the number of pages, and if we are on the last one, there is no more to show.
// somewhere in common functions
export const hasMore = (total: number, size: number, currentPage: number): boolean => {
if (total === 0) { return false; }
// total number of pages
const pages = Math.ceil(total / size);
// if we are on the last page, no more
if (currentPage === pages) {
return false;
} else {
return true;
}
};
Back to our component
// replace line const _hasMore = doWePossiblyHaveMore();
const _hasMore = hasMore(txs.total, this.paramState.currentItem.size,
this.paramState.currentItem.page);
Testing... works.
Next Tuesday
I have one thing left to experiment with before I decide my State class is done. Do we go for a list state that always assumes the array in a sub property? The experiment is a failure if it proves too complicated for simple arrays. See you next Tuesday.
Thanks for tuning in. Please let me know in the comments any questions or ideas you have.
On Stackblitz
Top comments (0)