Alpine.js vs. proxy-based reactivity in Vue
Alpine.js actually depends directly on the npm package @vue/reactivity, so at their core both frameworks run on the very same proxy machinery. Reactivity still feels different in daily Alpine use than in Vue, with real consequences for debugging and for performance on large, nested objects.
Table of Contents
- 1. The shared origin: Alpine depends directly on @vue/reactivity
- 2. How proxy-based reactivity works in general
- 3. Difference 1: no memoized computed() in plain Alpine getters
- 4. Difference 2: effect granularity instead of virtual DOM diffing
- 5. Debugging consequence: what actually counts as reactive?
- 6. Performance on large, deeply nested objects
- 7. Alpine.raw(): deliberately bypassing proxy wrapping
- 8. Public API in Vue vs. internal implementation detail in Alpine
- 9. The practical consequence for everyday debugging
- 10. Summary
- 11. FAQ
1. The shared origin: Alpine depends directly on @vue/reactivity
A look at the Alpine core package's package.json reveals something many Alpine developers do not realize: Alpine declares @vue/reactivity as a direct dependency, currently pinned to a version from Vue's 3.1 line. Alpine does not reimplement its own small reactive(), effect(), and raw() from scratch, it imports the exact same library Vue 3 uses for its own reactivity, and wires it in through a swappable engine interface.
The real difference therefore is not the underlying mechanism, it is how each framework packages and applies that mechanism for its users. Understanding why Alpine still behaves differently from a Vue component does not mean looking for a different reactivity algorithm, it means looking at how Alpine puts this shared foundation to use.
2. How proxy-based reactivity works in general
Both Alpine and Vue wrap a plain JavaScript object inside a Proxy. Every read access to a property of that proxy runs through a get trap that remembers which currently running reactive function, an effect() in Alpine's terms, just read that property. Every write access runs through a set trap that reruns every previously remembered function, provided the value actually changed.
This exact interplay of get tracking and set triggering is what makes x-text="count" update automatically the moment count++ happens anywhere in the code, with no one having to trigger an update manually. Nested objects are not fully walked and wrapped the moment the proxy is created, they only get wrapped in their own proxy on the first read access to that nested field, a behavior known as lazy deep reactivity.
3. Difference 1: no memoized computed() in plain Alpine getters
Vue offers computed(() => ...) as its own, publicly documented primitive, which caches a computed value and only recalculates once one of its dependencies actually changed. Alpine has no comparable public computed() for everyday use inside x-data. Define a computed value in Alpine through a plain getter on the x-data object, and you get reactivity, but no caching: the getter code reruns on every single read access, even if the underlying values have not changed at all since the previous read.
That is not an oversight, it is a deliberate consequence of Alpine using @vue/reactivity as its engine without applying that package's separate computed() primitive to x-data getters. For an expensive calculation, filtering and sorting a large array inside a getter that multiple x-text or x-show bindings read at once, that means the same expensive calculation runs several times per render cycle, while a Vue computed() in the same spot would calculate once and reuse the result for every other reader.
Alpine.data('productList', () => ({
products: [ /* large array */ ],
filter: '',
// Reruns on EVERY read, no caching like Vue's computed()
get filteredProducts() {
console.log('Filter runs again')
return this.products.filter(p => p.name.includes(this.filter))
},
}))
// If filteredProducts is read in three different places in the
// markup (x-text, x-show, x-for), the filter runs THREE times
// per update, not once like a cached computed().
4. Difference 2: effect granularity instead of virtual DOM diffing
Vue ties reactivity to a component's render function as a whole: whenever any reactive value the render function reads changes, the entire function reruns, produces a new virtual DOM tree, and a diffing algorithm compares it against the previous tree to compute the minimal set of real DOM changes needed. Alpine skips that detour entirely: every single directive such as x-text, x-show, or x-bind:class gets its own tiny effect(), which on a change performs directly and exclusively that one DOM operation.
This finer granularity is one of the main reasons Alpine works without a virtual DOM at all: there simply is no whole tree that needs recomputing and comparing, because every reactive connection is bound directly and individually to the exact DOM spot it affects, right from the start. The cost is more individual, small effect() instances in memory, the benefit is an update model that is often cheaper for small, deliberately interactive DOM islands, the kind Alpine typically manages, than a full component rerender.
5. Debugging consequence: what actually counts as reactive?
In Alpine, only what stays reachable through the proxy chain, starting from the original x-data object, counts as reactive. Destructure a single primitive property out of that object, for example const { count } = this, and the new local variable count loses its connection to the proxy entirely, because primitive values in JavaScript are always copied by value rather than kept as a reference the way objects are. An effect() that only reads the local copy afterward never reacts to later changes on the original again.
The tricky part is that this mistake throws no console error at all. The application keeps running, the value keeps rendering, it simply never updates again, which in practice often surfaces only after a long debugging session as lost reactivity rather than broken logic. The Alpine DevTools continue to show correctly reactive values for the original object, which further obscures the destructured copy's lost connection, since that is not where anyone is looking anymore.
Alpine.data('counter', () => ({
count: 0,
init() {
// WRONG: count is now a detached copy, no longer a reference
let { count } = this
setInterval(() => {
count++ // only mutates the local copy, the UI never updates
}, 1000)
},
}))
// RIGHT: going through this.count keeps the proxy connection intact
// setInterval(() => { this.count++ }, 1000)
6. Performance on large, deeply nested objects
Because both Alpine and Vue only wrap nested objects into a fresh proxy on first read access, wrapping cost spreads out over runtime instead of hitting all at once when the x-data object is created. For small, typical Alpine components, the kind common for individual interactive islands on a server rendered page, this cost essentially never matters.
It only becomes a real problem once a very large, deeply nested JSON object, a full product list with hundreds of entries and several nested levels per entry for example, is made fully reactive even though only a fraction of its fields are ever read inside a directive. Every access to a not yet wrapped nested field creates a fresh proxy, and inside an x-for loop iterating over many such objects, that adds up to noticeable overhead, especially during the initial render of the list.
7. Alpine.raw(): deliberately bypassing proxy wrapping
For exactly this case, Alpine provides Alpine.raw(proxy), the direct counterpart to Vue's toRaw(). The call returns the underlying, unwrapped original object, with no proxy overhead and no reactivity tracking at all. That suits large amounts of data that only get loaded once and then iterated, never individually watched, a large reference list that only ever gets read from, for example.
It matters that Alpine.raw() only unwraps the object once, the result itself remains a plain, non reactive JavaScript object afterward. Hook that raw object back into a reactive x-data and it has to go through Alpine.reactive() again to start tracking changes. In practice, Alpine.raw() pays off mostly combined with x-for over large, largely static data sets, where proxy tracking on every single field would be pure overhead.
Alpine.data('catalog', () => ({
// large, largely static reference data with no per field reactivity
products: Alpine.raw(largeProductList),
filter: '',
}))
8. Public API in Vue vs. internal implementation detail in Alpine
Vue turns its reactivity into a central, publicly documented building block of the language: ref(), reactive(), computed(), and watch() are concepts every Vue tutorial and doc page teaches from the start. Alpine treats the very same underlying engine as an internal detail instead: Alpine.reactive(), Alpine.effect(), and Alpine.raw() all exist and are essential for building custom directives and plugins, but they practically never show up in ordinary Alpine app development with x-data.
This difference in visibility has a simple reason: Vue components are built explicitly with JavaScript code inside a setup() function or a <script setup> section, where the reactivity primitives have to be used manually anyway. Alpine instead deliberately targets HTML first development, where a plain object literal in x-data becomes fully reactive automatically, without anyone calling reactive() themselves.
9. The practical consequence for everyday debugging
Anyone coming from Vue and searching Alpine for a computed() import will not find one, because it is deliberately not part of the public API. Anyone instead noticing a performance dip on a frequently read getter property should first check whether the same expensive calculation is being read from several places in the markup at once, exactly the pattern that, unlike in Vue, becomes a candidate for redundantly repeated work in Alpine.
Anyone finding a value that simply stops updating, even though the logic looks correct, should specifically look for destructured primitive values pulled out of the reactive object, the most common silent loss of reactivity in Alpine code. Both patterns can be traced through the Alpine DevTools, by watching which effect() instances actually rerun on a change and which do not.
| Aspect | Alpine.js | Vue 3 |
|---|---|---|
| Underlying mechanism | Proxy, direct dependency on @vue/reactivity | Proxy, native @vue/reactivity |
| computed() caching in default usage | No, a getter in x-data reruns on every read | Yes, computed() caches until a dependency changes |
| Effect granularity | One effect() per directive/binding | One render effect per component, then virtual DOM diffing |
| Visibility of the reactivity API | Internal detail, rarely visible in ordinary app development | ref, reactive, computed, watch as a central, documented API |
| Access to raw, unwrapped data | Alpine.raw() |
toRaw() |
Mironsoft
Alpine.js interactivity for Hyvä frontends
A Hyvä frontend that needs more interactivity, but without React overhead?
We build interactive frontend components for Hyvä themes with Alpine.js, lightweight and without build-step complexity, from simple toggles to complex form flows.
Custom Components
Develop interactive Alpine.js components for specific shop requirements.
Performance Review
Review existing Alpine.js implementations for reactivity pitfalls and performance.
Team Training
Bring developers up to speed on Alpine.js patterns for Hyvä themes hands-on.
10. Summary
Alpine reactivity vs. Vue: the essentials at a glance
Shared foundation
Alpine depends directly on the npm package @vue/reactivity, both run on the same proxy engine.
No caching
Getters in x-data rerun on every read access, unlike Vue's memoized computed().
Fine grained effects
Alpine ties effect() directly to individual directives instead of a whole component render function.
Alpine.raw()
Deliberately bypasses proxy wrapping for large, largely static data structures.