Vue 3.6 is in beta: 3.6.0-beta.1 went up on npm on 23 December, and beta.2 followed on 4 January. The beta.1 release notes make two claims. Vapor Mode, an opt-in way of compiling components so that they update the DOM directly instead of comparing virtual DOM trees, now has the full feature set planned for it. And the reactivity system under every ref and computed has been rebuilt.
Stable Vue is 3.5.26, published on 18 December, and production apps stay on it. The reactivity rewrite will reach every app that upgrades to 3.6, with no code change on your side, so it is the part to test now. Vapor, aimed at smaller bundles and faster updates, belongs in a scratch project for the moment.
What changed between alpha and beta
The alpha notes in July listed five features that did not work in Vapor yet: SSR hydration, async components, <Transition>, <KeepAlive> and <Suspense>. The beta notes do not go through that list item by item. Instead they make one statement: Vapor Mode "now has feature parity with all stable features in Virtual DOM mode".
Suspense is the one named exception. It is not supported in Vapor-only mode, although you can render Vapor components inside a virtual DOM <Suspense>. Suspense is still marked experimental in the Vue docs, so it falls outside "stable features" in any case.
The bug-fix list in the same notes gives indirect confirmation for two of the five. Two fixes name VaporTransition and VaporTransitionGroup, and one mentions keep-alive preserving a fragment's scope, so those components exist and are being polished. For SSR hydration and async components you have only the parity statement.
That matters most if you use Nuxt. The alpha notes said the missing hydration meant Vapor did not work with Nuxt yet, and the beta notes say nothing about Nuxt at all. Treat Vapor inside Nuxt as untested until Nuxt says otherwise.
Beyond the parity statement, both releases are small:
- beta.1: one feature (render block support in
createDynamicComponent), one performance improvement (caching of dynamic props and slots sources in the Vapor runtime) and eleven bug fixes, mostly in the Vapor compiler and runtime. - beta.2, twelve days later: seven fixes, covering a duplicated
unrefimport in Vapor single-file components, two caching cases in the Vapor compiler, slot existence checks, CSS variables on an empty Vapor fragment, a template ref on a virtual DOM child, and one inruntime-core.
Neither release lists a breaking change.
Turn on Vapor Mode for a component
A Vapor component compiles to code that creates its DOM once and wires each dynamic binding to the reactive value behind it, with no virtual DOM tree to diff. The notes describe Vapor Mode as "100% opt-in", and the opt-in is one attribute on <script setup>:
<script setup vapor lang="ts">
import { ref, computed } from 'vue'
const rows = ref<{ sku: string; price: number }[]>([])
const total = computed(() => rows.value.reduce((sum, row) => sum + row.price, 0))
</script>
<template>
<table>
<tr v-for="row in rows" :key="row.sku">
<td>{{ row.sku }}</td>
<td>{{ row.price }}</td>
</tr>
</table>
<p>Total: {{ total }}</p>
</template>
Only components that use <script setup> can be Vapor components. Inside the script you write the same Composition API code as anywhere else (ref, computed, watch, composables); what changes is the code the template compiles to.
A Vapor component needs one of two hosts. The first is an app created with createVaporApp instead of createApp. The notes say an app created this way avoids pulling in the virtual DOM runtime, which is where the smaller baseline bundle comes from. The second is an ordinary createApp app with the interop plugin installed:
import { createApp, vaporInteropPlugin } from 'vue'
import App from './App.vue'
createApp(App)
.use(vaporInteropPlugin)
.mount('#app')
Without the plugin, mounting fails, and in development Vue warns "Vapor component found in vdom tree but vapor-in-vdom interop was not installed".
The second route is the one for an existing app: you can build one performance-sensitive page in Vapor, the notes' own example, and leave the rest of the app alone. The notes add that a Vapor app can install the same plugin to host virtual DOM components, but that pulls the virtual DOM runtime back in and will "offset the benefits of a smaller bundle".
Mixing Vapor and virtual DOM components
With the plugin installed, Vapor and virtual DOM components can nest in each other, and the notes say this covers props, events and slots. They also say it "does not yet account for all possible edge cases", and they name the likely sore spot: a virtual DOM component library used inside Vapor components.
There is one documented known issue. A Vapor slot cannot be rendered with slots.default() inside a virtual DOM component; renderSlot has to be used instead. If you maintain a component that calls slots.default() in a render function, that is the first thing to check.
The notes recommend keeping distinct regions of your app in one mode or the other, and avoiding mixed nesting as much as you can. They mention that tooling to enforce those boundaries may come later. For now, a Vapor region is a folder you decide on and a rule you keep by hand.
What Vapor leaves out by design
The notes are explicit that Vapor supports a subset of Vue, and that for the subset the aim is identical behaviour per the API specification. Four features are not supported in Vapor Mode, by design:
- The Options API.
app.config.globalProperties.getCurrentInstance(), which returnsnullin a Vapor component.@vue:xxxper-element lifecycle events.
The first two rule out some older plugins. The third matters more than it looks, because a number of libraries read getCurrentInstance() inside a composable to find the current component, and in Vapor they get nothing back. If a composable you depend on does that, try it in a Vapor component before you convert anything that uses it: it may throw, or quietly fall back to different behaviour.
The alpha's list had a fifth entry, implicit instance properties such as $slots and $props in template expressions; the beta's list no longer includes it.
Custom directives are supported, with a different signature. A Vapor directive is a function that receives the element (or a Vapor component instance), a getter for the bound value, the argument and the modifiers, and may return a cleanup function. Because the value is a getter, you set up the reactive work yourself, and the notes show watchEffect for that, released automatically when the component unmounts:
import { watchEffect } from 'vue'
export const vPlainText = (el: Element, source: () => string) => {
watchEffect(() => {
el.textContent = source()
})
return () => console.log('cleanup')
}
Import it into a Vapor component and write v-plain-text="label" on an element. Do not name it vText: v-text is a built-in directive, so the compiler would use Vue's own and never call yours.
One more line from the notes sets expectations for bug reports. Vapor tries to match virtual DOM behaviour, but there may be minor inconsistencies in edge cases, and a minor inconsistency is not treated as a breaking change unless the behaviour was documented before.
The reactivity rewrite reaches every app
The second headline is not about Vapor. @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. What changes is the algorithm underneath that records which effect depends on which value and decides what to re-run when something changes.
Timing-sensitive code is where a difference would most likely show: a watcher that assumed a particular order relative to another watcher, a computed that is read inside its own dependency's setter, a composable that leaned on an effect running one more time than it strictly needed to. None of those is documented behaviour, and finding them now is cheaper than finding them when 3.6 is stable.
Try the beta in a scratch project
Make a throwaway project and pin the exact pre-release, so a later install cannot drift to a newer beta without you noticing:
npm create vue@latest vapor-scratch
cd vapor-scratch
npm install
npm install vue@3.6.0-beta.2 --save-exact --legacy-peer-deps
The last line needs both flags:
--save-exactwrites3.6.0-beta.2intopackage.json. Without it npm saves^3.6.0-beta.2, a range that a later install can resolve to a newer beta.--legacy-peer-depsgets past a peer check.@vitejs/plugin-vuedeclares that it works withvue@^3.2.25, npm does not count a pre-release as inside that range, and without the flag the install stops with anERESOLVEerror. Add the flag to any later install in this project too.
Then try three things:
- Add
vaporto one component's<script setup>and installvaporInteropPlugininsrc/main.tsas shown above. - Copy in one or two of your real composables and custom directives, especially anything that calls
getCurrentInstance(), and see what breaks. - In a branch of your real app, install the beta the same way and run your existing unit tests with no Vapor at all. That tests the reactivity rewrite against your own code.
Report anything odd on the Vue core issue tracker with the smallest reproduction you can make.