Utsav Somaiya / Article

Dispatching Alpine Events Between Livewire Components

A small pattern for dispatching Alpine events, letting another Livewire component react, and keeping the UI optimistic without introducing a heavy global store.

Sometimes the cleanest Livewire fix is not another layer of abstraction.

It is just this: dispatch a small Alpine event from the place where the interaction happens, let another Livewire component listen to it, and make the UI feel instant before the server round-trip finishes.

That pattern has been stuck in my head ever since this moment from Caleb's talk:

A quick Caleb Porzio moment that made me think again about small, sharp Livewire interaction patterns.

The Shape Of The Problem

One component owns the action. Another component somewhere else on the page needs to react to it.

For example:

  • a row action updates a negotiation state
  • a summary card needs to refresh
  • a badge count should move instantly

I did not want to refresh everything. I also did not want to build a big store just to move one piece of information across the page.

The Source Component

The first component handles the click, updates the visible state optimistically, and dispatches a browser event with the useful details.

1<div
2 x-data="{
3 approved: @js($approved),
4 pending: false,
5 async approve() {
6 if (this.pending) return
7 
8 this.pending = true
9 this.approved = true
10 
11 $dispatch('negotiation-approved', {
12 negotiationId: @js($negotiation->id),
13 approved: true,
14 })
15 
16 try {
17 await $wire.approve()
18 } catch (error) {
19 this.approved = false
20 
21 $dispatch('negotiation-approved', {
22 negotiationId: @js($negotiation->id),
23 approved: false,
24 })
25 } finally {
26 this.pending = false
27 }
28 }
29 }"
30>
31 <button
32 type="button"
33 x-on:click="approve()"
34 x-bind:disabled="pending"
35 class="rounded-full border px-4 py-2"
36 >
37 <span x-show="!pending && !approved">Approve</span>
38 <span x-show="pending">Approving...</span>
39 <span x-show="!pending && approved">Approved</span>
40 </button>
41</div>
1<div
2 x-data="{
3 approved: @js($approved),
4 pending: false,
5 async approve() {
6 if (this.pending) return
7 
8 this.pending = true
9 this.approved = true
10 
11 $dispatch('negotiation-approved', {
12 negotiationId: @js($negotiation->id),
13 approved: true,
14 })
15 
16 try {
17 await $wire.approve()
18 } catch (error) {
19 this.approved = false
20 
21 $dispatch('negotiation-approved', {
22 negotiationId: @js($negotiation->id),
23 approved: false,
24 })
25 } finally {
26 this.pending = false
27 }
28 }
29 }"
30>
31 <button
32 type="button"
33 x-on:click="approve()"
34 x-bind:disabled="pending"
35 class="rounded-full border px-4 py-2"
36 >
37 <span x-show="!pending && !approved">Approve</span>
38 <span x-show="pending">Approving...</span>
39 <span x-show="!pending && approved">Approved</span>
40 </button>
41</div>

That part matters: the user sees the state move immediately. The server still confirms the truth, but the interface does not feel late.

The Other Livewire Component

Now another component can listen for that event with Alpine and hand control back to its own $wire.

1<div
2 x-data="{ approvedCount: @js($this->approvedCount) }"
3 x-on:negotiation-approved.window="
4 approvedCount = $event.detail.approved
5 ? approvedCount + 1
6 : Math.max(approvedCount - 1, 0)
7 
8 $wire.refreshApprovedCount()
9 "
10>
11 <p class="text-sm text-zinc-500">Approved negotiations</p>
12 <p class="text-3xl font-semibold" x-text="approvedCount"></p>
13</div>
1<div
2 x-data="{ approvedCount: @js($this->approvedCount) }"
3 x-on:negotiation-approved.window="
4 approvedCount = $event.detail.approved
5 ? approvedCount + 1
6 : Math.max(approvedCount - 1, 0)
7 
8 $wire.refreshApprovedCount()
9 "
10>
11 <p class="text-sm text-zinc-500">Approved negotiations</p>
12 <p class="text-3xl font-semibold" x-text="approvedCount"></p>
13</div>

I like this because the boundary stays clear:

  • Alpine handles the tiny front-end interaction
  • the event carries only what the rest of the page needs
  • the second Livewire component decides how to reconcile itself

Why The Optimistic Part Helps

Without the optimistic step, the interaction waits on the network. That usually means:

  • the button feels heavier than it should
  • the badge updates too late
  • the UI looks unsure even when the action is simple

With the optimistic step, the page feels more direct. If the backend disagrees, you still have a rollback path. But most of the time, the interface gets to feel correct at the exact moment the user clicked.

Why I Reach For This

This pattern is small, but it scales well.

It works when:

  • you want components to stay separate
  • you only need to share one small state change
  • you want a faster feeling without making the architecture messy

For me, this is the sweet spot: Alpine for the interaction edge, Livewire for the server-backed truth, and just enough optimism to make the whole thing feel alive.