Reactivity: Alpine.js vs. Proxy-Based Reactivity in Vue Compared
AI generated
x-data
Alpine
Alpine.js / Reactivity Engine
Reactivity Compared
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.

11 min read @vue/reactivity Alpine.raw()

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.

11. FAQ: Alpine reactivity vs. Vue: the essentials at a glance

1Does Alpine.js really use the same library as Vue for reactivity?
Yes, Alpine declares @vue/reactivity as a direct npm dependency and uses it, through a swappable engine interface, as the core of its own reactivity.
2Why does Alpine still feel different from Vue?
The difference is not the mechanism, it is how each framework applies it: Alpine ties effects directly to individual directives instead of a whole component render function, and offers no public, memoized computed().
3Does Alpine have a computed() like Vue?
Not for everyday use. A getter on an x-data object is reactive, but it recalculates on every read access instead of being cached the way Vue's computed() is.
4What does that mean for an expensive calculation inside a getter?
If the same getter is read from several places in the markup at once, in x-text and x-show for example, the expensive calculation runs multiple times per update instead of once, unlike a cached Vue computed().
5Why does a destructured property lose its reactivity?
Primitive values are copied by value in JavaScript. A number destructured with const { count } = this becomes a detached copy afterward, with no connection to the original object's reactive proxy.
6Does Alpine throw an error when reactivity is lost?
No, the application keeps running normally, the affected value simply never updates again. That makes this mistake especially hard to find in practice without specifically looking for it.
7Why does Alpine not need a virtual DOM?
Because every directive gets its own fine grained effect() that performs directly and exclusively the one affected DOM operation, instead of recomputing and comparing a whole tree the way Vue does.
8What does Alpine.raw() do?
Alpine.raw(proxy) returns the underlying, unwrapped object with no proxy overhead and no reactivity tracking, the equivalent of Vue's toRaw().
9When does Alpine.raw() pay off in practice?
Mostly for large, largely static data structures, a long product list inside x-for for example, where per field proxy tracking would be pure overhead with no real benefit.
10Do you need to use Alpine.reactive() or Alpine.effect() in normal project work?
Usually not. Those functions matter mainly for building custom directives and plugins, while a normal x-data object becomes reactive automatically without anyone calling them directly.