Vue 3 Reactivity Transform: Understanding the Experiment
AI generated
<v/>
{ }
Vue.js · Compiler · Reactivity · History
Vue 3 Reactivity Transform
understanding the experiment

Reactivity Transform promised reactive values without .value, destructurable directly like normal variables. The compiler experiment was officially dropped in 2023. If you read old code or old blog posts about it today, you should understand how it worked, why it failed, and what remains relevant for practice.

17 min read $ref · $computed · compiler macros Vue 3.0 - 3.4 · historical feature

1. What Reactivity Transform was meant to solve

Reactivity Transform was an experimental compiler feature in Vue 3 that existed as an optional opt in between version 3.0 and 3.4. The core idea: instead of writing .value explicitly on every access to a ref, a compiler macro such as $ref() would let you read and write reactive values like normal variables. Reactivity Transform tried to eliminate the most common source of beginner confusion this way, namely constantly forgetting .value in script logic outside of templates.

The compiler analyzed the source code at build time and replaced the macro calls with actual ref() calls, with .value access inserted automatically. To the developer, the code looked as if the .value indirection did not exist at all, while at runtime exactly the same reactivity system ran as without Reactivity Transform. The goal was purely syntactic, not a new reactivity engine, just more compact code for the same semantics.

Important for understanding this: Reactivity Transform was never part of the official recommendation for new projects, it was always marked as experimental, similar to a research prototype within the Vue ecosystem. Anyone who runs into older tutorials, GitHub repos, or Stack Overflow answers from 2021 to 2023 today frequently finds Reactivity Transform syntax, without it being obvious that the feature has since been removed from core.

2. $ref, $computed and the macro syntax in detail

The central macro function was $ref(), a compiler macro that looked like a normal function call on the surface, but at build time was treated completely differently from a real JavaScript function. let count = $ref(0) was transformed by the compiler into let count = ref(0), with every later read of count automatically becoming count.value and every assignment automatically becoming count.value = .... Similarly there was $computed() for computed values, and $$() to convert an already transformed value back into a real ref when it had to be passed to a function expecting an actual ref.

These macros only worked because a special compiler pass analyzed the entire <script setup> block and rewrote every usage of the marked variables throughout the entire function body. That is fundamentally different from a normal function executed at runtime. Reactivity Transform was therefore inseparably tied to Vue's specific single file component compiler and could not work in plain .js files without that build step.


// Reactivity Transform syntax (experimental, removed in Vue 3.4)
// Requires the compiler macro to be enabled explicitly in build config

let count = $ref(0)
let doubled = $computed(() => count * 2)

function increment() {
  count++ // compiled to count.value++
}

// Passing a transformed variable to a function expecting a real ref
function useExternalRef(actualRef) {
  console.log(actualRef.value)
}
useExternalRef($$(count)) // $$() converts back to a real ref

3. Destructuring reactive objects without .value

A second central promise of Reactivity Transform was $(), a macro that allowed destructuring reactive objects without losing reactivity. Normally, a destructuring assignment like const { x, y } = reactive({ x: 1, y: 2 }) breaks the connection to the reactive object, because x and y become plain, non reactive copies. With $(), the compiler transformed each destructured variable into its own ref, still coupled to the original.

This pattern was especially attractive for composables that often receive a reactive options object and want to destructure individual fields out of it, without manually calling toRef() for each field. Reactivity Transform promised a significant reduction in boilerplate here, since toRef() calls for every single destructured field became unnecessary.


// Reactivity Transform: destructuring without losing reactivity
import { reactive } from 'vue'

function useMousePosition() {
  const state = reactive({ x: 0, y: 0 })
  window.addEventListener('mousemove', (e) => {
    state.x = e.clientX
    state.y = e.clientY
  })
  return $(state) // marks destructured fields as still reactive
}

// In a component:
let { x, y } = useMousePosition()
// x and y stay reactive despite destructuring, thanks to the $() macro

4. A practical example: a composable before and after

To make the actual difference tangible, a direct before and after comparison of the same composable helps. Without Reactivity Transform, every access to a ref's value inside the logic carries .value, which quickly becomes cluttered in composables with many internal computations. With Reactivity Transform, that indirection disappears entirely from visible code, while the compiled result stays exactly the same.

The difference is purely cosmetic and affects only readability during development. For runtime behavior, performance, and dependency tracking, it makes no difference whether .value is visible in the source code or inserted automatically by the compiler. That purely syntactic benefit later became the decisive point of criticism that led to Reactivity Transform being dropped.


// WITHOUT Reactivity Transform: standard Composition API
import { ref, computed } from 'vue'

function useCounter(initial = 0) {
  const count = ref(initial)
  const isEven = computed(() => count.value % 2 === 0)

  function increment() {
    count.value++
  }
  function reset() {
    count.value = initial
  }

  return { count, isEven, increment, reset }
}

// WITH Reactivity Transform (removed feature, shown for comparison only)
// function useCounter(initial = 0) {
//   let count = $ref(initial)
//   const isEven = $computed(() => count % 2 === 0)
//   function increment() { count++ }
//   function reset() { count = initial }
//   return { count: $$(count), isEven, increment, reset }
// }

5. Why the experiment was dropped in 2023

The Vue core team officially withdrew Reactivity Transform from the standard recommendations in mid 2023 and removed it from core as of Vue 3.4, even though it remains usable via plugins. The main reason was a fundamental tension between the promised simplification and the actual cognitive load: code with $ref() looked simpler locally, but became harder to understand once a variable was passed between multiple functions or files, because it was no longer obvious at a glance whether a value was reactive or not.

Another reason was IDE support. Because $ref() was not a real function call but a pure compiler macro, tools like Volar had to implement special case handling to support type inference, autocomplete, and refactoring correctly. That special handling was fragile and led to inconsistencies between different editor versions. The Vue team concluded that the explicit .value syntax, despite its verbosity, is the more reliable foundation for tooling and team readability.

6. Build tooling and compiler requirements

For Reactivity Transform to work at all, the feature had to be explicitly enabled in the build setup, both in Vite and in the Vue loader for webpack projects. Without that explicit activation, the compiler ignored the macros completely, and $ref() would simply have been an undefined function at runtime, causing an immediate runtime error.

This dependency on a specific build configuration made Reactivity Transform unsuitable from the start for libraries consumed by different users with different build setups. An npm library that internally used $ref() syntax would either have had to ship precompiled code, or require consumers to enable the same compiler option, a compatibility risk that many library authors avoided from the outset.


// vite.config.js — how Reactivity Transform had to be enabled (historical)
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [
    vue({
      reactivityTransform: true // required explicit opt-in, removed in Vue 3.4
    })
  ]
})

7. Migrating away from Reactivity Transform code

Anyone who still encounters an old project using Reactivity Transform syntax today needs to migrate it before any Vue 3.4 upgrade, since the macros stop working from that version onward without a separate community plugin. The migration is mechanical but not trivial: every $ref() becomes ref(), and every read access to the affected variable needs .value added, which is easy to miss without tool support, especially for destructured values from $().

The official Vue team provided a migration codemod for this transition that could automatically rewrite most cases. For complex code with nested destructuring or transformed variables being passed to external functions, however, manual follow up work was necessary, since the codemod could not always safely distinguish between a reactive and a normal variable.


// BEFORE migration: Reactivity Transform syntax
// let price = $ref(19.99)
// let total = $computed(() => price * quantity)

// AFTER migration: standard Composition API
import { ref, computed } from 'vue'

const price = ref(19.99)
const quantity = ref(1)
const total = computed(() => price.value * quantity.value)

function applyDiscount(percent) {
  price.value = price.value * (1 - percent / 100)
}

8. Lessons for today's Vue 3 practice

Even though Reactivity Transform is no longer actively developed, the experiment offers concrete lessons for today's day to day Vue 3 work. The most important one: syntactic sugar that makes reactivity invisible feels convenient locally, but shifts the cost to later refactorings and to teammates who have to read the code without the full context. Explicit .value is a deliberate trade off in favor of traceability, not a design flaw to work around.

A second lesson concerns experimental compiler features in general: anyone adopting them in production code should be aware of reversibility. Reactivity Transform was clearly labeled experimental by Vue itself, yet some teams adopted the syntax extensively, which made the later migration more work than necessary. The same caution applies to current experimental Vue features: test them first in small, isolated modules before letting them spread through the entire codebase.

Third, the history of Reactivity Transform shows how heavily Vue weighs IDE experience as a design criterion. A feature that degrades editor ergonomics gets withdrawn even if it looks more elegant in raw source code. This prioritization of reliable tooling over cosmetic compactness is a recurring pattern in Vue 3's evolution, and a good benchmark for your own architecture decisions in composables.

9. Reactivity Transform compared to the standard API

Even though Reactivity Transform is no longer recommended for new projects today, a direct comparison helps to understand the concrete trade offs that led to it being dropped.

Criterion Reactivity Transform Standard Composition API Outcome
Local readability No .value needed .value on every access Transform more compact, but deceptive
Discoverability of reactive values Not obvious at a glance .value makes reactivity explicit Standard API wins for team work
IDE support Required fragile special casing Normal TypeScript inference Standard API more reliable
Library compatibility Build setup dependent Works everywhere No compatibility risk with standard API
Current status Removed from core since 3.4 Officially recommended Clear choice for new projects

The table shows why the Vue team weighed the short term convenience of Reactivity Transform against the long term clarity of the explicit .value syntax and chose the latter. For new Vue 3 projects, the rule has been unambiguous ever since: standard ref() and reactive() without compiler macros, complemented by toRef() or toRefs() for destructuring cases.

Mironsoft

Vue 3 legacy code migration and modernization

Still have Reactivity Transform syntax in your code?

We cleanly migrate existing Vue 3 projects with outdated $ref syntax to the standard Composition API and prepare your codebase for future Vue versions.

Code audit

Identifying outdated compiler macros and compatibility risks

Migration

Cleanly moving $ref, $computed and $() to the standard API

Team training

Teaching current best practices for Vue 3 reactivity

10. Summary

Reactivity Transform was an ambitious compiler experiment promising reactive values without a visible .value, with $ref(), $computed(), and $() as its central macro building blocks. The idea looked convincing locally, but failed due to lack of discoverability of reactive values across function boundaries, fragile IDE support, and insufficient library compatibility. The Vue team officially withdrew Reactivity Transform in 2023 and removed it from core as of Vue 3.4.

For today's projects, the lesson stands: explicit .value access is not unnecessary baggage, but a deliberate trade off in favor of clarity, tooling reliability, and portability across build setups. Anyone encountering old code with Reactivity Transform syntax should consistently migrate it to standard ref() and computed() before any major upgrade.

Reactivity Transform in Vue 3 — The Essentials at a Glance

What it was

Compiler macros $ref(), $computed(), $() for refs without visible .value.

Current status

Withdrawn in 2023, removed from core as of Vue 3.4, only available via a community plugin.

Main reason for dropping it

Reactivity no longer discoverable across function boundaries, fragile IDE support.

Recommendation today

Use standard ref()/computed(), migrate old code with the codemod.

11. FAQ: Vue 3 Reactivity Transform

1What was Reactivity Transform?
A compiler feature with $ref()/$computed() for refs without visible .value, optionally enabled between Vue 3.0 and 3.4.
2Still part of Vue 3?
No, withdrawn in 2023, removed from core as of 3.4, only available via a community plugin.
3Why was it dropped?
Reactivity not discoverable across function boundaries, fragile IDE support, compatibility issues with libraries.
4$ref() vs. ref()?
$ref() was a compiler macro with automatic .value, ref() is a real runtime function without compiler magic.
5Migrating to standard Vue 3?
$ref() becomes ref(), add explicit .value, use the official codemod, review complex cases manually.
6Still usable today?
Technically yes via a community plugin, but not recommended for new projects.
7What did $() do for destructuring?
Destructured reactive objects without losing reactivity, similar to toRefs() but without a manual call per field.
8Was it ever officially recommended?
No, always marked as an experimental opt in, never a standard recommendation.
9Usable without build tooling?
No, strictly tied to the SFC compiler, had to be explicitly enabled in Vite or the Vue loader.
10Today's alternative?
Standard ref()/reactive()/computed() with explicit .value, plus toRef()/toRefs() for destructuring.