DEV Community

Cover image for RxJS based state management in Angular - Part I
Ayyash
Ayyash

Posted on • Updated on

RxJS based state management in Angular - Part I

Google it, Angular State Management, odds are, you will end up on an ngRx solution. Referring to this greate article Choosing the State Management Approach in Angular App, I am here to explore and implement the RxJS based solution.

On stackblitz

On Sekrab Garage

The problem:

If you are here, you know the problem state management fixes, most probably!

The solution:

One of the approaches to design a solution, is working your way backwards. Given a template, that represents visual components, what do we need to get state organized?

Here is a quick example, say we have a list of records, with basic delete, add and edit functionalities. Most often than not, the functionalities occur in sub routes, or child components. In this part, I want to explore the very basic RxJS state functionality. In future parts (I am hoping), will be adding extra functionalities, and some twist in scenarios. The idea is, stay simple, we do not want to run to NgRX, just yet.

Start here, and work backwards

this.records$ = this.recordService.GetList().pipe(
    switchMap(rcs => this.recordState.doSomethingToInitializeState(rcs))
);
Enter fullscreen mode Exit fullscreen mode

The component

<ng-container *ngIf="records$ | async as records">
  <ul>
    <li *ngFor="let record of records">
      <a (click)="editRecord(record)">{{ record.prop }}</a>
      <a (click)="delete(record)">Delete</a>
    <li>
  </ul>
</ng-container>
Enter fullscreen mode Exit fullscreen mode

For simplicity, let us assume that the components that handle creating and editing (the form components) are loaded on the same route, for example, in a dialog. Thus the main list of records does not get reloaded, nor the OnInit fired again.

this.recordService.SaveRecord({...record}).subscribe({
 next: (success) => this.recordState.editOneItemState(record)
});

this.recordService.CreateRecord({...newRecord}).subscribe({
next: (successRecord) => this.recordState.addNewItemToState(successRecord)
});

this.recordService.DeleteRecord({...record}).subscribe({
next: (success) => this.recordState.deleteItemFromState(record);
});
Enter fullscreen mode Exit fullscreen mode

The record service should take care of getting from server or API. So first step is to load the list into state, then to allow editing, deletion and appending of new items. Our state should look like this:

class State {
   doSomethingToInitializeState(){ ... }

   editOneItemState(item) {...}

   addNewItemToState(item) {...}

   deleteItemFromState(item) {...}
}
Enter fullscreen mode Exit fullscreen mode

What RxJs provides, is a BehaviorSubject exposed asObservable, this subject, is what gets updated (via next method). Let's name our objects properly from now on. The subject shall be named stateList, because it represents the list of the elements to be added to the state.

// internal BehaviorSubject initiated with an empty array (safest solution)
private stateList: BehaviorSubject<Record[]> = new BehaviorSubject([]);

// exposed as an observable
stateList$: Observable<Record[]> = this.stateList.asObservable(); // optionally pipe to shareReplay(1)
Enter fullscreen mode Exit fullscreen mode

Let's initiate, add, update, and delete, properly:

SetList(items: Record[]): Observable<Record[]> {
   // first time, next items as is
   this.stateList.next(items);
   // return ready to use observable 
   return this.stateList$;
}
Enter fullscreen mode Exit fullscreen mode

One of the cool features of BehaviorSubject is the getValue() of the current subject, so let me define a getter for the current list:

get currentList(): Record[] {
    return this.stateList.getValue();
}
Enter fullscreen mode Exit fullscreen mode

But before we carry on, let us build this class upon a generic, so we can make as many states as we wish later.

export class StateService<T>  {
    // private now is protected to give access to inheriting state services
    protected stateList: BehaviorSubject<T[]> = new BehaviorSubject([]);
    stateList$: Observable<T[]> = this.stateList.asObservable().pipe(shareReplay(1));

    SetList(items: T[]): Observable<T[]> {
        this.stateList.next(items);
        return this.stateList$;
    }

    get currentList(): T[] {
        return this.stateList.getValue();
     }

    // add item, by cloning the current list with the new item
    addItem(item: T): void {
        this.stateList.next([...this.currentList, item]);
    }

    // edit item, by finding the item by id, clone the list with the 
    // updated item (see note below)
    editItem(item: T): void {
        const currentList = this.currentList;
        const index = currentList.findIndex(n => n.id === item.id);
        if (index > -1) {
            currentList[index] = clone(item); // use a proper cloner
            this.stateList.next([...currentList]);
        }
    }

    // find item by id then clone the list without it
    removeItem(item: T): void {
        this.stateList.next(this.currentList.filter(n => n.id !== item.id));
    }
}
Enter fullscreen mode Exit fullscreen mode

To make sure ID exists, we can extend T to a generic interface like this

export interface IState {
    id: string; 
}

export class StateService<T extends IState>  { ... }
Enter fullscreen mode Exit fullscreen mode

As you figured, think state? think immutable. Always clone. In the above, you can use lodash clone function (install the clone function alone), or you can do as I always do, just copy over the code into your source code ๐Ÿ˜‚! Happy, in control life. The stackblitz project has that clone ready in core/common.ts

These basic members are good enough for our basic uses, one more thing to cover is allowing the list to grow by appending new items to it (think continuous pagination), thus the need to append new elements to state list.

appendList(items: T[]) {
        // update current list
        const currentList = this.currentList.concat(items);
        this.stateList.next(currentList);
}
Enter fullscreen mode Exit fullscreen mode

We might also need to prepend an item:

prependItem(item: T): void {
        this.stateList.next([item, ...this.currentList]);
 }
Enter fullscreen mode Exit fullscreen mode

There are other functionalities to include but we will stop here to implement.

Example: list of transactions, add, edit, and delete

Transaction Service

First, the transaction service with the CRUD, assuming the HttpService is either the HttpClient or any other provider of your choice, for example Firestore. The stackblitz project works with a local json array in mock-data folder.

The HttpService is where caching data and handling cache is supposed to go, this part is left out as it is not the scope of state management using RxJS.

import { ITransaction, Transaction } from '../services/transaction.model';
import { HttpService } from '../core/http';

@Injectable({ providedIn: 'root' })
export class TransactionService {
  private _listUrl = '/transactions';
  private _detailsUrl = '/transactions/:id';
  private _createUrl = '/transactions';
  private _saveUrl = '/transactions/:id';
  private _deleteUrl = '/transactions/:id';

  constructor(private _http: HttpService) {}

  GetTransactions(options: any = {}): Observable<ITransaction[]> {
    // we'll make use of options later
    const _url = this._listUrl;

    return this._http.get(_url).pipe(
      map((response) => {
        return Transaction.NewInstances(<any>response);
      })
    );
  }

  GetTransaction(id: string): Observable<ITransaction> {
    const _url = this._detailsUrl.replace(':id', id);
    return this._http.get(_url).pipe(
      map((response) => {
        return Transaction.NewInstance(response);
      })
    );
  }

  CreateTransaction(transaction: ITransaction): Observable<ITransaction> {
    const _url = this._createUrl;
    const data = Transaction.PrepCreate(transaction);

    return this._http.post(_url, data).pipe(
      map((response) => {
        return Transaction.NewInstance(<any>response);
      })
    );
  }

  SaveTransaction(transaction: ITransaction): Observable<ITransaction> {
    const _url = this._saveUrl.replace(':id', transaction.id);
    const data = Transaction.PrepSave(transaction);

    return this._http.put(_url, data).pipe(
      map((response) => {
        return transaction;
      })
    );
  }

  DeleteTransaction(transaction: ITransaction): Observable<boolean> {
    const _url = this._deleteUrl.replace(':id', transaction.id);

    return this._http.delete(_url).pipe(
      map((response) => {
        return true;
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Transaction Model, the basics

import { makeDate } from '../core/common';

export interface ITransaction {
  id: string; // important to extend IState interface
  date: Date;
  amount: number;
  category: string;
  label: string;
}

export class Transaction implements ITransaction {
  id: string;
  date: Date;
  amount: number;
  category: string;
  label: string;

  public static NewInstance(transaction: any): ITransaction {
    return {
      id: transaction.id,
      date: makeDate(transaction.date),
      amount: transaction.amount,
      category: transaction.category,
      label: transaction.label,
    };
  }

  public static NewInstances(transactions: any[]): ITransaction[] {
    return transactions.map(Transaction.NewInstance);
  }

  // prepare to POST
  public static PrepCreate(transaction: ITransaction): any {
    return {
      date: transaction.date,
      label: transaction.label,
      category: transaction.category,
      amount: transaction.amount,
    };
  }
  // prepare to PUT
  public static PrepSave(transaction: ITransaction): any {
    return {
      date: transaction.date,
      label: transaction.label,
      category: transaction.category,
      amount: transaction.amount,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

The Transaction State Service:

@Injectable({ providedIn: 'root' })
export class TransactionState extends StateService<ITransaction> {
  // one day, I will have a rich method that does something to state
 }
}
Enter fullscreen mode Exit fullscreen mode

Now inside the list component, all we have to do, is get transactions, and load state.

tx$: Observable<ITransaction[]>;
constructor(
    private txState: TransactionState,
    private txService: TransactionService
  ) {}

  ngOnInit(): void {
    this.tx$ = this.txService
      .GetTransactions()
      .pipe(switchMap((txs) => this.txState.SetList(txs)));
  }
Enter fullscreen mode Exit fullscreen mode

In the template, subscribe to your tx$

<ul  *ngIf="tx$ | async as txs">
  <li *ngFor="let tx of txs;">
    <div class="card">
        <div class="small light">{{tx.date | date}}</div>
        {{tx.label }}
        <div class="smaller lighter">{{ tx.category }}</div>
       <strong>{{ tx.amount }}</strong>
    </div>
  </li>
</ul>
Enter fullscreen mode Exit fullscreen mode

Updating state

To add an element, I am not going in details of the form that creates the new transaction, so we will create a random transaction upon clicking the button, but to make a point, in stackblitz project I will place these buttons in a child component.

append(): void {
    // this functionality can be carried out anywhere in the app
    this.txService.CreateTransaction(newSample()).subscribe({
      next: (newTx) => {
        // update state
        this.txState.addItem(newTx);
      },
      error: (er) => {
        console.log(er);
      },
    });
  }
  prepend(): void {
    // prepend to list
    this.txService.CreateTransaction(newSample()).subscribe({
      next: (newTx) => {
        // update state
        this.txState.prependItem(newTx);
      },
      error: (er) => {
        console.log(er);
      },
    });
  }
Enter fullscreen mode Exit fullscreen mode

Delete, cute and simple

 delete(tx: ITransaction): void {
    // this also can be done from a child component
    this.txService.DeleteTransaction(tx).subscribe({
      next: () => {
        this.txState.removeItem(tx);
      },
      error: (er) => {
        console.log(er);
      },
    });
  }
Enter fullscreen mode Exit fullscreen mode

Edit

 edit() {
    // steer away from bad habits, always clone
    const newTx = { ...this.tx, date: new Date() };
    this.txService.SaveTransaction(newTx).subscribe({
      next: () => {
        this.txState.editItem(newTx);
      },
      error: (er) => {
        console.log(er);
      },
    });
  }
Enter fullscreen mode Exit fullscreen mode

This was an example of a root service that gets loaded on a root component, but sometimes, there can be multiple individual instances, or a state of a single object. Coming up, I hope, I will dive a bit deeper with the pagination example.

What do you think? your comments and feedback is most welcome.

Resouces:

Discussion (0)