Reputation: 163
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
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
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
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