How to build virtual scroll lists with rxVirtualFor
Goal. Practical recipes for @rx-angular/template/virtual-scrolling: basic setup,
persisting rendered views (appendOnly), trackBy, custom and window scroll elements,
reverse infinite scroll, and extending the package. For the full API, see the
RxVirtualFor,
viewport, and
strategy references.
Setup
Three parts must be imported and glued together:
- the viewport:
RxVirtualScrollViewportComponent - the view repeater:
RxVirtualFor - a scroll strategy: e.g.
FixedSizeVirtualScrollStrategy
import {
FixedSizeVirtualScrollStrategy, // choose any strategy you like
RxVirtualScrollViewportComponent,
RxVirtualFor,
} from '@rx-angular/template/virtual-scrolling';
@Component({
imports: [RxVirtualFor, RxVirtualScrollViewportComponent, FixedSizeVirtualScrollStrategy],
})
export class MyComponent {}
Render a fixed-size list
The rxVirtualForOf input accepts an Observable, a Signal, or a static array.
Size the items in CSS to match the strategy's itemSize.
<rx-virtual-scroll-viewport [itemSize]="50">
<div class="hero" *rxVirtualFor="let hero of heroes$">
<strong>{{ hero.name }}</strong>
<div>{{ hero.description }}</div>
</div>
</rx-virtual-scroll-viewport>
.hero {
height: 50px; // items must be sized to match itemSize
}
@Component({
/* … */
})
export class AnyComponent {
heroes$: Observable<Hero[]> = getHeroes();
// a Signal or a plain array works too:
heroesSignal: Signal<Hero[]> = getHeroesSignal();
}
Keep rendered views with appendOnly
appendOnly keeps views in the DOM after they scroll out of view instead of recycling
them. Useful when integrating with @angular/cdk/drag-drop. It reacts to changes, so
it can be toggled at runtime.
<rx-virtual-scroll-viewport [itemSize]="50" appendOnly>
<div class="hero" *rxVirtualFor="let hero of heroes; trackBy: 'id'">
<strong>{{ hero.name }}</strong>
</div>
</rx-virtual-scroll-viewport>
Reduce boilerplate with trackBy
trackBy accepts either a keyof T shorthand or a regular
TrackByFunction ((index: number, item: T) => any).
<rx-virtual-scroll-viewport [itemSize]="50">
<div class="hero" *rxVirtualFor="let hero of heroes; trackBy: 'id'">
<strong>{{ hero.name }}</strong>
</div>
</rx-virtual-scroll-viewport>
Use a custom scroll element
Apply rxVirtualScrollElement to an outer element to make it the scroll container, so
you can place content before and after the viewport.
<div rxVirtualScrollElement>
<div>Any content you like before</div>
<rx-virtual-scroll-viewport [itemSize]="50">
<div *rxVirtualFor="let item of items$">{{ item }}</div>
</rx-virtual-scroll-viewport>
<div>Any content you like after</div>
</div>
The bundled strategies detect when they are fully hidden from the viewport (accounting
for the size of content before and after) and render the minimum of runwayItems /
runwayItemsOpposite.
Scroll with the window
Add the scrollWindow directive to use the browser window as the scroll element,
especially useful for mobile.
<rx-virtual-scroll-viewport [itemSize]="50" scrollWindow>
<div *rxVirtualFor="let item of items$">{{ item }}</div>
</rx-virtual-scroll-viewport>
Reverse infinite scroll (keepScrolledIndexOnPrepend)
A reversed infinite scroller starts at the bottom and prepends data when the user
hits the top (as in a chat window). Set keepScrolledIndexOnPrepend so the strategy
keeps the scrolled index stable as new data arrives.
import { Component, inject } from '@angular/core';
import { Subject } from 'rxjs';
import { AutoSizeVirtualScrollStrategy, ListRange, RxVirtualFor, RxVirtualScrollViewportComponent } from '@rx-angular/template/virtual-scrolling';
@Component({
imports: [RxVirtualScrollViewportComponent, RxVirtualFor, AutoSizeVirtualScrollStrategy],
})
export class ReverseInfiniteListComponent {
private dataService = inject(MessageService);
initialScrollIndex = 19;
listRange: ListRange = { start: 0, end: 0 };
scrolled$ = new Subject<number>();
viewsRendered$ = new Subject<Message[]>();
messages$ = this.scrolled$.pipe(
filter(() => this.listRange.start === 0), // only fetch at the start
startWith(0),
exhaustMap((_, index) => this.dataService.getMessages(index)),
scan((messages, newMessages) => [...newMessages, ...messages], []),
);
trackMessage = (index: number, message: Message) => message.id;
}
<rx-virtual-scroll-viewport autosize keepScrolledIndexOnPrepend [initialScrollIndex]="initialScrollIndex" (viewRange)="listRange = $event" (scrolledIndexChange)="scrolled$.next($event)">
<div
*rxVirtualFor="
let item of messages$;
trackBy: trackMessage;
renderCallback: viewsRendered$
"
>
<div>{{ item.message.text }}</div>
<div>{{ item.sendAt | date }}</div>
</div>
</rx-virtual-scroll-viewport>
Extend the package with a custom strategy
Because every part of the package is based on injection tokens, you can provide your own
RxVirtualScrollStrategy. Create a directive that provides itself as the strategy token
and extends the abstract base.
import { Directive } from '@angular/core';
import { RxVirtualScrollStrategy } from '@rx-angular/template/virtual-scrolling';
@Directive({
selector: 'rx-virtual-scroll-viewport[custom]',
providers: [{ provide: RxVirtualScrollStrategy, useExisting: CustomScrollStrategy }],
})
export class CustomScrollStrategy extends RxVirtualScrollStrategy {}
Result
You have a viewport that renders only the visible slice of a large iterable, scheduled
without blocking the UI thread. Use the viewRange and scrolledIndexChange outputs to
react to what is on screen.
See also
- Reference:
RxVirtualFor - Reference:
RxVirtualScrollViewport - Reference: Virtual scroll strategies
- How-to: Tune rendering with strategies
- Concept: Concurrent scheduling & the frame budget