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.