Skip to content
Release notesAdvanced Vue

Vue 3.6 alpha: Vapor Mode arrives, with caveats

Vue 3.6.0-alpha.1 was published on 12 July and alpha.2 on 18 July. Stable Vue is 3.5.18. The alpha is for trying Vapor Mode in a scratch project, not for apps people use.

vuejs.school team31 Jul 20258 min readVue 3.5.18 stable · 3.6.0-alpha.2

Vue 3.6.0-alpha.1 went up on npm on 12 July, and alpha.2 followed on 18 July. They are the first public builds of Vapor Mode, a way of compiling components that leaves out the virtual DOM, and they rebuild the reactivity system underneath every ref and computed.

Your apps should stay on the stable line, which moved to 3.5.18 on 23 July. The alpha is for trying Vapor in a throwaway project, and the alpha.1 release notes list what works in it and what is still missing.

What Vapor Mode is

Today a Vue component's template compiles to a render function that produces a tree of plain JavaScript objects, the virtual DOM. When state changes, Vue re-runs the render function, compares the new tree with the old one and patches the real DOM where they differ. Vue 3 already uses compiler hints to skip most of that comparison, but the runtime that does it still has to be shipped and run.

Vapor Mode is, in the words of the release notes, "a new compilation mode for Vue Single-File Components (SFC) with the goal of reducing baseline bundle size and improved performance". A Vapor component compiles to code that creates its DOM nodes once and then wires each dynamic binding straight to the reactive value that feeds it. There is no tree to diff, and an app built entirely this way does not need to download the virtual DOM runtime at all.

The notes say Vapor Mode "has demonstrated the same level of performance with Solid and Svelte 5" in the third-party js-framework-benchmark. They give no numbers beyond that comparison.

The notes are also explicit that Vapor "supports a subset of existing Vue APIs with mostly identical behavior". For the supported subset, the aim is the same behaviour the API specification describes, though the notes allow for small differences in edge cases. The rest is still pending or left out on purpose.

Opt in with the vapor attribute

Vapor is entirely opt-in and only works in single-file components that use <script setup>. You add one attribute:

components/HeavyTable.vue
<script setup vapor lang="ts">
import { ref } from 'vue'

const rows = ref<string[]>([])
</script>

<template>
  <table>
    <tr v-for="row in rows" :key="row">
      <td>{{ row }}</td>
    </tr>
  </table>
</template>

A Vapor component can then run in one of two places. The first is an app created with createVaporApp instead of createApp. The notes say apps created this way avoid pulling in the virtual DOM runtime, which is where the smaller baseline bundle comes from.

The second is an ordinary app created with createApp, which can host Vapor components once the interop plugin is installed:

main.ts
import { createApp, vaporInteropPlugin } from 'vue'
import App from './App.vue'

createApp(App)
  .use(vaporInteropPlugin)
  .mount('#app')

Without the plugin, the component does not render, and in development Vue warns "Vapor component found in vdom tree but vapor-in-vdom interop was not installed". The reverse also works: a Vapor app can install vaporInteropPlugin to render virtual DOM components inside it, but doing so pulls the virtual DOM runtime back in and, as the notes put it, offsets the benefit of a smaller bundle.

With the plugin installed, the two kinds of component can nest in each other, and the notes say this covers standard props, events and slots. They also say it "does not yet account for all possible edge cases", single out virtual DOM component libraries used inside Vapor as a likely source of rough edges, and recommend keeping distinct regions of an app in one mode or the other rather than mixing freely.

Missing features and unsupported APIs

The notes keep two separate lists, and the difference matters. The first is features that are pending: they do not work in alpha.1 but are planned, and all but <Suspense> have pull requests that the notes say will be merged during the alpha phase.

  • SSR hydration, which the notes say means Vapor "does not work with Nuxt yet"
  • Async components
  • <Transition>
  • <KeepAlive>
  • <Suspense>

The second list is features that Vapor leaves out by design:

  • The Options API
  • app.config.globalProperties
  • getCurrentInstance(), which returns null inside a Vapor component
  • The implicit $slots and $props properties in template expressions
  • @vue:xxx per-element lifecycle events

If a component you own uses any of these, it is not a candidate for Vapor, and the notes say plainly that they "do not recommend migrating existing components to Vapor Mode yet" in any case.

Custom directives receive a getter

Custom directives still work in Vapor, with a different signature. A Vapor directive is a plain function. It receives the element (or component instance), a getter for the bound value, the argument and the modifiers, and it may return a cleanup function.

Because the value arrives as a getter, the directive sets up its own reactivity, typically with watchEffect, and Vue releases that effect when the component unmounts. This is the notes' example with a name, types and an export added:

directives/setText.ts
import { watchEffect } from 'vue'

export const vSetText = (el: Element, source: () => string) => {
  watchEffect(() => {
    el.textContent = source()
  })
  return () => console.log('cleanup')
}

The effect writes the current value into the element and runs again whenever it changes. The returned function runs when the component unmounts; this one only logs.

To use it, import vSetText in a Vapor component and write <p v-set-text="message"></p>: <script setup> turns a camelCase import that starts with v into a directive. Do not call it vText. v-text is a built-in directive, and the compiler uses the built-in one instead of yours.

The reactivity core is rebuilt on alien-signals

The second headline item is listed under performance improvements: the reactivity core was refactored by porting alien-signals, a standalone signals library. What changes is the algorithm that records which effect depends on which value and passes a change on to the right subscribers. The notes list no breaking change for it, so ref, reactive, computed, watch and watchEffect are written exactly as in 3.5.

Unlike Vapor, this change is not opt-in. Installing the alpha swaps the reactivity core under every component, including those that stay in virtual DOM mode, so it is worth testing even in a project that never uses the vapor attribute. Alpha.2 already fixes two reports against alpha.1: an effect that could not track a ref inside an effect scope, and a performance regression in a real-world app.

Bug fixes in alpha.1 and alpha.2

Alpha.1 also carries fixes that are not specific to Vapor:

  • In reactivity, effectScope keeps the correct active scope across repeated on() and off() calls, toRefs is allowed on plain objects, and effects are now queued in an array.
  • The scheduler handles errors during job flushing better, and nextTick recovers from an error in a post-flush callback.
  • A v-bind() in <style> that resolves to null or undefined no longer inherits a value from a parent by accident.

Alpha.2, six days later, contains only fixes: three in the Vapor compiler, the two reactivity fixes above, interop fixes for component emits and v-model between the two modes, and two smaller runtime fixes.

What this means for a Vue 3.5 app

Nothing changes. Vue 3.5.18 is current, and Vapor is an attribute you have to add on purpose. Nuxt users cannot use Vapor at all until SSR hydration lands.

The notes suggest two uses during the alpha: a performance-sensitive sub-page in an existing app, or a small new app built entirely in Vapor. A learning project qualifies as the second. For an app people already use, our advice is stricter: wait with the first until there is a stable 3.6 and the pending list is empty. A page that renders a very large table or list, and that you have measured to be slow, is the kind to try then.

The lesson linked below shows how to time an update. That is worth doing on any version, because it tells you whether a page is slow before you reach for a new compile mode.

How to try the alpha safely

Make a throwaway project with create-vue:

npm create vue@latest vapor-scratch
cd vapor-scratch

Before you install anything, open package.json. Set the vue entry under dependencies to the exact alpha version, and add an overrides entry at the top level. Leave the other entries as create-vue wrote them:

package.json
{
  "dependencies": {
    "vue": "3.6.0-alpha.2"
  },
  "overrides": {
    "vue": "3.6.0-alpha.2"
  }
}

Then run npm install. Both entries are needed:

  • The exact version. With no ^, a later install cannot drift to a newer alpha. npm install vue@3.6.0-alpha.2 would save ^3.6.0-alpha.2, which also matches later alphas and betas.
  • The override. @vitejs/plugin-vue and other packages declare Vue as a peer dependency with a range such as ^3.2.25, and a range like that does not match pre-release versions. Without the override, npm stops with an ERESOLVE error; with it, every package that asks for Vue gets the alpha. Write it exactly as in dependencies, because npm refuses an override of your own dependency that differs, with EOVERRIDE (see overrides in the npm docs). The docs' "$vue" shorthand fails here on npm 10, the version that comes with Node 20 and 22.

Once the install succeeds, add vapor to one component's <script setup>, install vaporInteropPlugin in main.ts as shown above, and compare behaviour with the same component in virtual DOM mode. Check the areas the notes call out: slots passed between the two modes, custom directives, and anything that reads getCurrentInstance() through a composable. Report differences on the Vue core issue tracker with the smallest reproduction you can make.

Check it stuck3 questions from the Performance and Vapor Mode quiz.
Practice now
Was this helpful?

Keep reading