8
votes

I feel a bit embarrassed asking this but I'm sure I'm missing something. Spent ages looking/researching but only come up with complex and convoluted solutions requiring subject or behavioursubjects or pipe through mergeMap, etc.

I'm moving from an imperative HTTP observable approach (subscribing manually) to a reactive HTTP observable approach (observable$ | async) in the component HTML template.

No problems getting it working with the async pipe when the page is initialised.

However, what is the best way to refresh the template "on the fly", i.e. I know that I've inserted a record into the underlying database and want this to be reflected in my template.

With the imperative approach I could simply subscribe again (from a click handler for example) and reassign the result to the property associated with the ngfor directive and change detection would fire and DOM updated.

The only solution I have come up with is to simply get the observable again from the service and this triggers change detection.

Just to make it clearer here is an exert of code (very simple just to focus on the solution).

There has to be a simple solution?

<div *ngIf="(model$ | async)?.length > 0" class="card mb-3 shadow">

  <div class="card-header">Actions Notes</div>

  <div class="card-body">

    <mat-list *ngFor="let action of model$ | async">
      <div class="small">
        <strong>
          {{action.createdDate | date:'dd/MM/yyyy HH:mm'}}
          {{action.createdBy}}
        </strong>
      </div>
      <div>{{action.notes}}</div>
    </mat-list>

  </div>
</div>

<button (click)="onClick()">name</button>
 model$: Observable<any>;

  constructor(
    private changeTransferActionsService: ChangeTransferActionsService
  ) {}

  ngOnInit(): void {
    this.getObservable();
  }

  private getObservable(): void {
    this.model$ = this.changeTransferActionsService
      .getActionsWithNotesById(this.model.id)
      .pipe(
        map((x) => x.result),
        share()
      );
  }

  onClick(): void {

    //insert row into database here (via HTTP post service call)

    this.getObservable();
  }

Just following up on my original question, here are three working solutions with explanations. Thanks to everybody that has responded.

Solution 1 - BehaviourSubject / SwitchMap

Use an RxJS BehaviorSubject with an arbitrary type value and initial value. Combine this with a higher order observable using flattening operator SwitchMap.

When the button is clicked the BehaviourSubject emits (next) a value (0 in this case), which is fed to the HTTP observable (passed value ignored) thus causing the inner observable to emit a value and change detection kicks in.

As a BehaviourSubject is used the observable emits a value on subscription.

In the component HTML template.

<button (click)="onClick()">Click Me</button>

In the component Typescript.

import { BehaviorSubject, Observable } from 'rxjs';
.
.
.
subject = new BehaviorSubject(0);
.
.
.

this.model$ = this.subject.asObservable().pipe(
      // startWith(0),
      switchMap(() => this.changeTransferActionsService
        .getActionsWithNotesById(this.model.id)
        .pipe(
          map((x) => x.result)
        )));
.
.
.
onClick(): void {
    this.subject.next(0);
  }

Solution 2 - Subject / SwitchMap / initialValue

Use an RxJS Subject Combine this with a higher order observable using flattening operator SwitchMap.

When the button is clicked the Subject emits (next) a value (undefined in this case), which is fed to the HTTP observable (passed value ignored) thus causing the inner observable to emit a value and change detection kicks in.

The Subject requires a startWith(0) otherwise the observable won't emit any values when the component is first initialised and first subscribed to by async pipe.

import {Observable, Subject } from 'rxjs';
.
.
.
subject = new Subject();
.
.
.
 this.model$ = this.subject.asObservable().pipe(
      startWith(0),
      switchMap(() => this.changeTransferActionsService
        .getActionsWithNotesById(this.model.id)
        .pipe(
          map((x) => x.result)
        )));
.
.
.
onClick(): void {
    this.subject.next(0);
  }
<button (click)="onClick()">Click Me</button>

Note that in both cases above the next method on the subject could have been invoked directly in the HTML component template thus had the subject had a public access modifier (the default anyway). This would have negated the need to have an onCLick method in the Typescript.

<button (click)="subject.next(0)">Click Me</button>

Solution 3 - fromEvent

As suggested by hanan

Add a template variable name to the button and use @ViewChild to get element reference. Create an RxJS observable using fromEvent and then use switchMap higher order observable approach.

.
.
.
 @ViewChild('refreshButton') refreshButton: ElementRef;
.
.
.

 ngAfterViewInit(): void {
    const click$ = fromEvent(this.refreshButton.nativeElement, 'click');

    this.model$ = click$.pipe(
      startWith(0),
      switchMap(_ => {
        return this.changeTransferActionsService
          .getActionsWithNotesById(this.model.id)
          .pipe(
            map((x) => x.result),
          );
      }));
  }


.
.
.
<button #refreshButton>Click Me</button>
.
.
.

In conclusion, solution 3 makes the most sense if a refresh is to be kicked off at will by the user via a click as it hasn't necessitated the need to introduce a "dummy" subject.

However, the subject approach makes sense of if the observable should emit programmatically from anywhere...by simply calling the next method on the subject the observable can emit values on command.

You know, there is much more to this topic and a truly reactive approach may start off your thinking process about using shared injectable singleton services and subscriptions based around subjects/behavioursubjects/asyncsubjects and so on. These are architectural and design decisions though. Thanks for all your suggestions.

1
When you say I know that I've inserted a record into the underlying database, does that mean that you subscribe somewhere to these database changes in your angular application?Alexander Staroselsky
The missing link is your event handler method. Instead of that call to onclick you can also use an observable. So completely remove the onClick handler and introduce a click$ Subject. In your template (click), simply do a click$.next(). Now you have another stream you can combine (with a switchMap) with your service call. The rest really stays the same, but your service observable is now refreshed everytime the click stream triggers.MikeOne
I asked because if you had a way via socket or whatever to get live changes from your database then a shared service with BehaviorSubject/Subject/AsyncSubject is going to be your best bet. The service can be your primary source of truth with component subscribing to observables exposed from the service. Anytime the underlying subject is updated, all existing and new subscribers will get the new data, avoiding to manually need to trigger another request. Services with subjects are your friend.Alexander Staroselsky
Thanks for your replies. Basically, let's say that I can insert a row into the database from the same component as the ngfor (described above). I've added a comment in the onClick() showing that the click also inserts a row. This is were I would like to trigger the refresh. Just after the insert. Using a behaviour subject via a service is an option I guess but it seems like to much code to do something so simple and I don't have any other subscribers.Robin Webb

1 Answers

1
votes

You can get refresh Btn reference and create stream from its click event and then start it with a dummy entry. Now you have a refresh stream, then switch it to your data stream like this:

Component

@ViewChild('refreshButton') refreshBtn:ElementRef;  
  model$: Observable<any>;

  ngAfterViewInit() {
    let click$ = fromEvent(this.refreshBtn.nativeElement, 'click')
    this.model$ = click$.pipe(startWith(0),
    switchMap(_=> {
       return this.changeTransferActionsService
      .getActionsWithNotesById(this.model.id)
      .pipe(
        map((x) => x.result),
      );
    }));
  }

Template

Instead of using 2 async pipes use only 1 with ngLet it will simplify your template and you don't have to use share():

<button #refreshButton >Refresh</button>

<ng-container *ngLet="(model$ | async) as model">

<div *ngIf="model.length > 0" class="card mb-3 shadow">

  <div class="card-header">Actions Notes</div>

  <div class="card-body">

    <mat-list *ngFor="let action of model">
      <div class="small">
        <strong>
          {{action.createdDate | date:'dd/MM/yyyy HH:mm'}}
          {{action.createdBy}}
        </strong>
      </div>
      <div>{{action.notes}}</div>
    </mat-list>

  </div>
</div>

<button (click)="onClick()">name</button>

</ng-container>

BUT Be pragmatic, don't use too much effort to make it fully reactive. Your earlier solution was also good, you just need improvements in your template.

Note: You can use *ngIf in this case instead of *ngLet