Implement Angular infinite scroll as a standalone directive on a sentinel element that observes it outside the zone, emits a loadMore output only when the sentinel is visible and the host says it is not already loading, and re-checks after each load so short pages keep filling the container.
Problem / Scenario Context
An Angular order-history page uses a popular infinite-scroll library built on scroll events. It fires duplicate requests on fast scrolls, runs change detection on every scroll event, and does not work inside the Material side panel where the list actually scrolls. The team wants a small, dependency-free directive based on an IntersectionObserver sentinel that behaves correctly with the panel's scroll container, signals-based state and the app's existing RxJS data services.
The general algorithm is in infinite scroll & pagination; the container case in infinite scroll inside a scrollable container. This page packages it as an Angular directive, building on the Angular Observer Directives topic.
Mechanics Explanation
The directive has a narrow job: turn "sentinel near the viewport" into "request the next page", exactly once per page. Three pieces of state decide whether to emit:
- Visible — the sentinel intersects the root (with a margin), tracked from the observer.
- Loading — the host is fetching; supplied as an input so the directive does not own data state.
- Done — there are no more pages; also an input.
Emission happens when visible becomes true while not loading and not done, and again when loading turns false while the sentinel is still visible — the second case is what fills a container whose first page was too short to scroll. An Angular effect() reacting to the loading input covers it neatly.
The observer runs outside the zone (running observer callbacks outside NgZone); only the emission re-enters, because it starts a data load that will change template state.
Comparison Table: Infinite Scroll Implementations in Angular
| Implementation | Change detection while scrolling | Duplicate loads | Scroll containers | Short first page |
|---|---|---|---|---|
| Scroll-event library, inside zone | every event | possible | configuration-dependent | often stalls |
| IO sentinel inside zone | every crossing | possible without guard | with root |
stalls |
| IO sentinel outside zone + loading input | only on emit | prevented | with root input |
re-check effect |
CDK virtual scroll scrolledIndexChange |
per index change | handled by you | built in | n/a |
Minimal Reproducible Example
@Directive({ selector: '[infiniteSentinel]', standalone: true })
export class InfiniteSentinel implements OnInit, OnDestroy {
@Output() loadMore = new EventEmitter<void>();
private io?: IntersectionObserver;
constructor(private el: ElementRef) {}
ngOnInit() {
this.io = new IntersectionObserver(([e]) => e.isIntersecting && this.loadMore.emit()); // inside zone, no guard
this.io.observe(this.el.nativeElement);
}
ngOnDestroy() { this.io?.disconnect(); }
}
Inside the side panel it measures against the viewport, a fast scroll emits twice before the first request returns, and a short first page never loads the second.
Production-Safe Solution
import {
Directive, ElementRef, NgZone, DestroyRef, inject, input, output, effect, signal, afterNextRender,
} from '@angular/core';
@Directive({ selector: '[infiniteSentinel]', standalone: true })
export class InfiniteSentinelDirective {
readonly root = input<HTMLElement | null>(null); // scroll container, or null for the viewport
readonly margin = input('400px');
readonly loading = input(false);
readonly done = input(false);
readonly loadMore = output<void>();
private visible = signal(false);
private el = inject(ElementRef<HTMLElement>);
private zone = inject(NgZone);
constructor() {
const destroyRef = inject(DestroyRef);
afterNextRender(() => {
const io = this.zone.runOutsideAngular(() => new IntersectionObserver(([e]) => {
this.visible.set(e.isIntersecting);
this.maybeEmit();
}, { root: this.root(), rootMargin: this.margin() }));
io.observe(this.el.nativeElement);
destroyRef.onDestroy(() => io.disconnect());
});
// When a load finishes and the sentinel is still visible (short page), load again.
effect(() => {
if (!this.loading()) queueMicrotask(() => this.maybeEmit());
});
}
private maybeEmit(): void {
if (!this.visible() || this.loading() || this.done()) return;
this.zone.run(() => this.loadMore.emit()); // data load changes template state
}
}
// order-history.component.ts
@Component({
selector: 'app-order-history',
standalone: true,
imports: [InfiniteSentinelDirective],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="panel-scroll" #panel>
@for (o of orders(); track o.id) { <app-order-row [order]="o" /> }
<div infiniteSentinel [root]="panel" [loading]="loading()" [done]="done()"
(loadMore)="next()" aria-hidden="true"></div>
@if (loading()) { <p role="status">Loading more orders…</p> }
</div>`,
})
export class OrderHistoryComponent {
private api = inject(OrdersApi);
readonly orders = signal<Order[]>([]);
readonly loading = signal(false);
readonly done = signal(false);
private page = 0;
next(): void {
this.loading.set(true);
this.api.page(++this.page).subscribe({
next: (rows) => { this.orders.update((o) => [...o, ...rows]); this.done.set(rows.length === 0); },
complete: () => this.loading.set(false),
error: () => { this.page--; this.loading.set(false); },
});
}
}
interface Order { id: string }
declare class OrdersApi { page(n: number): import('rxjs').Observable<Order[]> }
The loading input is the duplicate-load guard: the directive cannot emit while the host is fetching. The root input receives the panel element through a template reference variable, so visibility is measured against the panel. track o.id keeps existing rows in place as pages append. The role="status" message gives screen-reader users feedback, which announcing infinite scroll loads with aria-live develops further.
Testing the Directive
Unit tests with TestBed can drive the directive without a real layout by replacing IntersectionObserver with a controllable fake that records the callback and lets the test deliver entries:
class FakeIO {
static last: FakeIO;
constructor(public cb: IntersectionObserverCallback) { FakeIO.last = this; }
observe() {} unobserve() {} disconnect() {} takeRecords() { return []; }
fire(isIntersecting: boolean) { this.cb([{ isIntersecting } as IntersectionObserverEntry], this as never); }
}
it('emits once per page and re-checks after a short page', async () => {
(globalThis as any).IntersectionObserver = FakeIO;
const fixture = TestBed.createComponent(HostComponent); // host binds loading and (loadMore)
fixture.detectChanges();
await fixture.whenStable(); // afterNextRender has run
FakeIO.last.fire(true);
expect(fixture.componentInstance.calls).toBe(1);
fixture.componentInstance.loading.set(false); // short page finished, still visible
fixture.detectChanges(); await fixture.whenStable();
expect(fixture.componentInstance.calls).toBe(2);
});
Real scrolling behaviour — margins against the panel, fling scrolling — belongs in a Playwright test against the running app.
Verification Steps
- Fling-scroll the panel and confirm exactly one request per page in the Network panel.
- Use a page size of 3 and confirm the panel fills itself until it can scroll.
- Profile with Angular DevTools: change detection should run only when pages load.
- Reach the end and confirm no further requests after
donebecomes true. - Simulate a failed request and confirm scrolling again retries the same page.
Common Mistakes to Avoid
- Letting the directive own the loading state. The host knows when a fetch finishes; pass it in.
- Observing against the viewport when the list scrolls in a panel.
- Emitting inside the zone on every crossing. Only the emission that starts a load needs the zone.
- Tracking rows by index. Appending then re-renders more than necessary.
FAQ
Why is loading an input rather than internal state?
The directive cannot know when the host's request finishes. Passing loading in lets the host remain the single source of truth and makes the duplicate-load guard exact.
How does the directive fill a container whose first page is short?
An effect watches the loading input. When it returns to false and the sentinel is still visible, the directive emits again, repeating until the list overflows the container or the data is done.
Why queueMicrotask inside the effect?
It defers the emission until after the effect run completes, so the host's signal writes happen outside the effect's reactive context and the latest visibility value is used.
Can I use it with the CDK virtual scroll viewport?
For virtual scroll, prefer the viewport's scrolledIndexChange or rendered range to trigger loading, since the sentinel may be recycled. The directive suits ordinary, non-virtualised lists.
Does it work with server-side rendering?
Yes. afterNextRender runs only in the browser, so no observer is created during server rendering, and the first page can be server-rendered normally.
How do I support RxJS-based components without signals?
Pass loading and done from observables with the async pipe, or convert them with toSignal. The directive only needs current boolean values.
Related
- Running Observer Callbacks Outside NgZone — why the observer stays outside
- Preventing Duplicate Fetches in Observer-Driven Pagination — the guard in general
- Lazy Loading Images with an Angular IntersectionObserver Directive — a sibling directive
↑ Back to Angular Observer Directives