There's been a lot of debate in the last few weeks on the merits of building web apps with HTML-over-the-wire vs. using popular JavaScript libraries like Vue and React, and how that choice affects the experiences of end users and developers.
In this post, we'll walk through some optimistic UI tricks we apply when using Laravel with Livewire and Alpine to capture the best of both worlds—instantaneous-feeling actions on the UI like what you'd expect from a Single-Page App (SPA) combined with the developer experience you may prefer in Livewire and Alpine.
Let's dive in!
Optimistic UI tricks help us maximize perceived performance:
The perception of how quickly (and smoothly) pages load and respond to user interaction is even more important than the actual time required to fetch the resources. While you may not be able to physically make your site run faster, you may well be able to improve how fast it feels for your users.
Starting from first principles, getting to "instant" interactions is easy when there's no server. A simple counter serves as a perfect example. Try it!
When you click, the counter increments, and the UI is updated instantly. No server is involved in the interaction, and all of the state is managed on the client side.
Now, let's imagine that the counter has to make an API call to let the server know it has been incremented and then wait for the server to confirm that the increment was successful before reflecting the new state on the user's UI.
This adds a few hundred milliseconds of lag to the user's perceived performance. It's important to note that the slowness here isn't because we have a server receiving API calls. It's because we're waiting for the server to respond before updating the UI.
What if we didn't wait for the response and instead optimistically assume that the request would succeed? That's an optimistic UI.
Ultimately, an optimistic UI aims to minimize the client's reliance on the server's response so that user interactions feel instant.
Let's move on to a real-world example using Livewire and Alpine.
We run a form-as-a-service product for forms called FieldGoal. This app initially used Vue on the front end, but we recently migrated it to use Livewire and Alpine. Things worked well, but the UX wasn't great on the "inbox" screen.
We were trying to stay on the server side as much as we could, but we pushed it too far to the point that actions felt noticeably less snappy. Those few hundred milliseconds of network cost for each action were hurting the user experience, so we set out to tweak the implementation.
Users of FieldGoal will often go through their inbox and archive form submissions they've already responded to, or mark a submission as spam if it somehow got through our spam filters. This is what that interaction looked like on a local machine, throttling to emulate a fast 3G connection.
It's easy to notice the lag between clicking the archive button and the UI arriving at its final end state, along with the fact that the transition isn't as smooth as it should be. We wanted to focus our energy on optimizing the inbox UX so that taking an action like archiving a submission would make the item instantly disappear from the inbox, and then immediately focus the next item in the inbox.
Here are some tips and tricks we applied to improve the perceived performance of the app.
wire.loading
Livewire allows us to apply some loading states whenever specific actions are triggered. One of the loading state options allows us to remove an element while a Livewire action is in progress by adding a wire:loading.remove
to the element we want to hide. Optionally, we can specify the target actions that this behavior should apply to with the wire:target
attribute.
Our first attempt to improve the inbox UX was inspired by Josh Cirre and Caleb Porzio who showed how we can use wire:loading.remove
as an optimistic UI trick. In our case, we hoped we could use this trick to hide the form submission that was just archived without waiting for the network response.
Our attempt looked something like this:
@foreach ($this->submissions as $submission)<button wire:key="{{ $submission->id }}" wire:click="selectSubmission({{ $submission->id }})" wire:loading.remove wire:target="moveToArchive({{ $submission->id }})"> <!-- (...) --></button>@endforeach
Although this helped, and would've likely been sufficient for a simpler use-case, it wasn't enough in our particular scenario. We could see the archived submission disappearing from the list instantly, but the next submission wasn't being automatically selected. We applied the same trick to the detail section, and the story is similar; users would see the empty box until they get a response from the server with the next item selected on the list and its details.
The issue here is that the selected submission logic happens on the server side. Also, we have multiple actions, and Livewire doesn't support targeting multiple actions with params using wire:target
at the moment. Furthermore, since users can archive a submission and then undo the action, there isn't an easy way for us to track the submissions which have been archived and "un-remove" them using wire:loading
.
These issues didn't exist in the Vue version because all the interactions were happening on the client side. Luckily, we can fix it without introducing a new tool since Livewire now ships with Alpine out of the box!
As we said earlier, this page was heavily reliant on server side state. Selecting one submission was done with a Livewire action that set the selected submission's state on the backend, then re-renders the component, which displayed the details of the selected submission in the details section.
All that works well on local. It feels magical, take a look:
But as soon as we add latency to the game, such as a slow 3g throttle... well, it doesn't feel so magical anymore:
This isn't Livewire's fault. We shouldn't be using server side state for this kind of interaction in the first place. We fixed this by moving the submissions and selected state to the client side.
<div wire:ignore x-data="{ submissions: @js($this->submissions), selectedSubmission: null, init() { selectedSubmission = submissions[0] || null }, selectSubmission(submission) { selectedSubmission = submission }, }"> <!-- (...) --></div>
We're not even using entangle here. All the interactivity for selecting submissions (including keyboard navigation) happens entirely on the client side.
Also, we no longer rely on Livewire re-renders here; we're delegating that part entirely to Alpine. Instead of using something like wire:click
to trigger server-side actions like this:
@foreach ($this->submissions as $submission)<button type="button" wire:click="selectSubmission({{ $submission->id }})"> Select Submission</button>@endforeach <!-- and --> <button type="button" wire:click="moveToArchive({{ $this->selectedSubmission->id }})"> Archive</button>
We now need to handle that in Alpine:
<template x-for="submission in submissions" :key="submission.id"> <button type="button" x-on:click="selectSubmission(submission)"> Select Submission </button></template> <!-- and --> <button type="button" x-on:click="moveToArchive()"> Archive</button>
And this moveToArchive
method would be on the Alpine component, and it would look something like this:
<div wire:ignore x-data="{ submissions: @js($this->submissions), selectedSubmission: null, init() { selectedSubmission = submissions[0] || null }, selectSubmission(submission) { selectedSubmission = submission },+ async moveToArchive() {+ if (! selectedSubmission) return+ + await $wire.call('moveToArchive', selectedSubmission.id)+ + const currentIndex = submissions.findIndex((sub) => (+ sub.id === selectedSubmission.id+ ))+ + selectedSubmission = submissions[currentIndex + 1] || submissions[currentIndex - 1] || null+ + submissions = submissions.filter((sub) => (+ sub.id !== selectedSubmission.id+ ))+ }, }" > <!-- (...) --> </div>
Luckily, Livewire and Alpine mix really well together. It's almost like they were built by the same person 😂.
Selecting a submission now takes place entirely on the client side without waiting for a response, and it feels super snappy.
However, the actions still wait for the network response before updating the UI. Let's see if we can improve that.
The issue now is that to update the UI, we're still waiting for the Livewire action call to happen. In our case, the result of these actions would be the same:
We don't need to wait for the response here. We can optimistically update the UI by assuming the action will work:
<div wire:ignore x-data="{ submissions: @js($this->submissions), selectedSubmission: null, init() { selectedSubmission = submissions[0] || null }, selectSubmission(submission) { selectedSubmission = submission }, async moveToArchive() { if (! selectedSubmission) return - await $wire.call('moveToArchive', selectedSubmission.id)+ const promise = $wire.call('moveToArchive', selectedSubmission.id) const currentIndex = submissions.findIndex((sub) => ( sub.id === selectedSubmission.id )) selectedSubmission = submissions[currentIndex + 1] || submissions[currentIndex - 1] || null submissions = submissions.filter((sub) => ( sub.id !== selectedSubmission.id )) + await promise }, }" > <!-- (...) --> </div>
That's it. The UI will be instantly updated when the request is sent to the server. Since we no longer rely on the re-renders, we can update the UI to reflect the intention of the action the user took on the submission.
This is a snippet of the actual code. In our case, we're also recording the most recent action taken to revert it by either clicking on the "undo" button in the success notification or pressing the keyboard shortcut, but the idea is the same. Here's how it currently feels:
Can the UI ever be too fast? Yes!
After we applied these optimistic UI tricks to FieldGoal's inbox, we found, for example, that if the user held down the archive keyboard shortcut for a bit too long, they'd end up archiving multiple submissions since the action is instant. We needed to slow things down just enough to add friction between actions.
Luckily, Alpine has two built-in modifiers to help us: .debounce
and .throttle
. Both the debounce and throttle modifiers default to a 250ms debounce or throttle interval respectively. You can tweak this higher or lower by doing something like debounce.300ms
or throttle.700ms
.
We experimented with both options and chose to apply Alpine's built-in .throttle
at the default 250ms
interval to solve this problem. We decided against debounce
because it adds a leading delay, which makes it seem like the action is slower (perceived performance downgrade). But throttle
added a trailing delay, which means that the first action is still instant, but the next action will face friction, which is what we want.
<button type="button" x-on:click.throttle="moveToArchive()"> Archive</button>
It's helpful to try the app and see how it feels after applying optimistic UI tricks. Sometimes, as users, we subconsciously expect a bit of a lag and a visible render to tell our brains that our intended actions worked. After applying some optimistic UI tricks, you may notice some areas needing more explicit feedback to tell the user that their action took effect.
Optimistic UI tricks help us maximize the user's perceived performance, and that's really important when we're creating a highly interactive experience. We started this refactor because, as heavy users of FieldGoal, we weren't satisfied with how the inbox felt to us.
On the other hand, FieldGoal has other pages that are much less interactive. Our settings and billing pages, for example, aren't particularly exciting or interactive. Could we invest time into making the "change password" experience seem faster through optimistic UI tricks? Probably. But it's hard to justify that investment, so we're very content to continue using Livewire for the entirety of the pages with very little or no Alpine involved.
An Optimistic UI really shines with interactivity. When you want users to get into a flow state and spend a lot of time on a particular part of the app, that's a great place to invest in optimistic UI. But it's okay to keep it simple with just SSR for the more "boring" bits where the user doesn't get more value from the optimistic UI experience.
There are many different approaches to building user interfaces on the web, and each approach has its own trade-offs and usefulness in a given situation. JS and HTML-over-the-wire have both seen a lot of success in the real world, and they each deserve their own well-earned props. Moreover, they can be even more powerful when wielded together.
Optimistic UI offers us a way to maximize perceived performance and create immersive experiences on the web. We focused on how these tricks play out in Livewire and Alpine, which helped us capture the best of both worlds—instantaneous-feeling actions on the UI like what you'd expect from a SPA through Alpine, combined with the developer experience you look for in Livewire.
These tricks aren't unique to Laravel and Alpine either. Optimistic UI concepts can be applied in any stack - whether you're building with Vue, React, Hotwire, or Livewire.
You don't always need an optimistic UI, though. And it sure isn't free. One thing we didn't cover here is that you now have to handle some new edge cases, like: what happens when the action fails? It might be easy to overlook these kind of issues and sometimes tricky to implement compensating actions.
When your app is focused on interactivity, these optimistic UI tricks can be a noticeable delight to the user experience. But it's okay to keep things simple for pages that don't need this level of interactivity, like a settings page.
We hope you enjoyed reading this post! Drop us a line on Twitter @TightenCo if you want to ask questions, share more optimistic UI tips, or say hi. 👋
A big shoutout to Caleb Porzio and Josh Cirre who recently posted content that inspired some of our internal conversations regarding optimistic UI.
wire:loading.remove
as a way to instantly hide a todo item that is marked "complete" on the client side without waiting for the server to respond.
We appreciate your interest.
We will get right back to you.