4

In my Angular 7 application I've incorporated some well known good practices and patterns. Some are OnPush change detection, takeUntil pattern to unsubscribe from Observables and I try to use Async Pipe as much as possible so Angular takes care for most of the stuff.

So my components are usually something like this:

In my.component.ts

data$: Observable<MyObject>;

ngOnInit() {
  this.data$ = this.myService.loadData().pipe(shareReplay());
}

In the template:

<ng-template #myLoader>Loading...</ng-template>
<div *ngIf="(data$ | async) as data; else myLoader">
  {{ data.label }}
</div>

And so I don't need to add separate Subject for the takeUntil pattern. But then if I have a Edit/Update form and I load data to fill the form. Then I need to save the entered data on form submit and I make a HTTP call for that again. On success I need the page to redirect so usually what I do is to subscribe to the observable and redirect there. So if I am subscribing I need to unsubscribe and I am back to the takeUntil again:

private unsubscribe$: Subject<any> = new Subject<any>();

ngOnDestroy(): void {
  this.unsubscribe$.next(null);
  this.unsubscribe$.unsubscribe();
}

submit(value) {
  this.myService
    .update(value)
    .pipe(takeUntil(this.unsubscribe$))
    .subscribe(_ => this.router.navigate(['/somewhere']));
}

Is there a way to fully remove the need of the takeUntil and just use Async pipes. What I ideally want is to have the async pipe in the ngSubmit like so:

<form (ngSubmit)="submit(value) | async">

but since this is not possible is there another way?

Some related resources:

2 Answers 2

1

Fun fact. If you use the HttpClient that Angular provides, you do not need to unsubscribe. After a response is received, it calls the complete() method.

1
  • 1
    While this is true, there are other benefits of unsubscribing of any observable. It's not about memory leaks anymore but about mitigating side effects - ex. you execute a query with HttpClient but then you decide to navigate to another page. Without unsubscribe any logic on completion will be still executed, but if you do unsubscribe you'll abort the underlying XHR and remove all listeners.
    – nyxz
    Commented Mar 15, 2019 at 9:39
0

A little late but yes, this is possible. You can expose all of your component state through a single Observable<ViewModel> and then use RxJs operators in the component to aggregate whatever is needed by your template. In your template you use the | async as model and it feels a lot like 'normal' values.

Here's an example of a form that populates a div with data from a service on button click. Notice that there are no RxJs Subscriptions or hooks into the angular lifecycle like OnInit. Nothing happens until subscription, which angular handles, so its fine to do all this in our constructors. I'd go so far to say that if you can't make your template work with readonly members you aren't really reactive.

Also note the single submitting$ drives everything: spinners, http request, enabled states, etc all 'react' to that one input. That means all those things stay in sync by default and we can do things like debounce in that one place. In this case combineLatest makes it easy but there's usually some operator that does what you need :)

import { Injectable, Component, ChangeDetectionStrategy } from '@angular/core';
import { BehaviorSubject, combineLatest, filter, map, switchMap, tap, Observable, startWith, of, delay } from 'rxjs';
import { FormBuilder } from '@angular/forms';

@Injectable({ providedIn: 'root' })
export class ExampleService {

  private count = 0;

  getExampleData(request: ExampleRequest): Observable<ExampleData> {
    return of({
      userName: request.userName,
      secret: "" + this.count++
    }).pipe(delay(2000));
  }
}

@Component({
  selector: 'app-example',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
  <ng-container *ngIf="viewModel$ | async as model">
    <form [formGroup]="exampleForm"
          (ngSubmit)="onSubmit()">
        <div>
            <label>
                User Name:
                <input formControlName="userName"
                       placeholder="User Name">
            </label>
        </div>
        <button type="submit">Send</button>
    </form>
    <hr>
    <ng-template *ngIf="model.submitting;else foo">
        Submitting...
    </ng-template>
    <hr>
    <ng-template #foo>
        User Details:
        <ul>
            <li>User Name: {{model.exampleData.userName}}</li>
            <li>User Secret: {{model.exampleData.secret}}</li>
        </ul>
    </ng-template>
</ng-container>
  `,
  styleUrls: ['./example.component.css']
})
export class ExampleComponent {
  constructor(private fb: FormBuilder,
    private exampleService: ExampleService) { }

  exampleForm = this.fb.nonNullable.group({
    userName: ''
  })

  private readonly submitting$ = new BehaviorSubject(false)
  private readonly exampleResponse$ = this.submitting$.pipe(
    filter(Boolean),
    map(_ => this.exampleForm.value as ExampleRequest),
    switchMap(exampleRequest => this.exampleService.getExampleData(exampleRequest)),
    tap(_ => this.submitting$.next(false))
  )

  readonly viewModel$: Observable<ExampleComponentViewModel> = combineLatest(
    [this.exampleResponse$.pipe(startWith({ userName: "", secret: "" })),
    this.submitting$],
    (exampleData, submitting) => ({ exampleData, submitting }))

  onSubmit() {
    this.submitting$.next(true)
  }
}

export interface ExampleComponentViewModel {
  exampleData: ExampleData
  submitting: boolean
}

export interface ExampleRequest {
  userName: string,
}

export interface ExampleData {
  userName: string,
  secret: string
}

I've obviously ignored things like errors, etc but this hopefully points you in the right direction.

Not the answer you're looking for? Browse other questions tagged or ask your own question.