Sekrab Garage

Angular state management simple solution

RxJS based state management in Angular - Part IV

AngularDesign March 8, 22

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