← All articles

An Extension Pattern for Flexible Angular Signal effect Management

Angular Signal effects skip reruns when you set the same value again. A trigger signal and refresh helper separate UI actions from data loading for refreshes and infinite scroll.

Published
An Extension Pattern for Flexible Angular Signal effect Management cover image

Angular Signal effect is handy, but it has a few pitfalls. In particular, many developers hit the case where it does not react when you set the same value again.
For example, consider code like this:

@Component({...})
class ExampleComponent {
  page = signal<number>(0);

  constructor() {
    effect(() => {
      // Runs when the page value changes
      console.log('page changed:', this.page());
    });
  }
}

In this code, the effect runs whenever page changes. Because it is memoized, setting the same value again does not run the effect.
In real apps, though, you sometimes want to run other work even when the value did not change. For example, you may want to reload data when page.set(0) fires. Pull-to-refresh with Ionic's ion-refresher is a typical case.

Implementing a Signal extension

To handle this, I built an extension that makes effect triggering explicit. You can implement it like this:

export const pageSignal = (): WritableSignal<number> & {
  trigger: WritableSignal<number>;
  refresh: () => void;
} => {
  const page  = signal<number>(0);
  const trigger = signal<number>(0);
  return Object.assign(page, {
    trigger,
    refresh: () => {
      page.set(0);
      trigger.update((val) => val + 1);
    },
  });
}

I added a trigger Signal so effect and computed can track changes even when setting page does not change its value. I also added a refresh method to update page and trigger together.

Use cases and benefits

The biggest benefit is separating user actions such as "go to next page" and "refresh" from data-fetch logic. In the traditional approach, page updates, reloads, and the fetch that follows are tightly coupled, which tends to make components complex.

For example, "reset the page to 0 and reload data" forces you to think about both state changes and fetching in the same place, which hurts readability.

@Component({...})
class ExampleComponent {
  private http = inject(HttpClient);

  page = signal<number>(0);
  items = signal<Item[]>([]);

  ngOnInit() {
    // Initial load
    this.loadData(this.page());
  }

  refresh(event: RefresherCustomEvent) {
    // Reset the page to 0 and reload the data
    this.page.set(0);
    this.loadData(this.page()).finally(() => event.target.complete());
  }

  loadMore(event: InfiniteScrollCustomEvent) {
    this.page.update(page => page + 1);
    this.loadData(this.page()).finally(() => event.target.complete());
  }

  private async loadData(page: number) {
    const data = await firstValueFrom(this.http.get<Item[]>('/api/users/' + page));
    this.items.update(items => {
        if (page === 0) {
          return [...data]; // Reset the data when the page is 0 or refresh was triggered
        } else {
          return [...items, ...data]; // Otherwise append to the existing items
        }
      });
  }
}

With the extension, you can centralize "when should data load?" in one place and treat page state changes as simple triggers. Because data refetches automatically from state changes, UI logic and data logic stay separate and the code is easier to maintain.

Practical example: infinite scroll

Here is a concrete infinite scroll example:

@Component({
  ...
  template: `
    <ion-content>
      <ion-refresher slot="fixed" (ionRefresh)="refresh($event)">
        <ion-refresher-content></ion-refresher-content>
      </ion-refresher>

      <ion-list>
        @for(item of items(); track item.id) {
          <ion-item>{{ item.name }}</ion-item>
        }
      </ion-list>

      <ion-infinite-scroll (ionInfinite)="loadMore($event)">
        <ion-infinite-scroll-content></ion-infinite-scroll-content>
      </ion-infinite-scroll>
    </ion-content>
  `
})
export class InfiniteScrollComponent {
  private http = inject(HttpClient);
  
  page = pageSignal();
  items = signal<Item[]>([]);
  completeEvent = signal<HTMLIonInfiniteScrollElement | HTMLIonRefresherElement>(undefined);

  constructor() {
    effect(async () => {
      // Runs initially, when the page value changes, or when refresh is triggered
      const [page, trigger] = [this.page(), this.page.trigger()];
      const data = await firstValueFrom(this.http.get<Item[]>('/api/users/' + page))
        .finally(() => this.completeEvent()?.complete()) // Call `complete` when a CustomEvent exists
      
      this.items.update(items => {
        if (page === 0) {
          return [...data]; // Reset the data when the page is 0 or refresh was triggered
        } else {
          return [...items, ...data]; // Otherwise append to the existing items
        }
      });
    });
  }
  
  refresh(event: RefresherCustomEvent) {
    this.page.refresh();
    this.#completeEvent.set(event.target);
  }

  loadMore(event: InfiniteScrollCustomEvent) {
    this.page.update(page => page + 1);
    this.#completeEvent.set(event.target);
  }
}

Summary

The core idea is separating state management from flexible control. In the traditional approach, page state and reload logic were tightly coupled. With this extension, page and trigger fully separate page state from data reloads.

Signal extensions make it easier to split UI logic from data logic. The pattern fits refresh and infinite scroll especially well, and I plan to use it actively in future work.

See you next time.