I am writing this part, knowing there will be an IV, because I am drifting away, experimenting more features. Last time we spoke, I told you we have a challenge of keeping up with the total number of records coming from the server, and updating it when user adds or removes. So let's work backwards and see how the end result should look like.
anchorChallenge: a state of a list and a single object
Even though we agreed not to do that, to keep it simple, but I am experimenting just to confirm that it is indeed unnecessary complication. Let's add a Total in our template, and rewrap the content a bit
<!-- wrap it inside a container -->
<ng-container *ngIf="tx$ | async as txs">
<!-- add placeholder for total -->
<div>
Total: {{dbTotalHere}}
</div>
<ul class="rowlist spaced">
<li *ngFor="let tx of txs;">
<div class="card">
<span class="rbreath a" (click)="delete(tx)">🚮</span>
<div class="content">
<div class="small light">{{tx.date | date}}</div>
{{tx.label }}
<div class="smaller lighter">{{ tx.category }}</div>
</div>
<div class="tail"><strong>{{ tx.amount }}</strong></div>
</div>
</li>
</ul>
</ng-container>
In component, I expect the matches and the total to come back together in a list, so the model eventually looks like this
// returned data from db usually has a total, or count, in addition to the items matched to query
export interface IList<T> {
total: number;
matches: T[];
}
And here is the update on the transaction service and model
GetTransactions(options: any = {}): Observable<IList<ITransaction>> {
// turn options into query string and add them to url (out of scope)
const _url = this._listUrl + GetParamsAsString(options);
return this._http.get(_url).pipe(
map((response) => {
// map the result to proper IList
return Transaction.NewList(<any>response);
})
);
}
In the Transaction model, we just need to create the NewList mapper:
public static NewList(dataset: any): IList<ITransaction> {
return {
total: dataset.total,
matches: Transaction.NewInstances(dataset.matches)
};
}
So what if, we create a state of the IList<T>
?
Complication: The extra generic (in addition to the StateService
generic)
Complication: now IList
must extend IState
, which must have an id prop. Totally rubbish! But let's go on.
The ListState
service
@Injectable({providedIn: 'root'})
export class ListState<T> extends StateService<IList<T>> {
// to add new extended features here
}
Now back to our component, and see what we need
// new tx state (n for new, because no one is looking)
nTx$: Observable<IList<ITransaction>>;
constructor(
private txService: TransactionService,
private paramState: ParamState,
// our new state
private listState: ListState<ITranscation>
) { }
ngOnInit(): void {
// watch param changes to return the matches
this.nTx$ = this.paramState.stateItem$.pipe(
switchMap((state) => this.txService.GetTransactions(state)),
switchMap(txs => {
// here I need to "append" to the internal matches, and update "total"
return this.listState.updateListState(txs);
})
);
// but, I need to set the total and matches to an empty array first
this.listState.SetState({
total: 0,
matches: []
});
// setoff state for first time
this.paramState.SetState({
page: 1,
size: 5,
});
}
And the component
<ng-container *ngIf="nTx$ | async as nTx">
<!-- get total -->
<div class="spaced bthin">
Total {{ nTx.total }}
</div>
<!-- get matches -->
<ul class="rowlist spaced">
<li *ngFor="let tx of nTx.matches">
... as is
</li>
</ul>
</ng-container>
When user adds:
add(): void {
this.txService.CreateTx(newSample()).subscribe({
next: (newTx) => {
// add to internal matches and update total
this.listState.addMatch(newTx);
},
error: (er) => {
console.log(er);
},
});
}
Let's stop here and see what we need. We need to extend the functionality of the List State so that the internal matches array, is the one that gets updated with new additions, and the total count, is updated with a +1 or -1.
Yesterday I went to sleep on that, I dreamed that I should be able to have the same model dealing with the array, and the total, if it does not make sense, it is not supposed to, it was a dream!
Complication If the total is being updated by other means, like server polling where multiple users are affecting the total, our state has to keep track, but honestly if we reach a point where it matters, we should go a different path, or run to NgRx (although I don't think they have the solution out of the box, but you will feel less guilty in front of your team mates!)
Complication Now we have to cast T to "any" or IState
before we use "id" on it. More rubbish! Bet let's keep going.
The List state service:
@Injectable({providedIn: 'root'})
export class ListState<T> extends StateService<IList<T>> {
updateListState(item: IList<T>): Observable<IList<T>> {
// append to internal matches and update total, the return state
const newMatches = [...this.currentItem.matches, ...item.matches];
this.stateItem.next({matches: newMatches, total: item.total});
return this.stateItem$;
}
addMatch(item: T) {
// add item to matches, next state, also adjust total
const newMatches = [...this.currentItem.matches, item];
this.stateItem.next({matches: newMatches, total: this.currentItem.total + 1});
}
removeMatch(item: T) {
// remove item from matches, next state, also adjust total
// casting to "any" is not cool
const newMatches = this.currentItem.matches.filter(n => (<any>n).id !== (<any>item).id);
this.stateItem.next({matches: newMatches, total: this.currentItem.total - 1});
}
editMatch(item: T) {
// edit item in matches, next state
const currentMatches = [...this.currentItem.matches];
const index = currentMatches.findIndex(n => (<any>n).id === (<any>item).id);
if (index > -1) {
currentMatches[index] = clone(item);
this.stateItem.next({...this.currentItem, matches: currentMatches});
}
}
}
As you can see, we have driven our simple state a bit deeper, and used practically the same methods on a deeper level. Not cool. But, on the other hand, I like the idea of having the original abstract state itself, a state of IList
where the matches is a sub property. This can be more useful even if we want to create a state of a simple array, all we have to do is place the array in a pseudo model with matches
property.
That note aside, let's back up a bit, and try something different. What if we use param state to hold the total?
anchorChallenge: tangling states
First, we have to retrieve the total from the returned server call. In the list component:
// we are back to tx, not nTx, if you were paying attention
this.tx$ = this.paramState.stateItem$.pipe(
switchMap((state) => this.txService.GetTransactions(state)),
switchMap((txs) => {
// HERE: before we append the list of matches, let's update paramState with total
// but... you cannot update state in the pipe that listens to the same state!
this.paramState.UpdateState({total: txs.total});
return this.txState.appendList(txs.matches)}),
);
// now that we are appending to list, need to first empty list
this.txState.SetList([]);
// setoff state for first time
this.paramState.SetState({
page: 1,
size: 5,
total: 0 // new member
});
And when we add or remove an item, again, we need to update param state:
add(): void {
this.txService.CreateTx(newSample()).subscribe({
next: (newTx) => {
// update state, watch who's listening
this.paramState.UpdateState({total: this.paramState.currentItem.total+1});
this.txState.addItem(newTx);
},
error: (er) => {
console.log(er);
},
});
}
delete(tx: ITx): void {
this.txService.DeleteTx(tx).subscribe({
next: () => {
// update state
this.paramState.UpdateState({total: this.paramState.currentItem.total-1});
this.txState.removeItem(tx);
},
error: (er) => {
console.log(er);
},
});
}
Every time we update param state, we fire a GetTransactions
call. One temptation to fix that is to update the currentItem
variables directly. But that would be wrong. The currentItem
in our state has a getter and no setter, for a purpose. We do not want to statically update internal value, we always want to update state by next-ing the subject. Though Javascript, and cousin Typescript, would not object on setting a property of an object. The other better option is to rely on RxJS's distinctUntilKeyChanged
this.tx$ = this.paramState.stateItem$.pipe(
// only when page changes, get new records
distinctUntilKeyChanged('page'),
switchMap((state) => this.txService.GetTxs(state)),
switchMap((txs) => {
// if you are worried coming back from server, the total is not up to date
// update state only if page = 1
this.paramState.UpdateState({total: txs.total});
return this.txState.appendList(txs.matches)}),
);
Another solution, now that we have a state class, is create a separate state for total. You might think it's aweful, but another property may also need to be kept track of, "has more to load" property.
Note, the ngOnOnit
is called only when page loads, and that is where the initial list need to be emptied, otherwise revisiting the same page will append to a previous list.
Let's look into a scenario of having multiple states of the same service. But first..
Fix the id rubbish
Let's get rid of the extra id in IState
by splitting the state class to two distinctive classes: StateService
and ListStateService
. Note: I ditched the state service created above as an experiment.
// the ListStateService with generic extending IState
export class ListStateService<T extends IState> {
protected stateList: BehaviorSubject<T[]> = new BehaviorSubject([]);
stateList$: Observable<T[]> = this.stateList.asObservable();
// ...
}
// the StateService fixed to have a generic with no complications
export class StateService<T> {
protected stateItem: BehaviorSubject<T | null> = new BehaviorSubject(null);
stateItem$: Observable<T | null> = this.stateItem.asObservable();
// ...
}
anchorNext Tuesday
I hope you're still following. Next time I will be investigating the local state and the "has more" feature for pagination. If you have any questions or comments, let me know in the comments section (wherever it might be, depending on where you're seeing this 🙂)