Infinite scrolling in Blazor

 
 
  • Gérald Barré

Infinite scrolling is a technique that automatically loads data when you reach the end of the page, allowing you to scroll indefinitely. It is commonly used in social media feeds and blogs.

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

Razor
<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 approach uses an invisible sentinel element placed after the last displayed item. When this element enters the viewport, new items are loaded. You can detect this using the IntersectionObserver API.

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

JavaScript
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) {
                observer.unobserve(lastIndicator);
                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, create a small C# file with the following content:

C#
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>> ItemsProviderRequestDelegate(InfiniteScrollingItemsProviderRequest request);

Finally, create the Razor component InfiniteScrolling.razor:

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

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

@if (!_enumerationCompleted)
{
    <div @ref="_lastItemIndicator" style="height:1px;flex-shrink:0"></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 bool _enumerationCompleted = 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));

                var previousCount = items.Count;
                items.AddRange(newItems);

                if (items.Count == previousCount)
                {
                    _enumerationCompleted = true;
                }
                else
                {
                    await _instance.InvokeVoidAsync("onNewItems");
                }
            }
            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?