Magnus Wolffelt
Magnus Wolffelt

Reputation: 502

Angular Signals: What's the proper way to trigger a fetch when input Signals change value?

So I've been learning and using Signals in Angular, and it's exciting. However, there are some use cases where I feel there's some friction. I can't figure out a good pattern when you have a component with input signals, and you want to trigger a re-fetch of data whenever some input value changes.

computed is obviously not the way to go since they can't be async. And effect, according to the docs, shouldn't modify component state. So that seems like a no-go as well. And ngOnChanges is being deprecated (long term) in favor of Signals-based components and zoneless.

Consider the following component:

@Component()
export class ChartComponent {
    dataSeriesId = input.required<string>();
    fromDate = input.required<Date>();
    toDate = input.required<Date>();

    private data = signal<ChartData | null>(null);
}

Whenever one of the input signals gets a new value, I want to trigger a re-fetch of data, and update the value of the private data signal.

How would one go about this? What's the best practice? Effect and bypass the rule to modify state?

Upvotes: 20

Views: 13449

Answers (5)

Rens Jaspers
Rens Jaspers

Reputation: 1962

Angular 19 and later

Use Angular's rxResource, like this:

import { rxResource } from "@angular/core/rxjs-interop";

@Component({
  selector: "app-chart",
  template: `
    {{ resource.value() }}
    @if(resource.isLoading()) { Loading... } 
    @if(resource.error(); as error) { {{ error }} }
  `,
})
export class ChartComponent {
  dataSeriesId = input.required<string>();
  fromDate = input.required<Date>();
  toDate = input.required<Date>();

  params = computed(() => ({
    id: this.dataSeriesId(),
    from: this.fromDate(),
    to: this.toDate(),
  }));

  resource = rxResource({
    request: this.params,
    loader: (loaderParams) => this.fetchData(loaderParams.request),
  });

  private fetchData({ id, from, to }: { id: string; from: Date; to: Date }) {
    return of(`Example data for id [${id}] from [${from}] to [${to}]`).pipe(
      delay(1000)
    );
  }
}

Any change to the inputs will trigger a new fetch, cancelling the previous one. If fetchData uses Angular's HttpClient, the ongoing HTTP request will be automatically cancelled.

Working example on StackBlitz

Before Angular 19

Use Angular's rxjs-interop to convert the input signals to an Observable, then switchMap to fetch the results and then convert the result back to a signal, like this:

import { toObservable, toSignal } from "@angular/core/rxjs-interop";

@Component({
  selector: "app-chart",
  standalone: true,
  template: ` {{ data() }} `,
})
export class ChartComponent {
  dataSeriesId = input.required<string>();
  fromDate = input.required<Date>();
  toDate = input.required<Date>();

  params = computed(() => ({
    id: this.dataSeriesId(),
    from: this.fromDate(),
    to: this.toDate(),
  }));

  data = toSignal(
    toObservable(this.params).pipe(
      switchMap((params) => this.fetchData(params))
    )
  );

  private fetchData({ id, from, to }: { id: string; from: Date; to: Date }) {
    return of(`Example data for id [${id}] from [${from}] to [${to}]`).pipe(
      delay(1000)
    );
  }
}

Any change to any of the inputs will trigger a new fetch.

Working example on StackBlitz

Upvotes: 16

manuelkue
manuelkue

Reputation: 122

Since Angular 19 you can use the Resource API (experimental in v19)

In its simplest form it allows you to fetch data and generates a Resource.

import {resource} from '@angular/core';

const newResource = resource({
  loader: () => fetch("someAPI").then((res) => res.json()),
});

// Example usage of the result in an effect:
effect(() => console.log(newResource.value()));

In a more realistic scenario you would probably want to trigger the fetch each time a sourceSignal changes:

const userId = signal<undefined | string>(undefined);

const userResource = resource({
  request: () => userId(),
  loader: (request) => fetch(`someUserAPI/${request}`).then((res) => res.json()),
});

Now each time the Signal userId changes the loader will be executed.

Be aware that if the request signal computes to undefined the loader will not be executed and the resource status will become Idle.

To work with the resource's response you have several signal properties: value, hasValue, isLoading, status which can be used as regular signals, e.g. in compute, effect and in the template.

Read more to this new API here: https://angular.dev/guide/signals/resource

Upvotes: 2

ymajoros
ymajoros

Reputation: 2738

Using this utility function: https://ngxtension.netlify.app/utilities/signals/derived-async/ you can achieve it quite elegantly (fetching is a one-liner):

import { toObservable, toSignal } from '@angular/core/rxjs-interop';

@Component({
  selector: 'app-chart',
  standalone: true,
  template: `
    {{data()}}
  `,
})
export class ChartComponent {
  dataSeriesId = input.required<string>();
  fromDate = input.required<Date>();
  toDate = input.required<Date>();

  data = derivedAsync(() => this.fetchData$(dataSeriesId(), fromDate(), toDate());

  private fetchData$(id: string, from: Date, to: Date) {
    return of(`Example data for id [${id}] from [${from}] to [${to}]`).pipe(
      delay(1000)
    );
  }
}

(derived from Rens Jaspers' answer)

Upvotes: 3

Danylo Gudz
Danylo Gudz

Reputation: 2656

If you need some action to be done when signal value is changed you need to use effect from @angular/core.

All these two way convertions from/to observable look more like a workaround but not for your case since it can be implemented in a way that is suggested to be done.

In your case what you want and need to do is to create an effect that "watches" your input signals' values changes and run your request and set resulting data as a result into your signal:

    effect(
      () => {
        this.fetch(
          this.dataSeriesId(),
          this.fromDate(),
          this.toDate()
        ).subscribe((res) => {
          this.data.set(res);
        });
      },
      { allowSignalWrites: true }
    );

Fully working example: https://stackblitz.com/edit/stackblitz-starters-r6qwly?file=src%2Fmain.ts

upd. in case of different time of fetch to fulfil you might end up with wrong data, then also need to cancel old subscription whenever params change:

    let dataLoadSub: Subscription | null = null;
    effect(
      () => {
        dataLoadSub?.unsubscribe();
        dataLoadSub = this.fetch(
          this.dataSeriesId(),
          this.fromDate(),
          this.toDate()
        ).subscribe((res) => {
          this.data.set(res);
        });
      },
      { allowSignalWrites: true }
    );

Upvotes: 1

Mario B
Mario B

Reputation: 2357

The accepted answer does work ofc, but for me is a bit of a hacky workaround. A big point of signals is to limit the usage of RXJS in Angular. They are even talking about making RXJS completely optional in the future. This would greatly lower the barrier of entry for new developers.

I recommend using the writable-signal approach. You need to be a bit careful not to create circles, but in the case you describe (and many others) it should work just fine.

I think right now (2024/06) Angular signals are still a bit "rough around the edges" - there does not seem to be a really satisfying solution for this common use case. I am sure this will change in the future.

Upvotes: 0

Related Questions