Ayyash
Elmota

Elmota

RxJS based state management in Angular - Part III

RxJS based state management in Angular - Part III

Adding "total" property to state

Ayyash's photo
Ayyash
·Mar 1, 2022·
Listen to this article

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.

Challenge: 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.

image.png

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?

Challenge: 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 inIState 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();
   // ...
}

Next 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 🙂)

Code demo on stackblitz

 
Share this