Home > Software engineering >  How to avoid using subscriptions and BehaviorSubject.value using a declarative approach in rxjs
How to avoid using subscriptions and BehaviorSubject.value using a declarative approach in rxjs

Time:11-08

While refactoring my angular application I basically want to get rid of all subscriptions in order to use only async pipe provided by angular (just a declarative approach instead of an imperative one).

I have problems to implement a declarative approach when multiple sources can lead to changes in the stream. If we only had one source then of course, I could just use scan operator to build up my emitted values.

Scenario

Let's say I just want to have a simple component, where an array of strings is resolved during routing. In the component I want to display the list and want to be able to add or remove items using buttons.

Limitations

  1. I don't want to use subscribe, since I want angular to take care of unsubscription using (async pipe)
  2. I don't want to use BehaviorSubject.value, since it's (from my point of view) an imperative approach instead of a declarative one
  3. Actually I don't want to use any kind of subject at all (apart from the ones used for button click event propagation), since I don't think it is necessary. I should already have all needed observables, which just have to be "glued together".

Current process so far My journey so far took several steps. Please note that all approaches worked fine, but each has their individual downsights):

  1. Usage of BehaviorSubject and .value to create the new array --> not declarative
  2. Trying scan operator and create an Action interface, where each button emits an action of type XY. This action would be read inside the function passed to scan and then use a switch to determine which action to take. This felt a little bit like Redux, but it was a strange feeling to mix different value types in one pipe (first initial array, afterwards actions).
  3. My so far favorite approach is the following: I basically mimic a BehaviorSubject by using shareReplay and use this instantly emitted value in my button, by switching to a new observable using concatMap, where I only take 1 value in order to prevent creating a loop. Example implementation mentioned below:

list-view.component.html:

<ul>
  <li *ngFor="let item of items$ | async; let i = index">
    {{ item }} <button (click)="remove$.next(i)">remove</button>
  </li>
</ul>

<button (click)="add$.next('test2')">add</button>

list-view.component.ts

  // simple subject for propagating clicks to add button, string passed is the new entry in the array
  add$ = new Subject<string>();

  // simple subject for propagating clicks to remove button, number passed represents the index to be removed
  remove$ = new Subject<number>();

  // actual list to display
  items$: Observable<string[]>;

  constructor(private readonly _route: ActivatedRoute) {
    // define observable emitting resolver data (initial data on component load)

    // merging initial data, data on add and data on remove together and subscribe in order to bring data to Subject
    this.items$ = merge(
      this._route.data.pipe(map((items) => items[ITEMS_KEY])),

      // define observable for adding items to the array
      this.add$.pipe(
        concatMap((added) =>
          this.items$.pipe(
            map((list) => [...list, added]),
            take(1)
          )
        )
      ),

      // define observable for removing items to the array
      this.remove$.pipe(
        concatMap((index) =>
          this.items$.pipe(
            map((list) => [...list.slice(0, index), ...list.slice(index   1)]),
            take(1)
          )
        )
      )
    ).pipe(shareReplay(1));
  }

Nevertheless I feel like this should be the easiest example possible and my implementation seems to complex for this kind of issue. It would be great if someone could help in finding a solution to this, what should be a simple, problem.

You can find a StackBlitz example of my implementation here: https://stackblitz.com/edit/angular-ivy-yj1efm?file=src/app/list-view/list-view.component.ts

CodePudding user response:

To start, you don't need subjects to propagate HTML events.

Under the hood, Angular uses an EventEmitter, which is basically a subject, to propagate changes throughout the app.

So this

<button (click)="remove$.next(i)">remove</button>

Should become this

<button (click)="removeItem(item, i)">remove</button>

Next, for the route data, you can use simple operators to create a subject for them

routeData$ = this._route.data.pipe(pluck('ITEMS_KEY'), shareReplay(1)),

Now, this gives you a cleaner code for your component :

routeData$ = this._route.data.pipe(pluck('ITEMS_KEY'), shareReplay(1)),

constructor(private readonly _route: ActivatedRoute) {}

addItem(item: any) {
  // ...
}

removeItem(item: any) {
  // ...
}

Finally, you have to decide how this affects your data. What do addItem and removeItem should do in fine ? You have several options there, for instance :

  • Make http calls to your API
  • Update your application state
  • Redirect to the same route, but update the data / route params, etc

With this, you should be able to get rid of all subscriptions and let Angular do the work for you.

Even better, you can now switch to the OnPush detection strategy and greatly increase your application performance !

CodePudding user response:

This is my take on it: https://stackblitz.com/edit/angular-ivy-nsxabg?file=src/app/list-view/list-view.component.ts

I think having Subjects for simulating events is an overkill and if you just create new state for the items$ observable in two functions you have a neat solution.

CodePudding user response:

You can create a modifications$ stream that takes each emission from your "modification subjects", and maps them to a function that will modify the state accordingly:

export class AppComponent {
  add$ = new Subject<string>();
  remove$ = new Subject<number>();

  private modifications$ = merge(
    this.add$.pipe(map(item => state => state.concat(item))),
    this.remove$.pipe(map(index => state => state.filter((_, i) => i !== index))),
  );

  private routeData$ = this.route.data.pipe(map(items => items[ITEMS_KEY]));

  items$ = this.routeData$.pipe(
    switchMap(items => this.modifications$.pipe(
      scan((state, fn) => fn(state), items),
      startWith(items)
    ))
  );

  constructor(private route: ActivatedRoute) { }
}

Here we define items$ to start with the route data, then switch to a stream that applies the incoming reducer functions to the state. We use the initial items from route data as our seed value inside scan. We also use startWith to initially emit the initial items.

Here's a little StackBlitz sample.

  • Related