Reputation: 386
I'm migrating an Angular aplication to use signal
s and input
signals, but found a situation I'm not satisfied with.
With previous @Input()
I could define a setter to trigger an "effect" when a specific input changed, and I would setup an Observable
for a request to the server, something like
@Input()
set id(value: number) {
this.loading = true;
this.entity$ = this.service.findById(value).pipe(finalize(() => {
this.loading = false;
}));
}
I like to avoid using ngOnChanges
as I can avoid validating which input changed.
With signals the closest I could find is to use an effect
to trigger the query, but the documentation doesn't really recommend it
Effects are rarely needed in most application code, but may be useful in specific circumstances
Avoid using effects for propagation of state changes. This can result in ExpressionChangedAfterItHasBeenChecked errors, infinite circular updates, or unnecessary change detection cycles.
Because of these risks, setting signals is disallowed by default in effects, but can be enabled if absolutely necessary.
As I have to set the loading
signal to true, I would be breaking the last part
effect(() => {
this.loading.set(true);
this.entity$ = this.service.findById(value).pipe(finalize(() => {
this.loading.set(false);
}));
})
I have also found a way using toObservable()
on the input signal, but it uses effetc()
under the hood so I suppose I'm limited in the same way.
Is there a recommended approach? Am I dependent of ngOnChanges
for this scenarios?
Upvotes: 2
Views: 1545
Reputation: 20599
Another option is to use the library Angular Signal Generators which has a set of custom signals to tackle awkward situations such as the one in your post. (full disclosure: I am the maintainer of this library).
In your case I would use the asyncSignal to merge changes to your input signal with the call to the service that returns an observable. Additionally, instead of writing out to another signal to track the loading state, just create an object that includes the load status, and update the process status as part of the observable stream.
Component
export class ChildComponent {
private readonly service = inject(TestService);
readonly $id = input<string | undefined>(undefined, { alias: 'id' });
readonly $entityState = asyncSignal<EntityState>(
() => {
const id = this.$id();
return id
? this.service.findById(id).pipe(
map((entity) => ({ entity, loading: false })),
startWith({ loading: true }),
)
: of({ loading: false });
},
{ defaultValue: { entity: undefined, loading: false } }
);
}
Template
<div>
<b>Loading</b> - {{$entityState().loading}}
</div>
<div>Loaded:
@if ($entityState().entity; as entity) {
#{{entity.id}} {{entity.name}}
}
@else {
Nothing
}
</div>
Upvotes: 1
Reputation: 58031
You don't even need a second signal, just use the async pipe combined with @if
and @else
and the loading happens without any extra code.
import { AsyncPipe, CommonModule } from '@angular/common';
import {
Component,
inject,
Injectable,
input,
InputSignal,
effect,
} from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { of, delay, Observable } from 'rxjs';
import 'zone.js';
@Injectable({ providedIn: 'root' })
export class TestService {
findById(id: string): Observable<any> {
return of({ id: Math.random().toString(), name: 'test' }).pipe(delay(2000));
}
}
@Component({
selector: 'app-child',
standalone: true,
imports: [CommonModule],
template: `
@if((entity$ | async); as data) {
API DONE -> for ID {{id()}}: {{data | json}}
} @else {
Loading...
}
`,
})
export class Child {
service = inject(TestService);
id: InputSignal<string | null> = input<string | null>(null);
loading = false;
entity$: Observable<any> = of(null);
constructor() {
effect(() => {
console.log('running effect');
const id = this.id();
if (id) {
this.entity$ = this.service.findById(id);
}
});
}
ngOnInit() {}
}
@Component({
selector: 'app-root',
standalone: true,
imports: [Child],
template: `
<app-child [id]="id"/>
`,
})
export class App {
id: string = Math.random().toString();
constructor() {
setInterval(() => {
this.id = Math.random().toString();
}, 5000);
}
}
bootstrapApplication(App);
One way to solve this is to convert the signal to an observable using toObservable
then we can apply a switchMap and make the API call, since there is no restrictions on observables, we can set the loading signal if we want!
import { AsyncPipe, CommonModule } from '@angular/common';
import {
Component,
inject,
Injectable,
input,
InputSignal,
signal,
} from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { bootstrapApplication } from '@angular/platform-browser';
import { of, delay, Observable } from 'rxjs';
import { finalize, switchMap } from 'rxjs/operators';
import 'zone.js';
@Injectable({ providedIn: 'root' })
export class TestService {
findById(id: string): Observable<any> {
return of({ id: Math.random().toString(), name: 'test' }).pipe(delay(2000));
}
}
@Component({
selector: 'app-child',
standalone: true,
imports: [CommonModule],
template: `
{{loading()}}
@if(!loading() && (observable$ | async); as data) {
API DONE -> for ID {{id()}}: {{data | json}}
} @else {
Loading...
}
`,
})
export class Child {
service = inject(TestService);
id: InputSignal<string | null> = input<string | null>(null);
loading = signal(false);
observable$ = toObservable(this.id).pipe(
switchMap((id: string | null): any => {
this.loading.set(true);
return id
? this.service.findById(id).pipe(
finalize(() => {
this.loading.set(false);
})
)
: of(false);
})
);
ngOnInit() {}
}
@Component({
selector: 'app-root',
standalone: true,
imports: [Child],
template: `
<app-child [id]="id"/>
`,
})
export class App {
id: string = Math.random().toString();
constructor() {
setInterval(() => {
this.id = Math.random().toString();
}, 5000);
}
}
bootstrapApplication(App);
Upvotes: 1
Reputation: 27461
As mentioned in the Signal RFC, writing signals from effects can lead to unexpected behavior and make data flow difficult to follow.This default behavior can be overridden by passing the allowSignalWrites option to the effect creation function.
You can create reusable function for this function accepts an array of signal dependencies and a function that returns an observable
function fromEffect<
T,
const Deps extends Signal<any>[],
Values extends {
[K in keyof Deps]: Deps[K] extends Signal<infer T> ? T : never;
}
>(deps: Deps, source: (...values: Values) => Observable<T>, options?: Options) {
!options?.injector && assertInInjectionContext(fromEffect);
const injector = options?.injector ?? inject(Injector);
const sig = signal<T | undefined>(undefined);
effect(
(onCleanup) => {
const values = deps.map((dep) => dep()) as Values;
const sub = source(...values).subscribe((value) => {
sig.set(value);
});
onCleanup(() => sub.unsubscribe());
},
{ injector, allowSignalWrites: true }
);
return sig.asReadonly();
}
For Detailed explanation you can read the original blog here
this.entity$ = fromEffect([this.id],id => this.service.findById(id).pipe(finalize(() => {
this.loading.set(false); })))
Upvotes: 1