Vue 3.6 left beta on 18 July, when 3.6.0-rc.1 went up on npm after seventeen betas. The rc.1 release notes open with the reason: Vue 3.6 "is now entering the RC phase as we have completed the intended feature set for Vapor Mode", the opt-in way of compiling components without a virtual DOM. A second release candidate followed on 22 July with a breaking change to how Vapor handles events.
Stable Vue is 3.5.40, published on 16 July, and it is still the version for production. The Vue team has not announced a release date for 3.6.0. The RC is worth testing if you want to know whether Vapor would speed up a slow page, or whether the rewrite of Vue's reactivity system, which every 3.6 app gets, changes anything for your code.
What Vapor Mode is and how stable the RC is
Vapor Mode is a compilation mode for single-file components. A Vapor component compiles to code that creates its DOM directly and updates each binding when its reactive value changes, with no virtual DOM tree to build and compare. The notes give two goals, a smaller baseline bundle and better performance, and say Vapor has shown the same level of performance as Solid and Svelte 5 in third-party benchmarks.
The wording on stability changed between phases. The beta notes called Vapor "feature-complete in Vue 3.6 beta, but is still considered unstable". The RC notes say "Vapor Mode is feature-complete in Vue 3.6 RC" and drop the word "unstable". The two recommended uses did not change:
- a performance-sensitive page inside an existing app;
- a small new app built entirely in Vapor.
Turn on Vapor for a component or a whole app
Vapor is still 100 percent opt-in, one component at a time. The RC notes list three ways to mark a component. The first is a vapor attribute on <script setup>:
<script setup vapor lang="ts">
import { ref } from 'vue'
const price = ref(101.5)
</script>
<template>
<p>Price: {{ price }}</p>
<button type="button" @click="price += 0.5">Up</button>
</template>
The second, <script vapor>, is a shorthand for <script setup vapor>. The third, a vapor attribute on <template>, turns on Vapor compilation for the whole file, which is how a component with no script opts in. The Options API is not supported in any form, so a component with export default { data() {} } cannot be a Vapor component.
A Vapor component needs a host. An app made only of Vapor components uses createVaporApp, which the notes say avoids pulling in the virtual DOM runtime and is where the drastically smaller baseline bundle comes from:
import { createVaporApp } from 'vue'
import App from './App.vue'
createVaporApp(App).mount('#app')
An existing app keeps createApp and installs the interop plugin, which lets Vapor and virtual DOM components sit inside each other:
import { createApp, vaporInteropPlugin } from 'vue'
import App from './App.vue'
createApp(App).use(vaporInteropPlugin).mount('#app')
Components written as render functions or in JSX stay virtual DOM components, so a Vapor app that uses them needs the plugin too, which brings the virtual DOM runtime back.
The interop covers props, events and slots passed between the two kinds of component. In the notes' words, it "does not yet account for all possible edge cases", and the example they give is a virtual DOM component library used inside Vapor. Their advice is the same as in the beta: give each rendering mode its own distinct region of the app, and avoid nesting the two back and forth.
What Vapor Mode does not support
The RC notes say Vapor supports a subset of Vue by design, and give the rule behind it: features that depend on virtual DOM nodes or on the component public instance proxy are not available. The public instance proxy is the object behind this in the Options API, and what a template ref on a virtual DOM component returns. The notes list these as unsupported or not applicable in Vapor:
- The Options API.
app.config.globalProperties.getCurrentInstance(), which returnsnullin a Vapor component.@vue:xxxper-element lifecycle events.v-memo.- Component template refs that expose
$el,$props,$attrs,$slotsor$refs.
Two of these were not in the beta.1 notes. v-memo exists to skip re-creating virtual DOM nodes for a subtree, and a Vapor component has no virtual DOM nodes to skip. If a list relies on v-memo for speed, that component either stays a virtual DOM component or drops the directive when it moves to Vapor.
The template ref change means that code such as childRef.value.$el, written to reach a child's root element, stops working. Expose what the parent needs with defineExpose instead.
rc.2 makes event delegation opt-in
Four days after rc.1, rc.2 reversed a default. Vapor used to delegate supported events to document: one listener at the top of the page found the right handler for each click. That differed from normal Vue, where a listener sits on the element itself, and it had a visible cost. An ancestor that called stopPropagation() could stop a child's handler from ever running.
The rc.2 changelog lists the change under breaking changes: listeners are now attached directly to elements by default, matching virtual DOM mode and the browser. Delegation becomes an opt-in optimisation, through a new Vapor-only .delegate modifier on supported static events:
<script setup vapor lang="ts">
const emit = defineEmits<{ add: [sku: string] }>()
const skus = ['A-100', 'A-200', 'A-300']
function onAdd(sku: string) {
emit('add', sku)
}
</script>
<template>
<button v-for="sku in skus" :key="sku" type="button" @click.delegate="onAdd(sku)">
Add {{ sku }}
</button>
</template>
With .delegate, Vue adds one click listener on document instead of one per button, and the old trade-off comes back for these buttons: an ancestor that calls stopPropagation() stops their handlers.
The compilerOptions.eventDelegation setting was removed in the same release. If your build config sets it, delete it, and put .delegate on the individual listeners where one shared listener is worth having, such as long lists of buttons.
Slots and custom directives work differently
In Vapor, calling slots.default() is not a harmless look at what a slot contains. It runs the slot: it can create DOM nodes, register reactive effects, and claim server-rendered markup during hydration. Code that calls the slot only to decide whether to show a fallback is exactly what the notes warn against:
<script setup vapor lang="ts">
import { useSlots } from 'vue'
const slots = useSlots()
const showFallback = !slots.default?.()
</script>
<template>
<div v-if="showFallback">Nothing here yet</div>
</template>
The notes' advice is to leave slot rendering to the template. Elements inside <slot> are fallback content, rendered when the parent passes nothing for the slot. With no script left, the vapor attribute moves to <template>, so the component stays a Vapor component:
<template vapor>
<slot>
<div>Nothing here yet</div>
</slot>
</template>
Custom directives have a different interface in Vapor too, the same one the notes have described since the alpha: a function that receives the element and a getter for the value, and may return a cleanup function.
The reactivity rewrite reaches every app
The RC notes' second headline is unchanged since the beta and reaches every 3.6 app, Vapor or not. @vue/reactivity has been refactored on top of alien-signals, a standalone signals library, and the notes say this significantly improves the reactivity system's performance and memory usage. The notes list no change to the public API: you call ref, reactive, computed, watch and watchEffect as before.
That makes it the part of the RC most worth testing, because your existing test suite covers it without a change to a single component. Code that leaned on undocumented timing, such as the order two watchers fire in, is where a difference would most likely show.
Try the RC in a branch
A release candidate means the feature set is decided and the work left is fixing. It does not mean nothing will change, and rc.2 proved that within four days. So treat it as a test target, not a dependency. From the root of an existing project:
git switch -c try-vue-3-6
npm pkg set overrides.vue='$vue'
npm install vue@3.6.0-rc.2 --save-exact
npm run type-check && npm run test:unit
What the two middle lines do:
npm pkg setadds"overrides": { "vue": "$vue" }topackage.json.@vitejs/plugin-vue, Pinia and Vue Router each declare the Vue versions they work with as a range such as^3.2.25, and npm does not count a pre-release as inside such a range. Without the override, npm stops with anERESOLVEpeer conflict, on this install or the next one.$vuestands for the version in your own dependencies, so every package that asks for Vue accepts the RC. The single quotes keep the shell from reading$vueas a variable.--save-exactwrites3.6.0-rc.2intopackage.json, so the next RC arrives only when you choose it. Without it, npm saves^3.6.0-rc.2, a range a later install can resolve to a newer RC.
npm's error message suggests --legacy-peer-deps instead. Avoid it: npm then ignores peer dependencies completely, and Pinia 4 imports one of its peers, @vue/devtools-api, which can go missing.
Then test in two rounds:
- Without Vapor. The last command runs the type check and the unit tests, under the script names create-vue uses (change them to yours). With no Vapor components yet, this tests the reactivity rewrite on its own.
- With one Vapor page. Pick one page that measurement says is slow and that uses only
<script setup>. Convert its components, installvaporInteropPlugininsrc/main.ts, then run the checks and measure the page again.