7 min read

AlpineJS in 2026: $store, $persist, and $dispatch Patterns for PHP Developers

Master AlpineJS state management with $store, $persist, and $dispatch. Practical patterns for PHP, Laravel, and Livewire developers building reactive UIs.

Featured image for "AlpineJS in 2026: $store, $persist, and $dispatch Patterns for PHP Developers"

AlpineJS has settled into a comfortable role in the PHP ecosystem: it is the JavaScript layer you reach for when Livewire’s server round-trips are overkill and a full Vue or React component is more ceremony than the problem deserves. If you are building applications with Laravel, Symfony, or any PHP backend in 2026, there is a good chance Alpine is already in your stack. But most developers are using maybe thirty percent of what it offers.

The $store, $persist, and $dispatch magics — along with x-teleport — are the parts that elevate Alpine from a Stimulus alternative to a genuine reactive state management layer. Here is a practical look at each one, with patterns that translate directly to the kinds of problems PHP developers face day-to-day.

Why Alpine Remains Relevant

Before diving into patterns, it is worth being clear about where Alpine fits in 2026. Livewire 4 is excellent for server-driven interactivity. Symfony UX Live Components solve the same problem for Symfony shops. But both of those tools come with a network request cost — every user interaction that updates state makes a round-trip to your PHP server.

Alpine lives entirely in the browser. For interactions that do not need server data — toggling a sidebar, managing a multi-step form’s current step, persisting a user’s dark mode preference, coordinating a toast notification system — Alpine handles them without any network traffic. The combination of Livewire handling data and Alpine handling local UI state is not a compromise; it is the intended architecture, and Livewire 4’s wire:model and Alpine’s x-model are designed to coexist cleanly.

$store: Reactive Global State

The $store magic gives you a reactive data store that any Alpine component on the page can read and write. You define stores in your Alpine initialization block, and they behave exactly like component x-data — changes propagate reactively to everything that references them.

// In your main layout or app.js
document.addEventListener('alpine:init', () => {
    Alpine.store('cart', {
        items: [],
        open: false,

        get count() {
            return this.items.length;
        },

        add(product) {
            this.items.push(product);
            this.open = true;
        },

        remove(productId) {
            this.items = this.items.filter(i => i.id !== productId);
        }
    });
});

Now any component on the page can read or mutate the cart without passing props through layers of components:

<!-- Cart icon in the navbar -->
<button @click="$store.cart.open = !$store.cart.open" class="relative">
    <svg><!-- cart icon --></svg>
    <span
        x-show="$store.cart.count > 0"
        x-text="$store.cart.count"
        class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full text-xs w-5 h-5 flex items-center justify-center"
    ></span>
</button>

<!-- Product card - completely separate component -->
<div x-data>
    <button @click="$store.cart.add({ id: {{ $product->id }}, name: '{{ $product->name }}', price: {{ $product->price }} })">
        Add to cart
    </button>
</div>

<!-- Cart drawer - also separate -->
<div x-show="$store.cart.open" x-cloak class="fixed inset-y-0 right-0 w-80 bg-white shadow-xl">
    <template x-for="item in $store.cart.items" :key="item.id">
        <div x-text="item.name"></div>
    </template>
</div>

The store acts as a single source of truth. Because it is reactive, every template expression that references $store.cart.count updates automatically when an item is added — no events, no prop drilling, no re-renders to coordinate manually.

For PHP applications, a natural pattern is to seed the store from server-rendered data. If the user has existing cart items from a session, you can pass them in at initialization:

<script>
document.addEventListener('alpine:init', () => {
    Alpine.store('cart', {
        items: @json($cartItems), // Blade/Twig injects existing items
        open: false,
        // ... methods
    });
});
</script>

$persist: Surviving Page Reloads

$persist is Alpine’s localStorage bridge. When you wrap a value in $persist(), Alpine automatically saves it to localStorage on every change and restores it on page load. The magic key is derived from the property name, with an optional prefix for namespacing.

<div x-data="{ darkMode: $persist(false), sidebarOpen: $persist(true) }">
    <button @click="darkMode = !darkMode">
        Toggle dark mode
    </button>
    <button @click="sidebarOpen = !sidebarOpen">
        Toggle sidebar
    </button>
</div>

After the first visit, these preferences survive page navigation, browser restarts, and full page reloads without any backend involvement. The value false is the default — if no localStorage entry exists yet, Alpine uses it; otherwise it restores whatever was saved.

One pattern that works particularly well for PHP developers building multi-page applications: persist UI state that would otherwise require a server round-trip or session storage. Tab selection, accordion state, pagination preferences, and column sort direction in data tables are all good candidates.

<!-- Data table with persisted sort preference -->
<div x-data="{
    sortColumn: $persist('created_at'),
    sortDirection: $persist('desc'),

    sort(column) {
        if (this.sortColumn === column) {
            this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
        } else {
            this.sortColumn = column;
            this.sortDirection = 'asc';
        }
        // Trigger a Livewire method or form submission with the new sort
        this.$dispatch('sort-changed', { column: this.sortColumn, direction: this.sortDirection });
    }
}">

$dispatch: Custom Events Between Components

$dispatch emits a custom browser event from the current element, which bubbles up the DOM. Any ancestor element — including a Livewire component — can listen for it. This is how Alpine components coordinate without being directly nested.

The pattern works both within Alpine (using @event-name) and across the Alpine/Livewire boundary (using @event-name.window or Livewire’s wire:on):

<!-- Deep in a product card component -->
<button @click="$dispatch('product-selected', { id: productId, name: productName })">
    Select
</button>

<!-- Livewire component wrapping the product grid -->
<div wire:on:product-selected="selectProduct($event.detail.id)">
    <!-- product grid renders here -->
</div>

For Alpine-to-Alpine communication across sibling components (where events do not naturally bubble to a shared ancestor), dispatch to window:

<!-- Notification trigger anywhere on the page -->
<button @click="$dispatch('notify', { message: 'Saved!', type: 'success' })">
    Save
</button>

<!-- Notification display component, listening globally -->
<div
    x-data="{ notifications: [] }"
    @notify.window="notifications.push($event.detail); setTimeout(() => notifications.shift(), 3000)"
>
    <template x-for="note in notifications" :key="note.message">
        <div x-text="note.message" :class="note.type === 'success' ? 'bg-green-500' : 'bg-red-500'" class="p-4 text-white rounded">
        </div>
    </template>
</div>

This toast notification pattern is common enough that it is worth putting in a reusable Blade component or Twig partial. The $dispatch stays in your component-specific templates; the listener lives in your layout.

x-teleport: Rendering Outside the Component Tree

x-teleport moves Alpine-managed content to a different part of the DOM while keeping it reactive to the originating component’s data. The most common use is modals: your modal trigger button is deep in a card or list item, but the modal itself needs to render at the <body> level to avoid z-index and overflow-hidden issues.

<!-- Product card - buried in a grid -->
<div x-data="{ showModal: false, product: @json($product) }">
    <button @click="showModal = true">Quick View</button>

    <template x-teleport="body">
        <div
            x-show="showModal"
            x-cloak
            @click.self="showModal = false"
            class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
        >
            <div class="bg-white rounded-lg p-6 max-w-md w-full">
                <h2 x-text="product.name"></h2>
                <p x-text="product.description"></p>
                <button @click="showModal = false">Close</button>
            </div>
        </div>
    </template>
</div>

The teleported content renders as a direct child of <body> in the DOM, but it still has full reactive access to showModal and product from the originating x-data. When showModal changes, both the button’s state and the modal update together.

Combining the Patterns

In a real PHP application, these pieces often work together. A typical admin interface might use $store for the notification queue and sidebar state, $persist for the user’s table preferences and dark mode setting, $dispatch to communicate from deeply nested Blade/Livewire components up to the layout-level notification handler, and x-teleport for any confirmation dialogs or complex overlays.

The result is a frontend that handles all local UI state without server round-trips, while Livewire or Symfony Live Components handle the data fetching and mutation that actually needs PHP. Alpine’s reactive system is lightweight enough that adding all of these patterns adds only a few kilobytes of JavaScript overhead — far less than any alternative framework that could solve the same problems.

If you have been using Alpine primarily for x-show, x-bind, and @click, spending an afternoon with $store and x-teleport will substantially change what you reach for when building the next interactive PHP interface.

Sources