Ivo Udelsmann
Ivo Udelsmann

Reputation: 386

Angular signal inputs effect to load data from server

I'm migrating an Angular aplication to use signals 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

Answers (3)

Daniel Gimenez
Daniel Gimenez

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>

Stackblitz

Upvotes: 1

Naren Murali
Naren Murali

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);

Stackblitz Demo


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);

Stackblitz Demo

Upvotes: 1

Chellappan வ
Chellappan வ

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

Related Questions