Directive or Pipe?
Angular offers two primary tools for working with the DOM: Directives and Pipes.
Pipes are designed to transform data within a template. They act as formatting utilities, refining how data is presented without affecting the page structure. Their sole focus is on rendering data in the desired way.
Directives, conversely, are built for direct DOM interaction and manipulation. They can either modify the behavior or appearance of an existing element (Attribute Directives) or fundamentally alter the DOM layout by adding or removing elements (Structural Directives).
The strategy will be to construct the Infinity Scroll functionality using both a Directive and a Pipe, then weigh the advantages and disadvantages of each implementation.
Directive
- First, adjust the
InfinityScrollOptionsinterface to remove theelementproperty, since the host element of the directive will be used instead. - Second, introduce a
noMoreData$property to theInfinityScrollOptionsinterface. This is a user-supplied Observable that signals when all data has been fully loaded.
/**
* Infinity Scroll Options excluding the element
*/
export interface InfinityScrollDirectiveOptions<T>
extends Omit<InfinityScrollOptions<T>, "element"> {
/**
* User defined Observable that
* tells if all data had been loaded.
*/
noMoreData$: Observable<any>;
}
A key advantage of directives is their ability to inject the host element, removing the necessity to pass it explicitly.
To accumulate data as the user scrolls and emit it as a single array, you'll need to store the accumulated data in a buffer. To access this buffer in the template, you must also expose the directive's instance to the host template.
@Directive({
selector: "[infinityScroll]",
// export the directive instance to the host template
exportAs: "infinityScroll",
standalone: true,
})
export class InfinityScrollDirective<T> {
#elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
/**
* The data buffer that will be used to accumulate data
* and emit it as a single array.
*/
#dataBuffer = new BehaviorSubject<T[]>([]);
/**
* The data buffer exposed as an Observable
*/
data$ = this.#dataBuffer.asObservable();
}
We'll utilize the @Input decorator to pass the InfinityScrollOptions to the directive. An InjectionToken would be an alternative, but @Input is more straightforward for this use case.
// ... InfinityScrollDirective
export class InfinityScrollDirective<T> {
#destroy = new Subject<void>();
@Input({ required: true, alias: "infinityScroll" })
set options(options: InfinityScrollDirectiveOptions<T[]>) {
// ensures that previous infinityScroll subscription is unsubscribed
this.#destroy.next();
// reset the data buffer
this.#dataBuffer.next([]);
infinityScroll({
...options,
element: this.#elementRef.nativeElement,
})
.pipe(
scan((acc, data) => [...acc, ...data], [] as T[]),
takeUntil(options.noMoreData$),
takeUntil(this.#destroy)
)
.subscribe(data => {
this.#dataBuffer.next(data);
});
}
}
Because the directive is designed to accept options that may change at runtime, it's critical to unsubscribe from the existing infinite scroll subscription before initializing a new one.
- The
scanoperator is employed to accumulate the incoming data chunks and emit them as one consolidated array. - The first
takeUntiloperator is applied to halt the infinite scroll process oncenoMoreData$emits a value. The secondtakeUntilis used to stop it when the directive itself is destroyed.
ngOnDestroy(): void {
// Indicate infinity scrolling have to stop
this.#destroy.next();
// No more data will be pushed to the data buffer
this.#dataBuffer.complete();
}
The ngOnDestroy lifecycle hook serves a dual purpose here: it unsubscribes from the active infinite scroll subscription and signifies the completion of the data buffer.
<!-- alias to infinity scroll directive instance -->
<div
[infinityScroll]="infinityScrollOptions"
#infinityScroll="infinityScroll"
style="max-width: 15rem;max-height: 10rem; overflow: auto"
>
<ul>
<!-- loop over the data source -->
<li *ngFor="let item of infinityScroll.data$ | async">{{ item.title }}</li>
</ul>
</div>
<p *ngIf="infinityScrollOptions.loading | async">Loading..</p>
The template implementation is straightforward: simply iterate over the data source and conditionally display a loading indicator. The critical piece here is the application of max-height and overflow CSS styles. These styles are what make the container scrollable. Without them, there would be no scroll event generated to trigger the infinite scroll logic.
Setting up the Infinity Scroll configuration
interface Todo {
title: string;
}
const PAGE_SIZE = 10;
@Component({
templateUrl: "./app.component.html",
standalone: true,
imports: [CommonModule, InfinityScrollDirective],
})
export class AppComponent {
#lastBatchLength = new BehaviorSubject<number>(
PAGE_SIZE /** Default to Page Size */
);
/**
* An observable that signals if all data had been loaded
*
* It detects whether all data is loaded or not by comparing
* the last batch length with the current batch length.
*
* I'm assuming that the page length is constant, therefore,
* if the last batch length is the not same as the current batch length,
* then we approach the end
*/
noMoreData$ = this.#lastBatchLength.asObservable().pipe(
pairwise(),
filter(([prev, curr]) => prev !== curr)
);
}
Before configuring the infinite scroll, you need to define the stopping condition. This isn't strictly necessary in every scenario, but it's essential for when you have a finite dataset. You must ensure the infinite scroll comes to a halt once all data has been loaded; otherwise, your application will make repetitive, unnecessary requests.
// ... AppComponent
export class AppComponent {
// ... other code
#http = inject(HttpClient);
infinityScrollOptions: InfinityScrollDirectiveOptions<Todo[]> = {
initialPageIndex: 1,
threshold: 50,
loading: new BehaviorSubject(false),
noMoreData$: this.noMoreData$,
loadFn: (result: InfinityScrollResult) => {
return this.#http
.get<Todo[]>(`https://jsonplaceholder.typicode.com/todos`, {
params: {
_start: result.pageIndex,
_limit: PAGE_SIZE,
},
})
.pipe(
tap(todos => {
this.#lastBatchLength.next(todos.length);
})
);
},
};
}
The configuration options for the infinite scroll are remarkably similar to those in the vanilla JavaScript implementation. The primary distinction is the use of HttpClient for data retrieval, rather than the native fetch API.
- Advantages
- The element reference is automatically available through Dependency Injection.
- Disadvantages
- You bear the responsibility of manually managing the subscription lifecycle.
- Before creating a new subscription, you must unsubscribe from the previous one.
- You are required to clear the data buffer before establishing a new infinite scroll subscription.
Demo
Pipe
@Pipe({
name: "infinityScroll",
standalone: true,
})
export class InfinityScrollPipe<T> implements PipeTransform {
transform(
options: InfinityScrollDirectiveOptions<T[]>,
element: HTMLElement
): Observable<T[]> {
return infinityScroll({
...options,
element,
}).pipe(
scan((acc, data) => [...acc, ...data], [] as T[]),
takeUntil(options.noMoreData$)
);
}
}
- The core RxJS operators utilized within the pipe are identical to those in our directive implementation. We still rely on
scanandtakeUntilfor the core logic. - A significant divergence lies in how the
elementreference is obtained. Instead of injecting it, the pipe receives it as a direct argument in itstransformmethod. Thanks to the inherent nature of pipes in Angular, a separate, manually-managed data buffer isn't necessary. The
asyncpipe takes over the subscription management, simplifying the component code.Implementation in a component template
<div
#infinityScrollPipeEl
style="max-width: 15rem;max-height: 10rem; overflow: auto"
>
<ul>
<li
class="border"
*ngFor="
let item of infinityScrollOptions
| infinityScroll : infinityScrollPipeEl
| async
"
>
{{ item.title }}
</li>
</ul>
</div>
- Advantages
- Manual subscription management is a thing of the past; the framework handles it for you.
- There is no need to clear a data buffer before initiating a new infinite scroll, as the
asyncpipe will automatically discard the previous data stream.
- Disadvantages
- The element reference must be explicitly passed to the pipe as an argument, making your template slightly more verbose.
Demo
Working with Signals
I couldn't get a clear mental model for a signal-based implementation because the approach depends on accessing a reference to the element itself. If you have a solution in mind, feel free to share it in the comment section below.
Final Thoughts
You now know how to build Infinity Scroll in Angular with both a Directive and a Pipe, along with the strengths and trade-offs of each. Pick whichever fits your use case best.
For my part, I'd prefer the pipe-based version since it keeps things more declarative.
