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.

When the Directive Emits loadMoreFive steps. The sentinel becomes visible within the root margin. If the host is not loading and not done, the directive emits loadMore inside the zone. The host sets loading true and fetches the next page. When loading returns to false, an effect re-checks visibility. If the sentinel is still visible because the page was short, it emits again; otherwise it waits for the next crossing.1Sentinel visibleWithin the root and rootMargin.2Not loading, not doneEmit loadMore via zone.run.3Host fetchesloading = true; page appended.4loading → falseEffect re-checks the latest visibility.5Still visible?Emit again for short pages; otherwise wait.

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

TypeScript
@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

TypeScript
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
  }
}
TypeScript
// 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.

Directive and Host ResponsibilitiesFour boxes. The directive, outside the zone, tracks sentinel visibility and decides when to emit. The host component owns the data, the page counter and the loading and done signals. The data service fetches pages as observables. The template renders rows with track by id and passes loading, done and the panel root back into the directive.Directivevisibility + emit ruleHost componentpage, loading, donesignalsData servicepage(n) observableTemplaterows + sentinel inputs

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:

TypeScript
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.

What Each Test Level CoversA grid of test levels and what each verifies. Unit tests with a fake observer verify the emit rule, the loading guard and the short-page re-check. Component tests with TestBed verify inputs, outputs and template wiring. End-to-end tests with Playwright verify real margins against the panel, fling scrolling and network deduplication.VerifiesNeeds a real layout?Unit, fake observeremit rule, guard, re-checknoTestBed componentinputs, outputs, templatenoPlaywright e2emargins, flings, one request/pageyes

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 done becomes 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.


↑ Back to Angular Observer Directives