/ sagar_pansuriya
§ AlpineJS

Alpine Stores vs Livewire State: Where Should UI State Live in a TALL App?

Illustration of state tokens being sorted into a server box for Livewire, a URL bar, and a browser box for Alpine stores

You've probably seen a Livewire component like this. It has $orders, $filters and $page, which make sense. It also has $showFilterPanel, $activeTab, $isDropdownOpen and $hoveredRow. Every time someone opens a dropdown, the browser sends a request, PHP hydrates the component, re-renders the Blade view, and sends back HTML so a menu can appear. On a good connection it takes 150 ms. On a train it takes a second.

Nothing is broken. It's just that state ended up on the server because Livewire makes it so easy to put it there. In a TALL app you have several places state can live, and picking the right one is most of the difference between an app that feels instant and one that feels like it's thinking.

This post gives you a way to classify state, the tool that fits each kind, and the trade-offs of the tools that sit between Alpine and Livewire.

Four kinds of state

Before choosing a tool, ask what the state is. In almost every TALL app I've worked on, state falls into one of four buckets:

KindExamplesHome
Server / domainOrders, the cart, form values that get validated and savedLivewire properties
URLSearch query, active filters, sort, page, selected tab you'd shareLivewire #[Url]
UI-ephemeralDropdown open, hover, accordion, "show password"Local Alpine x-data
Cross-component UISidebar collapsed, toast queue, theme, command palette openAlpine.store (plus $persist)

The question that sorts most state instantly: does the server need to know? If PHP never reads the value to query, validate or authorise something, it probably shouldn't cost a round trip to change.

Server state: Livewire properties

This is what Livewire is great at. Data that comes from the database, gets validated, or decides what the user is allowed to see belongs in public properties or computed properties:

class OrderIndex extends Component
{
    public string $status = 'open';

    #[Computed]
    public function orders()
    {
        return auth()->user()->orders()
            ->where('status', $this->status)
            ->latest()
            ->paginate(20);
    }
}

Remember that public properties are sent to the browser and back on every request, so keep them small. Store IDs, not whole models, and never trust a public property the user could tamper with for authorisation. Livewire's #[Locked] attribute stops the client from changing a property, which is worth using for things like a record ID.

URL state: #[Url]

If a user would reasonably expect to bookmark it, share it or hit back to undo it, it belongs in the URL. Filters and search are the classic case. Livewire makes this a one-line change:

use Livewire\Attributes\Url;

class OrderIndex extends Component
{
    #[Url(as: 'q', except: '')]
    public string $search = '';

    #[Url(history: true)]
    public string $status = 'open';
}

as gives the query string a shorter name, except keeps default values out of the URL, and history: true pushes a browser history entry so the back button steps through filter changes instead of leaving the page. Use history for deliberate choices like a status tab, not for a search box, or back becomes unusable.

URL state is still server state: changing it runs a request. That's fine, because changing a filter should fetch new data.

UI-ephemeral state: plain x-data

Dropdowns, tooltips, accordions, tab panels whose content is already on the page, password visibility toggles. None of these need PHP. Keep them in Alpine, local to the element:

<div x-data="{ open: false }" @click.outside="open = false" class="relative">
    <button @click="open = !open" :aria-expanded="open">Actions</button>

    <div x-show="open" x-transition class="absolute right-0 mt-2">
        <button wire:click="archive({{ $order->id }})">Archive</button>
        <button wire:click="duplicate({{ $order->id }})">Duplicate</button>
    </div>
</div>

Opening the menu is instant. Only the actions that change data go to the server. This one habit removes a surprising number of round trips from a typical Livewire app.

A detail worth knowing: when Livewire re-renders, its morphing keeps Alpine state on elements that survive the morph, so the dropdown won't snap shut every time the component updates. Add wire:key to repeated items so elements are matched correctly and the right menu keeps its state.

Cross-component UI state: Alpine.store

Some UI state is needed in more than one place. The sidebar toggle is in the header, but the sidebar is in the layout. A toast can be triggered from any component. Passing this through Livewire means a round trip and usually an event bus. An Alpine store is a global reactive object any component can read and write:

// resources/js/app.js
document.addEventListener('alpine:init', () => {
    Alpine.store('ui', {
        sidebarCollapsed: Alpine.$persist(false).as('ui.sidebarCollapsed'),
        paletteOpen: false,

        toggleSidebar() {
            this.sidebarCollapsed = !this.sidebarCollapsed;
        },
    });

    Alpine.store('toasts', {
        items: [],
        push(message, type = 'success') {
            const id = Date.now() + Math.random();
            this.items.push({ id, message, type });
            setTimeout(() => this.dismiss(id), 4000);
        },
        dismiss(id) {
            this.items = this.items.filter(t => t.id !== id);
        },
    });
});
{{-- header --}}
<button @click="$store.ui.toggleSidebar()">☰</button>

{{-- layout --}}
<aside :class="$store.ui.sidebarCollapsed ? 'w-16' : 'w-64'">…</aside>

{{-- toasts, anywhere in the layout --}}
<div x-data @notify.window="$store.toasts.push($event.detail.message)">
    <template x-for="t in $store.toasts.items" :key="t.id">
        <div x-text="t.message" @click="$store.toasts.dismiss(t.id)"></div>
    </template>
</div>

The toast example also shows the clean way for the server to talk to UI state. A Livewire action calls $this->dispatch('notify', message: 'Order archived'), the event reaches the browser, and Alpine owns everything after that. The server doesn't track which toasts are visible, because it has no reason to.

Stores also work well with wire:navigate. Because navigation swaps the page without reloading JavaScript, the store stays in memory between pages, so the command palette or a half-finished UI preference survives the navigation.

$persist: remember it across reloads

The sidebarCollapsed line above uses Alpine's Persist plugin, which saves a value to localStorage and restores it on the next visit. If you use Livewire 3's bundled Alpine, Persist is already included; with standalone Alpine, install @alpinejs/persist and register it. It works in local components too:

<div x-data="{ view: $persist('grid').as('orders.view') }">
    <button @click="view = 'grid'">Grid</button>
    <button @click="view = 'list'">List</button>
</div>

Always give persisted values an explicit .as() key. Without one, keys are generated from the property name, and two components with a view property will overwrite each other. Also remember localStorage is per device: if a preference should follow the user to their phone, it's server state and belongs in the database.

The bridge: $wire and entangle

Sometimes state genuinely needs to exist on both sides. A modal that opens from Alpine but whose visibility the server also sets after saving. A rich text editor whose content must end up in a Livewire property. This is where $wire and entangle come in.

<div x-data="{ open: $wire.entangle('showEditModal') }">
    <button @click="open = true">Edit</button>
    <div x-show="open">…</div>
</div>

entangle keeps an Alpine property and a Livewire property in sync. In Livewire 3 it's deferred by default: Alpine updates instantly, and the value travels to the server with the next request. A live modifier exists that syncs on every change, which brings the round trips right back.

The trade-offs are real:

  • Two sources of truth. If the server and the client both change the value around the same request, it's not always obvious which one wins.
  • Hidden cost. A live entangle on something that changes often, like a slider, quietly becomes a request per movement.
  • Often unnecessary. The Livewire docs themselves suggest using $wire directly in most cases. $wire.showEditModal is reactive in Alpine, and setting it updates the Livewire property locally until the next request.
<div x-data>
    <button @click="$wire.showEditModal = true">Edit</button>
    <div x-show="$wire.showEditModal">…</div>
</div>

My default: if the server never needs to set the value, keep it in Alpine alone. If the server needs to close the modal after a save, the simplest option is often a dispatched browser event ($this->dispatch('close-modal')) rather than shared state at all. Reach for $wire or entangle only when both sides truly read and write the same value.

A decision flowchart

When you're adding a new piece of state, walk it through these questions in order:

Decision flowchart: if the server must read the value it is a Livewire property; if it should be shareable it goes in the URL with the Url attribute; if several components need it use an Alpine store, persisted if it should survive reloads; otherwise keep it in local x-data
  1. Does PHP need to read it to query, validate, save or authorise? Livewire property.
  2. Should it survive a shared link or the back button? Add #[Url].
  3. Is it needed by more than one component? Alpine.store.
  4. Should it survive a reload on this device? Wrap it in $persist with an explicit key.
  5. Otherwise: local x-data. This should be the most common answer.

Takeaways

  • Classify state before choosing a tool: server, URL, UI-ephemeral or cross-component UI.
  • "Does the server need to know?" sorts most of it. If not, don't pay a round trip.
  • Use #[Url] for filters and search, with history only for deliberate navigation.
  • Keep dropdowns, tabs and toggles in local x-data.
  • Use Alpine.store for shared UI state and let Livewire talk to it through dispatched events.
  • Always key $persist with .as(), and keep cross-device preferences in the database.
  • Treat entangle as a last resort, and avoid live syncing on values that change often.

Go back to the component from the start. $status and $search stay, with #[Url]. $showFilterPanel moves to a store, $isDropdownOpen and $activeTab move to local x-data, and $hoveredRow goes away entirely in favour of a CSS :hover. The component gets smaller, every menu opens instantly, and the server only hears about things it actually cares about.

Discussion