Sekrab Garage

Angular state management simple solution

RxJS based state management in Angular - Part V

AngularDesign March 14, 22
Subscribe to Sekrab Parts newsletter.
Series:

Simple State Management

We are creating a class, that contains the minimum required, to handle Angular state management using RxJS, and testing few other methods as we go along.
  1. two years ago
    RxJS based state management in Angular - Part I
  2. two years ago
    RxJS based state management in Angular - Part II
  3. two years ago
    RxJS based state management in Angular - Part III
  4. two years ago
    RxJS based state management in Angular - Part IV
  5. two years ago
    RxJS based state management in Angular - Part V

In this article

I should call it a quit now. One more thing to experiment with. Couple of weeks ago I came down to making a state service of IList and found out that we recreated all functionalities just to accommodate the sub property of matches and total. Today, I am going to make that part of the state class. It will prove a failure if using it for a simple array with no pagination proved to be unnecessarily complicated.

anchorMoving the total and hasMore to the list state

We begin at the end. The Transactions observable is now responsible for total and hasMore props, thus no need to watch Params in the template.

  <!-- Watching the main observer at higher level -->
    <ng-container *ngIf="nTx$ | async as txs">
        <div class="bthin spaced">
            // it should contain its own total
            Total {{  txs.total }} items
        </div>
        <ul class="rowlist spaced">
            // and the matches are the iterable prop
            <li *ngFor="let tx of txs.matches;">
                <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>

        <button class="btn" (click)="add()">Add new</button> 
         // and the hasMore is part of it too
        <div class="txt-c" *ngIf="txs.hasMore">
            <button class="btn" (click)="next()">More</button>
        </div>
    </ng-container>

In the component

 ngOnInit(): void {

       // back to nTx ;)
        this.nTx$ = this.paramState.stateItem$.pipe(
            distinctUntilKeyChanged('page'),
            switchMap((state) => this.txService.GetTransactions(state)),
            switchMap((txs) => {
                // calculating hasMore from param state
                const _hasMore = hasMore(txs.total, this.paramState.currentItem.size, this.paramState.currentItem.page);
               // Now this, is new, it should set list and append new
               return this.txState.appendList({...txs, hasMore: _hasMore})}),
        }

        // empty list everytime we visit this page
        this.txState.emptyList(); 

        // setoff state for first time, simplified with no total or hasMore
        this.paramState.SetState({
            page: 1,
            size: 5
        });
    }

The first simplification we face: total now is being taken care of inside the state class

 // the add function now is slightly reduced
    add(): void {
        this.txService.CreateTransaction(newSample()).subscribe({
            next: (newTx) => {
                // no need to update param state, simply add item, it should take care of total
                this.txState.addItem(newTx);
            }
        });
    }

    delete(tx: ITransaction): void {
        this.txService.DeleteTransaction(tx).subscribe({
            next: () => {
                // this should now take care of total
                this.txState.removeItem(tx);
            }
        });
    }

The state class then looks like this (notice how heavier it looks than original, that should be a downside)

// First lets change the IState model to IListItem
export interface IListItem {
    id: string;
}
// and let me create an IList model to hold matches array, total and hasMore
export interface IList<T extends IListItem> {
    total: number;
    matches: T[];
    hasMore?: boolean;
}

// then our ListStateService would assume an observable of the IList, rather than an array
export class ListStateService<T extends IListItem>  {
    // instantiate with empty array and total 0
    protected stateList: BehaviorSubject<IList<T>> = new BehaviorSubject({ matches: [], total: 0 });
    stateList$: Observable<IList<T>> = this.stateList.asObservable();

   // the getter
    get currentList(): IList<T> {
        return this.stateList.getValue();
    }

    // the append list should now set and append list and return an observable of IList
    appendList(list: IList<T>): Observable<IList<T>> {
        // append to internal matches array
        const newMatches = [...this.currentList.matches, ...list.matches];

       //aaargh! progress current state, with the incoming list then return
        this.stateList.next({ ...this.currentList, ...list, matches: newMatches });
        return this.stateList$;
    }

    // new: empty initial state list and total
    emptyList() {
        this.stateList.next({ matches: [], total: 0 });
    }

     addItem(item: T): void {
        this.stateList.next({
            // always must carry forward the current state 
            ...this.currentList,
            matches: [...this.currentList.matches, item],
            // update total
            total: this.currentList.total + 1
        });
     }

    editItem(item: T): void {
        const currentMatches = [...this.currentList.matches];
        const index = currentMatches.findIndex(n => n.id === item.id);
        if (index > -1) {
            currentMatches[index] = clone(item);
            // again, need to carry forward the current state
            this.stateList.next({ ...this.currentList, matches: currentMatches });
        }
    }

    removeItem(item: T): void {
        this.stateList.next({
           // and carry forward the current state
            ...this.currentList,
            matches: this.currentList.matches.filter(n => n.id !== item.id),
           // update total
            total: this.currentList.total - 1
        });
    }
}

The first issue is to set the initial state with empty array, and zero matches. That is fixed with the new method emptyList().

The second issue is that since we have to take care of the object and the array, we need to carry forward the current state props in every operation. So it is like two in one! One dream, twice as many nightmares! It is not a big deal but when you start getting bugs you always question that part first.

Now to the test. Let's setup a component that gets an array of categories, with an add feature.

// the final result should look like this
<ng-container *ngIf="cats$ | async as cats">
    <ul *ngFor="let item of cats.matches">
        <li>
            {{ item.name }}
        </li>
    </ul>
    <div>
        <button class="btn-rev" (click)="add()">Add category</button>
    </div>
</ng-container>

Setting up the category state, and model:

export interface ICat {
    name: string;
    id: string; // required
}

@Injectable({ providedIn: 'root' })
export class CatState extends ListStateService<ICat> {
}

Also create a service to get categories and add category. The service should return an array of categories, not a list (no matches, and total props included). For brevity, I will leave that part out.

In our component

cats$: Observable<IList<ICat>>;
constructor(private catService: CatService, private catState: CatState) {
    // imagine CatService :)
}
ngOnInit(): void {
    this.cats$ = this.catService.GetCats().pipe(
        // here goes: to appendList, we have to wrap in a proper IList<ICat> model
        switchMap((data) => this.catState.appendList({matches: data, total: data.length}))
    );
}

add() {
    // add dummy cat without service to prove a point
    const d = {name: 'new category', id: uuid()};
    // dummy add
    this.catState.addItem(d)
}

Running this works fine. So the only added complexity is having to wrap the returned array in a pseudo model with matches property, and a useless total property.

anchorSide effects

So doing a sub array added complexity in the state itself, and made us aware of the IList model where it is not needed. Though the complexity is not huge, and for most of the Get List operations that usually are paginated, it should be a benefit, I... however... dislike it. For two reasons:

  • Wrapping the returned array in a model of no use seems too contrived
  • Open wound, the list state class has a lot of wounds that could easily be infected and eventually blow up in our faces.

anchorFinal verdict

To live true to our target of simplicity, I removed the IList implementation. Find the final state service on Stackblitz. Please do let me know if something was not clear, or was buggy and overlooked, or you have a better (simpler) idea. Thanks for coming this far, and to reward you for your patience, here is a joke:

And the bartender says, "Success, but you're not ready!"

So a JavaScript function walks into a bar.

anchorThanks 🙂

Resources:

  1. two years ago
    RxJS based state management in Angular - Part I
  2. two years ago
    RxJS based state management in Angular - Part II
  3. two years ago
    RxJS based state management in Angular - Part III
  4. two years ago
    RxJS based state management in Angular - Part IV
  5. two years ago
    RxJS based state management in Angular - Part V