Infinite Scrolling with Livewire v3 + AlpineJS without impacting performance

victormlima98

Victor Lima

Posted on February 22, 2024

Infinite Scrolling with Livewire v3 + AlpineJS without impacting performance

Laravel Livewire developers may know that public Models in a component are automatically hydrated every request.

This means that everytime a request is sent against the server, under the hood Livewire "refreshes" the model, retrieving it again from the database.

But did you know that eager loaded models are also refreshed?

The Issue

Let's jump into the code. For this article, I have a simple users list, where each user has an eager loaded Role.

I wrote a simple infinite scrolling using limit and offset, and it loads more every time the user sees the end of the the current page (x-intersect), until there are no data left to render.

In my Livewire component:



public const int PER_PAGE = 10;

public Collection $users;

public function mount(): void
{
    $this->users = collect();

    $this->loadUsers();
}

public function loadUsers(bool $loadingMore = false): void
{
    User::query()
        ->with('role')
        ->limit(10)
        ->when(
            $loadingMore,
            fn (Builder $query) => $query->offset($this->users->count())
        )
        ->get()
        ->each(fn (User $user) => $this->users->push($user));
}

public function loadMore(): void
{
    $this->loadUsers(loadingMore: true);
}

#[Computed]
public function total(): int
{
    return User::count();
}

#[Computed]
public function hasMore(): bool
{
    return $this->users->count() < $this->total;
}


Enter fullscreen mode Exit fullscreen mode

In the Livewire component's view:



@foreach ($this->users as $key => $user)
    <li class="flex justify-between gap-x-6 py-5"
        wire:key="user-{{ $key }}">
        <div class="flex min-w-0 gap-x-4">
            <img class="h-12 w-12 flex-none rounded-full bg-gray-50"
                src="https://ui-avatars.com/api/?background=0D8ABC&color=fff&name={{ $user->name }}"
                alt="">
            <div class="min-w-0 flex-auto">
                <p class="text-sm font-semibold leading-6 text-gray-900">{{ $user->name }}</p>
                <p class="mt-1 truncate text-xs leading-5 text-gray-500">{{ $user->email }}</p>
            </div>
        </div>
        <div class="hidden shrink-0 sm:flex sm:flex-col sm:items-end">
            <h3 class="text-gray-800 font-bold text-sm">Role</h3>
            <p class="text-sm leading-6 text-gray-900">{{ $user->role->name }}</p>
        </div>
    </li>
@endforeach

@if ($this->hasMore)
    <div class="self-center loading loading-spinner" x-intersect="$wire.loadMore()"></div>
@endif


Enter fullscreen mode Exit fullscreen mode

So, since I have 10 User models per page with an eager loaded Role, every time I go to the next page, Livewire automatically hydrates every existing public model and lazy load its relationships, making many additional queries per request, as seen in Debugbar:

First page:
Issue first page

Scrolled to the end of the list:
Issue last page
Note that for each page, it refreshes all the previous models, so it becomes a performance issue. Easy to fix, but an issue anyway.

Solution

So, how do we solve this without impacting performance and memory usage?

The answer: the good old arrays.

By parsing all models to array, Livewire won't hydrate each record when going to the next page or if the component hydrates by any other reason, because this behavior of course only occurs with public Eloquent Models.

So, let's dive into the solution:

First, let's write a method to parse our models to array considering the data we actually need to use in the frontend.



private function parseUser(User $user): array
{
    $role = $user->role->only(['id', 'name']);

    $user = $user->only([
        'id',
        'name',
        'email'
    ]);

    return array_merge(
        $user,
        ['role' => $role]
    );
}


Enter fullscreen mode Exit fullscreen mode

Then, when adding the User model to the users Collection, we just parse it first:
->each(fn (User $user) => $this->users->push($this->parseUser($user)));

The only thing left is to adjust our view to use arrays instead of Models:



@foreach ($this->users as $key => $user)
    <li class="flex justify-between gap-x-6 py-5"
        wire:key="user-{{ $key }}">
        <div class="flex min-w-0 gap-x-4">
            <img class="h-12 w-12 flex-none rounded-full bg-gray-50"
                src="https://ui-avatars.com/api/?background=0D8ABC&color=fff&name={{ $user['name'] }}"
                alt="">
            <div class="min-w-0 flex-auto">
                <p class="text-sm font-semibold leading-6 text-gray-900">{{ $user['name'] }}</p>
                <p class="mt-1 truncate text-xs leading-5 text-gray-500">{{ $user['email'] }}</p>
            </div>
        </div>
        <div class="hidden shrink-0 sm:flex sm:flex-col sm:items-end">
            <h3 class="text-gray-800 font-bold text-sm">Role</h3>
            <p class="text-sm leading-6 text-gray-900">{{ data_get($user, 'role.name') }}</p>
        </div>
    </li>
@endforeach


Enter fullscreen mode Exit fullscreen mode

And let's dive into the results.

First page:
Solution first page

Second page and so on:
Solution second page

As you can see, Livewire no longer refreshes any models because there are no models to be refreshed, just simple arrays with the data we actually want to use.

With this solution, of course you'll lose the ability to call model methods, but nothing stops you from doing whichever logic you need inside the parse method.

I hope it helps, and thanks for reading!

💖 💪 🙅 🚩
victormlima98
Victor Lima

Posted on February 22, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related