Infinite scrolling in Blazor

  • Gérald Barré

Infinite scrolling is a way to automatically loads data when you reach the end of the page. It allows you to continue scrolling indefinitely. The method is often used in social media feeds or blogs.

In this post, we'll create a Blazor component that you can use like the following:

<InfiniteScrolling ItemsProvider="GetItems">
    <ItemTemplate Context="item">
        <p>Item @item</p>
    </ItemTemplate>
    <LoadingTemplate>
        <div>Loading...</div>
    </LoadingTemplate>
</InfiniteScrolling>

@code {
    async Task<IEnumerable<int>> GetItems(InfiniteScrollingItemsProviderRequest request)
    {
        await Task.Delay(1000); // Simulate async loading
        return Enumerable.Range(request.StartIndex, 50);
    }
}

Demo of the infinite scrolling Blazor component

#Infinite scrolling implementation

The idea is to have an invisible element after the display element. When this element enters into the viewport, it means you need to load new items. You can be notified when the element enters into the viewport using the IntersectionObserver API.

First, you need to create a file named infinite-scrolling.js under wwwroot with the following content:

export function initialize(lastItemIndicator, componentInstance) {
    const options = {
        root: findClosestScrollContainer(lastItemIndicator),
        rootMargin: '0px',
        threshold: 0,
    };

    const observer = new IntersectionObserver(async (entries) => {
        // When the lastItemIndicator element is visible => invoke the C# method `LoadMoreItems`
        for (const entry of entries) {
            if (entry.isIntersecting) {
                await componentInstance.invokeMethodAsync("LoadMoreItems");
            }
        }
    }, options);

    observer.observe(lastItemIndicator);

    // Allow to cleanup resources when the Razor component is removed from the page
    return {
        dispose: () => dispose(observer),
        onNewItems: () => {
            observer.unobserve(lastIndicator);
            observer.observe(lastIndicator);
        },
    };
}

// Cleanup resources
function dispose(observer) {
    observer.disconnect();
}

// Find the parent element with a vertical scrollbar
// This container should be use as the root for the IntersectionObserver
function findClosestScrollContainer(element) {
    while (element) {
        const style = getComputedStyle(element);
        if (style.overflowY !== 'visible') {
            return element;
        }
        element = element.parentElement;
    }
    return null;
}

Then, you can create a small C# file with the following content:

public sealed class InfiniteScrollingItemsProviderRequest
{
    public InfiniteScrollingItemsProviderRequest(int startIndex, CancellationToken cancellationToken)
    {
        StartIndex = startIndex;
        CancellationToken = cancellationToken;
    }

    public int StartIndex { get; }
    public CancellationToken CancellationToken { get; }
}

public delegate Task<IEnumerable<int>> ItemsProvider(InfiniteScrollingItemsProviderRequest request);

Finally, you can create the razor component InfiniteScrolling.razor:

@using System.Threading
@typeparam T
@inject IJSRuntime JSRuntime
@implements IAsyncDisposable

@foreach (var item in _items)
{
    @ItemTemplate(item)
}
@if (_loading)
{
    @LoadingTemplate
}

<div @ref="_lastItemIndicator" style="height:1px"></div>

@code {
    private List<T> _items = new();
    private ElementReference _lastItemIndicator;
    private DotNetObjectReference<InfiniteScrolling<T>> _currentComponentReference;
    private IJSObjectReference _module;
    private IJSObjectReference _instance;
    private bool _loading = false;
    private CancellationTokenSource _loadItemsCts;

    [Parameter]
    public ItemsProviderRequestDelegate<T> ItemsProvider { get; set; }

    [Parameter]
    public RenderFragment<T> ItemTemplate { get; set; }

    [Parameter]
    public RenderFragment LoadingTemplate { get; set; }

    [JSInvokable]
    public async Task LoadMoreItems()
    {
        if (_loading)
            return;

        _loading = true;
        try
        {
            _loadItemsCts ??= new CancellationTokenSource();

            StateHasChanged(); // Allow the UI to display the loading indicator
            try
            {
                var newItems = await ItemsProvider(new InfiniteScrollingItemsProviderRequest(_items.Count, _loadItemsCts.Token));
                _items.AddRange(newItems);
            }
            catch (OperationCanceledException oce) when (oce.CancellationToken == _loadItemsCts.Token)
            {
                // No-op; we canceled the operation, so it's fine to suppress this exception.
            }
        }
        finally
        {
            _loading = false;
        }

        StateHasChanged(); // Display the new items and hide the loading indicator
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        // Initialize the IntersectionObserver
        if (firstRender)
        {
            _module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./infinite-scrolling.js");
            _currentComponentReference = DotNetObjectReference.Create(this);
            _instance = await _module.InvokeAsync<IJSObjectReference>("initialize", _lastItemIndicator, _currentComponentReference);
        }
    }

    public async ValueTask DisposeAsync()
    {
        // Cancel the current load items operation
        if (_loadItemsCts != null)
        {
            _loadItemsCts.Dispose();
            _loadItemsCts = null;
        }

        // Stop the IntersectionObserver
        if (_instance != null)
        {
            await _instance.InvokeVoidAsync("dispose");
            await _instance.DisposeAsync();
            _instance = null;
        }

        if (_module != null)
        {
            await _module.DisposeAsync();
        }

        _currentComponentReference?.Dispose();
    }
}

#Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?Buy Me A Coffee