Alpine.js State: When to Use x-data and When to Use a Store
Local x-data scopes and global Alpine.store solve different problems. Here is a practical rule for choosing between them, with a cart badge example.
04 Nov 2025, 08:52 UTC

A server-rendered page with a cart badge in the header and an "Add to cart" button inside a product card has a state problem: the two elements sit in different parts of the DOM, but they need the same number. Alpine.js gives you two places to put that number — a local x-data scope or a global Alpine.store — and choosing the wrong one is the difference between a two-line change and a long debugging session.
The rule that holds up in practice: keep state in x-data until a second, unrelated part of the DOM needs it. Only then promote it to a store.
What x-data actually creates
x-data declares a reactive scope on an element. Every property in the object you pass becomes available to that element and its descendants through Alpine's expression evaluator. Each element carrying x-data gets its own independent copy, so the same component can appear five times on a page without the instances sharing anything.
That isolation is the point. A dropdown's open flag, a tab index, or a form field's touched state belongs to one component. Putting those in a global store means every other component re-evaluates when the dropdown opens, which is wasted work and extra coupling.
One detail that trips people up: nested x-data creates a new scope, and lookups resolve to the nearest ancestor scope that defines the property. If a child scope shadows a parent property with the same name, the parent value is no longer reachable by that name from inside the child.
When to promote state to Alpine.store
Alpine.store registers a named reactive object that any component can read through the $store magic property. It is not tied to an element, so it survives DOM changes and is reachable from anywhere in the tree without nesting scopes just to pass values down.
Good candidates: the cart count, the current user, a theme preference, a toast queue. Poor candidates: anything only one component reads, and anything that changes at high frequency, such as scroll position or pointer coordinates, because every expression referencing the store re-evaluates on each change.
Worked example: a cart badge fed by a store
Assumption: Alpine 3.x loaded from a script tag or bundled, with Alpine.start() running after your code. The alpine:init event fires before Alpine walks the DOM, which is the documented place to register stores.
Register the store in your main script, before Alpine initializes:
document.addEventListener('alpine:init', () => {
Alpine.store('cart', {
count: 0,
add() {
this.count++
}
})
})
Then use it from two unrelated components. The header only reads; the product card keeps its own transient "adding" flag locally, because nothing else needs to know about it.
<header x-data>
<span>Cart: <span x-text="$store.cart.count"></span></span>
</header>
<div x-data="{ isAdding: false }">
<button
x-on:click="isAdding = true; $store.cart.add(); isAdding = false"
x-bind:disabled="isAdding">
Add to cart
</button>
</div>
If the initial count comes from the server, pass it in with a data attribute and read it in x-init rather than hardcoding zero:
<header x-data x-init="$store.cart.count = Number($el.dataset.initialCount)"
data-initial-count="3">
...
</header>
Note that x-init runs once per element when Alpine initializes it; it is not a watcher. Use it for setup, not for reacting to later changes.
Checking that it works
Open the browser developer console on the page and inspect the store directly:
Alpine.store('cart').count
It should return the current number. Setting it — Alpine.store('cart').count = 10 — should update the header text without a page reload, which confirms the binding is live rather than a one-time render. If the header does not change, check that the store was registered inside alpine:init and that the component sits inside the DOM subtree Alpine has initialized.
Trade-offs and limits
- Portability. A component that reads
$store.cartcannot be dropped onto another page without that store definition. Localx-datacomponents move cleanly. - Debugging depth. Deeply nested
x-datascopes make it hard to tell which scope owns a value. Needing three levels of nesting to reach a value is usually a signal to promote it. - Observer cost. Every expression referencing a store value is an observer. A store that changes on every mousemove will re-run many expressions per frame.
Practical rule
- Start every piece of state in the component's own
x-data. - Promote it to
Alpine.storeonly when a second, unrelated part of the DOM needs to read or write it. - Use
x-initto seed either kind of state from server-rendered data, and verify in the console that the value is reactive, not merely present.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.