The Hidden Cost of Naive Livewire Components
Every wire:model binding without a modifier fires a full server round-trip on each keystroke. In a component with a search field, three filter dropdowns, and a paginated table, that's a network request per character typed. On a busy server this compounds quickly.
Livewire v3 ships with the tools to fix this — but they require deliberate use.
Computed Properties: Memoised, Lazy, and Cache-Aware
The #[Computed] attribute turns a method into a property that is evaluated once per render cycle and memoised for the lifetime of that request.
use Livewire\Attributes\Computed;
class ProductSearch extends Component
{
public string $query = '';
public string $category = '';
#[Computed]
public function products(): LengthAwarePaginator
{
return Product::query()
->when($this->query, fn ($q) => $q->search($this->query))
->when($this->category, fn ($q) => $q->where('category_id', $this->category))
->paginate(20);
}
public function render(): View
{
return view('livewire.product-search');
}
}
In the Blade template you access it as $this->products — Livewire calls the method once and caches the result for that render. No accidental double-query from calling $this->products in both a conditional and a loop.
Persisting Computed Values Across Requests
For expensive aggregates that don't change per keystroke, add a cache TTL:
#[Computed(cache: true, seconds: 60)]
public function categoryCounts(): Collection
{
return Product::query()
->selectRaw('category_id, count(*) as total')
->groupBy('category_id')
->pluck('total', 'category_id');
}
The result is stored in the Laravel cache keyed by component ID and method name. Invalidate it explicitly with unset($this->categoryCounts) when a mutation occurs.
Debouncing and Lazy Bindings
Never bind a search input with plain wire:model. Use wire:model.live.debounce.400ms to wait for the user to pause:
<input
wire:model.live.debounce.400ms="query"
type="text"
placeholder="Search products…"
/>
For fields where you only care about the final value (a select, a checkbox), wire:model.blur fires exactly one request when focus leaves the element:
<select wire:model.blur="category">
@foreach($categories as $id => $name)
<option value="{{ $id }}">{{ $name }}</option>
@endforeach
</select>
Combining .live.debounce on text inputs with .blur on selects already eliminates the majority of superfluous requests in a typical filter UI.
Batching Multiple Property Updates
When Alpine.js or a custom JS event needs to update several Livewire properties at once, avoid firing one request per property. Use $wire.set inside $wire.call — or better, batch them with the JS API:
// In an Alpine component or inline script
AsyncFunction: async () => {
await $wire.setMultiple({
query: '',
category: '',
page: 1,
});
}
On the PHP side, expose a single action that accepts all reset values:
public function resetFilters(): void
{
$this->query = '';
$this->category = '';
$this->resetPage();
}
One method call, one HTTP request, one re-render. This is always preferable to three sequential $wire.set() calls.
Skipping Re-Renders with #[Locked] and #[Renderless]
Not every action needs a full re-render. Mark actions that only mutate server state (e.g., toggling a favourite) with #[Renderless]:
use Livewire\Attributes\Renderless;
#[Renderless]
public function toggleFavourite(int $productId): void
{
auth()->user()->favourites()->toggle($productId);
}
The round-trip still happens, but Livewire skips diffing and patching the DOM entirely — a meaningful saving when the component renders a large table.
Key Takeaways
- Use
#[Computed]to memoize expensive queries per render; addcache: truefor cross-request persistence. - Replace
wire:modelwithwire:model.live.debounce.400mson text inputs andwire:model.bluron selects. - Consolidate multi-property resets into a single PHP action to avoid stacked round-trips.
- Mark fire-and-forget mutations with
#[Renderless]to skip DOM diffing. - Profile with browser DevTools Network tab first — count requests before optimising.