Optimising Your Laravel Livewire Components with AlpineJs

  • php
  • laravel
  • livewire
  • alpinejs
Updated:

About a year ago I wrote a blog post covering some common Laravel Livewire Performance Tips & Tricks.

I wrote this post a few months before Livewire v3 came out, which changed many of the defaults making it much more optimised out of the box and making it less likely for you to encounter these issues.

For example, it meant instead of doing wire:model.defer to have Livewire only send the update to the backend when it was sending another API request (i.e the submit button) it now defaults to it, and if you want the input to be live for whatever reason, you must now do wire:model.live.

These are definitely more sensible defaults as one of the biggest problems (and not something that could be avoided) is that Livewire can seem incredibly fast and efficient locally when it does not require hitting the internet to make the request and get back the DOM change.

Whilst these changes for the most part, solve some of the performance issues you see in Laravel Livewire, you can still find yourself in situations where your livewire component still makes many API requests resulting in those performance issues coming back.

We're building an internal intranet for Pixel (the agency I run) to help us better manage our clients and budget. We have one screen in the application which allows us to set permissions and assign users to roles

It's pretty simple. It's a bunch of grouped permissions, with a list of users that have the role on the right.

Here's how I would've done it a few years ago.

The Livewire Only Way

Take this assigned users dropdown for example

In the past, I'd simply have a click handler on the div, like so: wire:click="addUser({{ $user->id }})", like this:

<div wire:key="{{ $user->id }}"
wire:click="addUserToRole({{ $user->id }})"
x-on:click="open = false"
class="cursor-pointer flex items-center gap-3 py-2 px-2.5 rounded-md hover:bg-gray-100 transition-all duration-300">
<h5 class="text-sm font-medium text-gray-600">{{ $user->name }}</h5>
<div class="w-1 h-1 bg-gray-400 rounded-full"></div>
<h5 class="text-sm text-gray-500">{{ $user->email }}</h5>
</div>

And the backend hooked up like so:

public function addUserToRole($userId)
{
$user = $this->users->find($userId);
 
// add the role to the user
$user->assignRole($this->role->name);
 
// update the class varriable
$this->usersInRole = $this->users->push($user);
}

We'd then have the section showing the users already in the role hooked up like this:

@foreach ($usersInRole as $user)
<div class="card flex items-center justify-between gap-3 py-2.5 px-3">
<div class="flex items-center gap-3 overflow-x-auto scrollbar-none whitespace-nowrap">
<div class="overflow-hidden border border-gray-100 border-solid rounded-full shrink-0 w-7 h-7">
<img x-bind:src="user.profile_picture_url">
</div>
 
<h5 class="text-sm font-medium text-gray-600">{{ $user->name }}</h5>
<div class="w-1 h-1 bg-gray-400 rounded-full shrink-0"></div>
<h5 class="text-sm text-gray-500">{{ $user->email }}</h5>
</div>
 
<button wire:click="removeUser({{ $user->id }})"
class="sticky right-0 before:absolute before:top-0 before:right-full before:w-16 before:h-full before:bg-gradient-to-r before:from-transparent before:to-white before:mr-3 p-1.5 bg-white shadow-xs border border-solid border-gray-200 rounded-lg">
<svg ... ></svg>
</button>
</div>

This setup presents a few issues:

  1. Every time you click to add a user, that's one API call
  2. On a slow server, the end user would see the dropdown close but the users in role update be delayed
  3. If you wanted to add the ability to filter/search the list, that would take even more calls as you'd rely on PHP returning a filtered $usersNotInRole list.

Overall, it's pretty clunky. It will fall into the common trap that I said above: it will work extremely well on local, and give you the false hope that you've hooked up a great UI with minimal effort.

The Proper AlpineJs Way

What you can achieve when you use the best of both of them is incredible.

Sure, if you don't know JavaScript too well it will take you a bit longer, but it's still nothing compared to learning how to manage state and complex codebases in it.

Let's revisit this setup.

With AlpineJS now, we only need one Livewire function to do it all:

public function updateRole($usersInRole)
{
$usersInRole = $this->users->whereIn('id', collect($usersInRole)->pluck('id'));
 
$this->role->syncUsers($usersInRole);
 
$this->dispatch('notify', [
'type' => 'success',
'content' => 'Role updated',
]);
}

And to house all of our AlpineJS code, we can take advantage of the store feature:

<script>
document.addEventListener('livewire:init', () => {
Alpine.data('updateRole', () => ({
dirty: false,
 
removeUserModalOpen: false,
removeUser: null,
 
users: @json($users),
usersInRole: @json($role->users),
 
addUserSearch: '',
addUsersDropdownOpen: false,
 
init() {
this.usersNotInRole = this.users.filter(user => !this.usersInRole.some(u => u.id === user.id));
},
 
updateRole() {
@this.updateRole(this.usersInRole).then(() => {
this.dirty = false;
});
},
 
toggleAddUser() {
if (this.addUsersDropdownOpen) {
return this.closeAddUserDropdown()
}
 
this.$refs.button.focus()
 
this.addUsersDropdownOpen = true
},
 
get usersNotInRole() {
// return users not in the role, and filter if the addUserSearch is not empty
return this.users.filter(user => !this.usersInRole.some(u => u.id === user.id) &&
(user.name.toLowerCase().includes(this.addUserSearch.toLowerCase()) ||
user.email.toLowerCase().includes(this.addUserSearch.toLowerCase())
)
);
},
 
addUserToRole(user) {
this.dirty = true;
 
this.usersInRole.push(user)
this.usersNotInRole = this.usersNotInRole.filter(item => item.id !== user.id)
 
this.closeAddUserDropdown()
},
 
closeAddUserDropdown(focusAfter) {
if (!this.addUsersDropdownOpen) return
 
this.addUsersDropdownOpen = false
 
focusAfter && focusAfter.focus()
},
 
toggleRemoveUser(user) {
this.removeUser = user;
this.removeUserModalOpen = true;
},
 
removeUserFromRole() {
this.usersInRole = this.usersInRole.filter(user => user.id !== this.removeUser.id);
this.usersNotInRole.push(this.removeUser);
 
this.dirty = true;
this.removeUserModalOpen = false;
}
}));
});
</script>

Let's break it down:

We start by defining the variables we'll use:

dirty: false, // this will show/hide our save button if there are changes
 
removeUserModalOpen: false, // show or hide our modal to remove a user
removeUser: null, // the user we are currently querying to remove
 
users: @json($users), // all users in the team
usersInRole: @json($role->users), // all users in the role
 
addUserSearch: '', // the search to filter users by
addUsersDropdownOpen: false, // showing the user dropdown

To then get the usersNotInRole so we know who to show in the dropdown, we utilise the init() function in AlpineJS and do some simple filtering:

init() {
this.usersNotInRole = this.users.filter(user => !this.usersInRole.some(u => u.id === user.id));
},

This is now the JavaScript function we'll run when the user hit's the save button:

updateRole() {
@this.updateRole(this.usersInRole).then(() => {
this.dirty = false; // reset our dirty variable
});
},

Here is the save button in action:

The save button shows because when we click the user in the dropdown, it now calls this function:

addUserToRole(user) {
this.dirty = true;
 
this.usersInRole.push(user)
this.usersNotInRole = this.usersNotInRole.filter(item => item.id !== user.id)
 
this.closeAddUserDropdown()
},

Which does a few things:

  1. Sets the state to dirty to show that something has changed
  2. Adds the user to the usersInRole array
  3. Removes the user from the usersNotInRole array
  4. Closes the dropdown.

So far we have made 0 Livewire/API calls, which means the speed of this is dependant on the users web browser.

I didn't show this earlier, but the AlpineJS version of removing users also never hits an API:

removeUserFromRole() {
this.usersInRole = this.usersInRole.filter(user => user.id !== this.removeUser.id);
this.usersNotInRole.push(this.removeUser);
 
this.dirty = true;
this.removeUserModalOpen = false;
}

It basically just does the opposite of adding the user to the role.

Now, to update our HTML, it looks much cleaner and simpler:

<template v-if="usersNotInRole.length > 0"
x-for="user in usersNotInRole"
:key="user.id">
<div x-on:click="addUserToRole(user)"
class="cursor-pointer flex items-center gap-3 py-2 px-2.5 rounded-md hover:bg-gray-100 transition-all duration-300">
<h5 class="text-sm font-medium text-gray-600"
x-text="user.name"></h5>
<div class="w-1 h-1 bg-gray-400 rounded-full"></div>
<h5 class="text-sm text-gray-500"
x-text="user.email"></h5>
</div>
</template>

This is the save button, which is the only component on the page that actually calls Livewire:

<button x-cloak
x-show="dirty"
type="button"
x-on:click="updateRole"
class="btn left-icon-btn btn-primary shrink-0">
Save
</button>

That's it.

Thoughts

It's definitely a lot more time-consuming having to play around with AlpineJS state and how to remove/add data to temporary arrays, but it will pay off when it hits a production environment and your users aren't relying on your backend to respond in a relatively quick time.

Just to reiterate: this approach pays off every time when you find yourself making a livewire call just to control the HTML state. It will not be worth the implementation time if you're only making a single livewire request anyway.