The problem: An x-data object works fine on its own, but the moment it sits inside a Livewire component, toggling state stops reacting — or worse, resets on every Livewire update.
Environment: Laravel 11, Livewire 3.x, Alpine.js 3.x, PHP 8.3
The Error
No JS error thrown.
x-data="{ open: true }" toggles correctly at first,
but resets to `open: true` every time a wire:click
anywhere in the component fires a re-render.
Why It Happens
Livewire and Alpine both manage DOM state, but they do it very differently. When a Livewire component re-renders (any wire:click, wire:model.live update, or polling tick), it diffs and morphs the DOM based on the server's Blade output. Alpine's x-data state, on the other hand, lives purely in the browser and has no idea a re-render even happened.
Normally Livewire 3's Alpine integration is smart enough to preserve Alpine state across morphs — that's the whole point of bundling Alpine directly into Livewire. But this preservation breaks in a few specific situations:
1. The element carrying x-data doesn't have a stable wire:key, so Livewire's morph algorithm treats it as a brand-new element on every render instead of recognizing it as the same node — and a new element means Alpine reinitializes its state from scratch.
2. The x-data object references a Livewire property directly (e.g. x-data="{ open: @entangle('showPanel') }") without realizing that @entangle makes Alpine's state a mirror of the server property — so every server-side change to showPanel legitimately overwrites the Alpine state, which looks like "resetting" but is actually working as designed.
3. The x-data element sits outside the Livewire component's root tag, meaning it isn't part of what Livewire tracks and morphs at all, so its behavior depends entirely on surrounding DOM changes.
The Fix
Add a stable wire:key to the element holding x-data so Livewire recognizes it across renders instead of recreating it:
<div wire:key="sidebar-panel" x-data="{ open: false }">
<button @click="open = !open">Toggle</button>
<div x-show="open">Panel content</div>
</div>
If you actually want the Livewire property and Alpine state to stay in sync intentionally, use @entangle deliberately and understand it's a two-way bridge, not a one-time initial value:
<div x-data="{ open: @entangle('showPanel').live }">
If you specifically want Alpine state to stay purely client-side and never resync with the server, keep it out of @entangle entirely and instead move the x-data element to a part of the DOM that Livewire doesn't touch on that particular re-render — for example, wrapping the toggle in its own wire:ignore block:
<div wire:ignore x-data="{ open: false }">
<button @click="open = !open">Toggle</button>
<div x-show="open">Panel content</div>
</div>
Step-by-Step
- Identify whether the Alpine state is supposed to be independent from the server, or intentionally synced via a Livewire property.
- For independent state, add a stable
wire:keyto thex-dataelement, or wrap it inwire:ignoreif it never needs server updates. - For synced state, use
@entangle('propertyName').liveand treat every server-side change as an expected overwrite. - Confirm the
x-dataelement is inside the Livewire component's single root element, not a sibling outside it. - Trigger a
wire:clickelsewhere in the component and confirm the Alpine state behaves as intended (preserved or synced, depending on your choice). - Check for duplicate
wire:keyvalues elsewhere in the same component — duplicates cause the same symptom but are easy to overlook.
Edge case: If you're using @entangle without .live, updates from Alpine to the server are deferred until the next network request, but updates from the server to Alpine still apply instantly — this asymmetry can look like a random, hard-to-reproduce bug where the panel "sometimes" resets, when it's actually deterministic based on which side triggered the change.
Still seeing state resets after checking wire:key and entangle? Reach out via the Contact page and I'll help debug it.