abracadabrax
abracadabrax

Reputation: 163

How to properly recalculate a boolean whenever the input observable changes?

I have a component to which I pass an object as its input.

That object comes from an API, so it is not available right away. And the object returned from the API can also be empty.

In the template, I want to hide the value unless it is not an empty object.

So I have this in my component:

import { Component, Input, OnChanges, SimpleChanges } from "@angular/core";
import { BehaviorSubject, Observable, of } from "rxjs";
import { tap } from "rxjs/operators";
import { not, isEmpty } from "ramda";

@Component({
  selector: "hello",
  template: `
    {{ hasName | async }} {{ name | async }}
    <h1 *ngIf="(hasName | async)">Hello {{ name | async }}!</h1>
  `,
  styles: [
    `
      h1 {
        font-family: Lato;
      }
    `
  ]
})
export class HelloComponent {
  @Input() name: Observable<{} | { first: string }> = of({});
  hasName: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);

  ngOnInit() {
    this.name.pipe(
      tap(name => console.log(name)),
      // the actual logic to calculate the boolean is quite involved, but I  
      // have simplified it here for brevity.
      tap(name => this.hasName.next(not(isEmpty(name)))) 
    );
  }
}

The problem is that the console.log in the tap is never printed and the second tap is never run either, so the value for hasName is always false as set at the of the class.

I think what happens is that in OnInit, this.name is still of({}), and not the actual observable passed in through the template from the parent.

I am using of({}) because typing this.name as Observable<{} | { first: string}> | undefined leads to more boilerplate in the class to check it is indeed defined before using it.

How can I make this work in an idiomatic way?

Complete Stackblitz: https://stackblitz.com/edit/angular-ivy-gxqh2d?devtoolsheight=33&file=src/app/hello.component.ts

Upvotes: 3

Views: 1025

Answers (3)

Michał Tkaczyk
Michał Tkaczyk

Reputation: 736

I'd suggest to move your logic to app.component.ts instead of passing the whole observable into an input. This could help you to manage the value of that input and prevent assigning a value to it two times (you have assigned an empty object two times in your code - parent & child component).

Simplest thing to make your code work is just to subscribe to the observable you've created inside your ngOnInit:

this.name.pipe(
  tap(name => console.log(name)),
  tap(name => this.hasName.next(not(isEmpty(name)))) 
).subscribe();

In case you don't want to manage that (don't want to be worried about non destroyed observables) and you prefer to use the async pipe, you can just reassign this.name inside ngOnInit.

this.name = this.name.pipe(
  tap(name => console.log(name)),
  tap(name => this.hasName.next(not(isEmpty(name)))) 
);

Upvotes: 0

maxime1992
maxime1992

Reputation: 23793

You can simplify this code a lot.

First off all, the TS definition.

{} | { first: string }

Can be done as

{ first?: string }

While it may look like I'm doing some neat picking here I'm not really. With the first option if you try to access variable.first you'll get an error saying it doesn't exist as the union cannot guarantee this. With the latter it's fine.

Then, the mock. When working with observables, try not to reassign observable. Here's how you can do what you where doing without reassigning:

  public name$: Observable<{ first?: string }> = concat(
    of({}),
    interval(2000).pipe(
      map(() => ({
        first: `${Math.random()}`
      }))
    )
  );

Finally, the child component. Passing observables as input is a code smell in 99% of the cases. Here all you want is a presentational component (also called dumb component).

This dumb component should not have any logic (as much as possible at least), and here's what you can do:

type Nil = null | undefined

@Component({
  selector: "hello",
  template: `
    <h1 *ngIf="name?.first">Hello {{ name.first }}!</h1>
  `
})
export class HelloComponent {
  @Input() name: { first?: string } | Nil;
}

Which means that when you call this from the parent, you just do:

<hello [name]="name$ | async"></hello>

Here's an updated stackblitz: https://stackblitz.com/edit/angular-ivy-hnv23a?file=src%2Fapp%2Fapp.component.ts

Upvotes: 1

martin
martin

Reputation: 96891

If you want to react when the input value changes you can write your own setter:

export class HelloComponent {
  @Input() set name(name: Observable<{} | { first: string }>) {
    this.name$ = name.pipe(
      tap(name => console.log(name)),
      tap(name => this.hasName.next(not(isEmpty(name))))
    );
  }

  name$: Observable<{} | { first: string }>;

  ...
}

Then in templates bind async to name$ instead.

Updated demo: https://stackblitz.com/edit/angular-ivy-7u7xkd?file=src%2Fapp%2Fhello.component.ts

Upvotes: 0

Related Questions