Skip to main content
gobeli

Angular Infinite Scroll

Angular resources are great to deal with asynchronous data like http requests. Inspired by TanStack Query, resources enhance async operations with metadata (loading state etc.) and utilities, while turning the value of the operation into a signal which can be used inside the Angular template or be further processed with computed signals.

Since resources allow providing a request computation which is reactive, things like paging become a breeze:

page = signal(0);
data = httpResource(() => `https://api.scryfall.com/cards/search?q=c%3Awhite+mv%3D1&page=${this.page()}`);

Each time the page signal is updated, the resource is reloaded with data from the new page.

Now what if you don't want to discard the previous value, but load additional data while keeping the previous result, like we do in an infinite scroll scenario. Currently, each time the page changes the new page is loaded and the existing data replaced.

Infinite scroll visualization

Unfortunately you can't just access the previous value of the resource and add the new page directly.

What we can do though is adding a linkedSignal which holds all data. Linked signals are, just like signals, writable, but they can depend on other state. They are useful in our case, because the previous value of the signal can be accessed and extended, which is not the case for computed signals.

allPages = linkedSignal<Card[], Card[]>({ 
  source: () => this.data.value()?.data ?? [], 
  computation: (source, prev) => (prev?.value ?? []).concat(source)
});

That's it, we now have all our data in the allPages signal and can access it in our template. Further we can use the httpResource to display a loading indicator:

<ul>
@for (item of allPages(); track item.id) {
	<li #item>{{item.name}}</li>
}
</ul>
@if (data.isLoading()) {
	<p>Loading...</p>
}

Now the infinite scroll is only an IntersectionObserver away. As you can see we have added a #item identifier to the li. This is done, so we can get the currently last item and load the next page as soon as it's in the viewport.

observer = new IntersectionObserver(entries => {
	if (entries[0].isIntersecting) {
		this.page.update(p => p + 1);
    }
});

loadOnEnd = effect(cleanup => {
	const el = this.cardElements().at(-1);
	if (el?.nativeElement) {
		this.observer.observe(el.nativeElement);
		cleanup(() => {
			this.observer.unobserve(el.nativeElement);
		});
	}
});

What is happening here? First, we create a IntersectionObserver, which updates the page count as soon as the observed element is in the viewport. Second, we use an effect to observe the last element anytime the template changes. The cleanup is done, so we don't trigger the update more than once for the same element. That's it, that's all you need! I think it's awesome how signals simplify dependent state.

The Code

import { httpResource } from '@angular/common/http';
import { Component, effect, ElementRef, linkedSignal, signal, viewChildren } from '@angular/core';

interface Card {
  name: string;
  id: string;
}

// scryfall search with a query with lots of results
const baseUrl = 'https://api.scryfall.com/cards/search?q=c%3Awhite+mv%3D1&page=';

@Component({
  selector: 'app-root',
  template: `
    <ul>
      @for (card of allPages(); track card.id) {
        <li #card>{{card.name}}</li>
      }
    </ul>
    @if (data.isLoading()) {
      <p>Loading...</p>
    }
  `,
  styles: [],
})
export class App {
  cardElements = viewChildren<ElementRef<HTMLLIElement>>('card');
  page = signal(1);
  data = httpResource<{ data: Card[] }>(
    () => `${baseUrl}${this.page()}`
  );
  allPages = linkedSignal<Card[], Card[]>({ 
    source: () => this.data.value()?.data ?? [], 
    computation: (source, prev) => (prev?.value ?? []).concat(source ?? [])
  });

  observer = new IntersectionObserver(entries => {
    if (entries[0].isIntersecting) {
      this.page.update(p => p + 1);
    }
  });

  loadOnEnd = effect(cleanup => {
    const el = this.cardElements().at(-1);
    if (el?.nativeElement) {
      this.observer.observe(el.nativeElement);
      cleanup(() => {
        console.log('cleanup')
        this.observer.unobserve(el.nativeElement);
      });
    }
  });
}