A Hack for Efficient Infinite Scrolling in Your Livewire App

Infinite scrolling with Livewire has traditionally meant starting with X records and when it comes time to "load more," you simply increase the query limit by X, re-fetch the records (resulting in duplication of what you've already queried), and then replacing your html with the new stuff. For example, at first you load 5 records, then to get the 6th thru 10th records, you increase the limit on your query to 10 and re-fetch (meaning this second query will include the 5 you grabbed originally). Scrolling deeply down the page, you can get to a point where you want the 95th thru 100th records, which means querying ALL 100, sending a huge payload back to the browser, and doing a massive morph-replace.

We use this methodology at the time of writing for our feeds on Pinkary, and to combat the issue, we limit our not-so-infinite scrolling to 100 posts; hence, I set out to find if an alternate approach exists.

In this article, I'll describe a way to do this more efficiently: fetching only the records we need to keep the page growing and appending those records to the existing DOM. Aka, how you'd normally do it when using a typical JS framework + api approach.

Traditional Livewire Infinite Scrolling Recap

Let's quickly go over the traditional setup to establish a baseline. We're going to infinitely load Posts into a feed (at least, until our server would run out of memory).

Here's an example Livewire feed component:

<?php

namespace App\Livewire;

use App\Models\Posts;
use Livewire\Attributes\Locked;
use Livewire\Component;

final class Feed extends Component
{
    /**
     * The number of posts per "load more" cycle.
     */
    #[Locked]
    public int $perPage = 5;

    /**
     * The total number of posts to load.
     */
    #[Locked]
    public int $limit = 5;

    /**
     * Load more questions.
     */
    public function loadMore()
    {
        $this->limit += $this->perPage;
    }

    public function render()
    {
        $posts = Posts::query()
            ->orderByDesc('created_at')
            ->simplePaginate($this->limit);

        return view('livewire.feed', [
            'posts' => $posts,
        ]);
    }
}

And that livewire.feed view can be something like:

<section>
    <!-- Render all the posts -->
    @foreach ($posts as $post)
        <livewire:posts.show
            :postId="$post->id"
            :key="'post-' . $post->id"
        />
    @endforeach

    <!-- Load more intersection -->
    <div x-intersect.margin.50%="$wire.loadMore()">
        <div class="text-center text-slate-400" wire:loading wire:target="loadMore">
            <x-heroicon-o-arrow-path class="w-5 h-5 animate-spin" />
        </div>
    </div>
</section>

And we can assume that's wired together by a simple web route that does little more than return the view (which gets Livewire rolling).

As mentioned above, the problem with this setup is that the infinite scrolling works by increasing the limit on our query for each "load more" cycle, grabbing all the records (which includes the records from the prior loading cycle), creating new html for the entire <section>, sending that over the internet, and then morph-replacing the existing DOM with our new stuff. So, when we want the 91-95th posts, we just grab all 95 from the database and render + send that html. When the 96-100th posts are needed, we grab all 100, etc.

Each load more cycle means a larger query, larger html payload over the internet, and more DOM stuff to morph.

What Would Be More Efficient?

Doesn't take rocket science here... We've been doing this effectively in our api + JS apps for years now. Something like:

  1. Make a JS feed component that has a data prop for an array of posts.
  2. Fetch an initial amount of posts from the server and hydrate those into the array, rendering them into the DOM (possibly as a SSR procedure).
  3. When it's time to load more, ask the api to give you the limited subset of posts you need (ie. 6-10th posts, 11-15th, etc.).
  4. Importantly, append those new posts to the array, which re-renders the DOM and appends the new post elements to our feed.
  5. Rinse and repeat.

Since we're not working with a JS frontend like Vue/React/etc., we'll need to approach this in a similar but slightly different way with Livewire.

What if we could set up a Livewire component to render a specific subset of posts (what we'd normally think of as a "page") as html and then append that html into the right location within the DOM? That'd be almost identical to the JS app methodology, except we get the server to convert an array of posts into html rather than doing that in the browser. Appending those new DOM elements, however, is the same concept.

Sounds like a plan.

The Efficient Approach

Let's do this.

First, we'll need a way to append html elements to the DOM in our Livewire app over replacing. Now, I'm a fan of the least-dependencies-possible approach, but for the sake of this article, I'm going to utilize a cool package by Christian Taylor (@imacrayon) called Alpine AJAX.

With that installed, we can update our Livewire Feed component and view. Here's the best part: the changes are minimal and overall, we get to reduce our code.

Note: the code examples are just rough examples, not meant to be copied verbatim; they don't represent the true source code of Pinkary.

<?php

namespace App\Livewire;

use App\Models\Posts;
use Livewire\Component;

final class Feed extends Component
{
    /**
     * The number of posts per "load more" cycle.
     */
    #[Locked]
    public int $perPage = 5;

    public function render()
    {
        $posts = Posts::query()
            ->orderByDesc('created_at')
            ->cursorPaginate($this->perPage); // yay! cursor pagination!

        return view('livewire.feed', [
            'posts' => $posts,
        ]);
    }
}

To update the view, we just need to tell Alpine AJAX what to do ($ajax('{{ $posts->nextPageUrl() }}', { target: 'feed pagination' })), what to do with the html payload (x-merge="append"), and to which DOM elements (id="feed' and id="pagination").

<section id="feed" x-merge="append">
    @foreach ($posts as $post)
        <livewire:posts.show
            :postId="$post->id"
            :key="'post-' . $post->id"
        />
    @endforeach

    @if($posts->hasMorePages())
        <div
            id="pagination"
            x-intersect.margin.600px="$ajax('{{ $posts->nextPageUrl() }}', { target: 'feed pagination' })"
        ></div>
    @endif
</section>

Last piece is to update our web route handling for the feed so that the Alpine AJAX call is handled correctly.

This can be done in a controller like so:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\View\View;
use Livewire\Livewire;

final class FeedController
{
    public function __invoke(Request $request): View|string
    {
        // Determine if the request is a first page load or an infinite scroll "load more" request.
        $isInfiniteScrollRequest = $request->hasHeader('X-Alpine-Request');

        return $isInfiniteScrollRequest
            ? Livewire::mount('feed') // render the component and return its html string fragment ๐Ÿ‘€
            : view('feed'); // do a regular full page load
    }
}

That's it. Let's break it down.

First of all, if we're dealing with a full page load (someone hitting our feed route and not attempting to "load more"), we can just return the view('feed') like normal.

If we're working with a "load more" request from Alpine AJAX, we get a little hacky (here's where the hack in the title comes from). We ask Livewire to mount our Feed component, and the returned value from that Livewire::mount() function call is the html string returned from the render() method of our Feed component after Livewire has performed some lifecycle events.

public function render()
{
    $posts = Posts::query()
        ->orderByDesc('created_at')
        ->cursorPaginate($this->perPage);

    return view('livewire.feed', [
        'posts' => $posts,
    ]);
}

Through the ease provided to us from Laravel's cursor pagination implementation, our AJAX request will have the query params needed for Laravel to know which cursor we're working with and what page we want to query (you could manually handle this if you needed to for some reason).

Thus, on our infinte scrolling "load more" requests from Alpine AJAX, we will fetch only a subset of posts for the next cursor page (->cursorPaginate(...)), render a fragment of html with those posts (return value from render()), and respond with that html string back to the browser. Alpine AJAX handles that response, and because of our configuration above, it will append that new html of posts into our <section id="feed"...> element.

Easy peasy.

With this in place, we do a normal full page load (like any other Livewire app) and we get efficient infinite scrolling (appending a limited subset of new html elements to our feed). All of the appended elements retain all of their normal Livewire component functionality.

It's a little hacky, because we're no longer using Livewire exclusively (we now have that FeedController that handles our special request scenarios) and we're asking Livewire to render a component as a raw html string that we send back to the browser. But hey - it's not the worst hack I've seen for a production app. Plus, this concept of appending html (vs. replacing) from a Livewire request->response will be built into the package one day in the future.

Bonus Example Using Blade Fragments

If it seems silly to make the feed a Livewire component (our Feed component in this article), since it doesn't have any extra methods that are called for client-triggered async functionality (one of the big benefits of using Livewire), I'll show you how to accomplish this using Laravel's @fragment feature of its Blade engine.

Since you now know the concept, I'm just going to quickly dump the code.

<?php

namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Livewire\Livewire;

final class FeedController
{
    public function __invoke(Request $request): View|string
    {
        $isInfiniteScrollRequest = $request->hasHeader('X-Alpine-Request');

        // Look familiar? ๐Ÿ˜‰
        $posts = Post::query()
            ->orderByDesc('created_at')
            ->cursorPaginate($perPage = 5);

        return view('feed', ['posts' => $posts])
            ->fragmentIf($isInfiniteScrollRequest, 'posts-list'); // fragment magic! ๐Ÿ‘€
    }
}

Making a posts.list Blade component:

@props(['posts'])
@fragment('posts-list')
    <div x-data>
        <section id="feed" x-merge="append">
            @foreach ($posts as $post)
                <livewire:posts.show
                    :postId="$post->id"
                    :key="'post-' . $post->id"
                />
            @endforeach
        </section>
        @if($posts->hasMorePages())
            <div
                id="pagination"
                x-intersect.margin.600px="$ajax('{{ $posts->nextPageUrl() }}', { target: 'feed pagination' })"
            ></div>
        @endif
    </div>
@endfragment

And that posts.list component can be called from a feed view.

<x-app-layout>
    <x-home-menu></x-home-menu>

    ...

    <!-- This would have been the livewire:feed component earlier in the article -->
    <x-posts.list :posts="$posts"></x-posts.list>
</x-app-layout>