In the last couple of years, the Vue framework has etched its place into the heart of many a Web developer. The team has been working on some major improvements the last couple of years that culminates in a beta of Vue 3 that has recently started shipping. In this article, I'll walk you through the pertinent changes that are coming to your beloved Vue.

Although many of the changes are in the underlying plumbing, there are some major changes that I'll detail in this article. The overarching changes include:

  • Conversion to TypeScript to improve type inference for TypeScript users
  • Switch to using the Virtual DOM for overall performance improvements
  • Improve tree-shaking for Vue users using WebPack (or similar solutions)

Overall, you should notice a much-improved experience without sacrificing compatibility with Vue 2. Although there are breaking changes, they've been kept to a minimum and allow new features to be opted into instead of forced upon existing code. Let's get to the changes!

The Current State of Vue 3

As of the writing this article, Vue 3 is in an early beta cycle. The cadence of betas is pretty quick. You can build from the source to get the absolutely latest version of Vue, but in any case, it's likely not time to start building or converting your projects. Some parts of Vue 3 (especially the big changes) are available as plug-ins to Vue 2, in case you want to start experimenting with them (I'll mention them in the article below as I talk about specific features). The team's goals are to make it highly compatible with Vue 2 so that you shouldn't need to change code to move to the new version, but instead opt into new features.

Although the core library of Vue is now in a beta of version 3, that's not true for the entire ecosystem. Most of these libraries are in alpha or preview states. From public commitments from the team, these libraries should be at version 3 by the time the core library is released. These include:

  • vue-router
  • vuex
  • eslint-plugin-vue
  • vue-test-utils
  • vue-devtools

Additionally, the Vue CLI will be updated to use the Vue 3 libraries once it ships. There is currently a CLI plug-in for upgrading your projects to Vue 3 to see how they work. This add-in is only for experimental use, as of the writing of the article. To use it, just open an existing Vue CLI project and type:

# in an existing Vue CLI project
vue add vue-next

This will upgrade the project to the latest beta versions.

To look at the source or get the latest versions for all things related to Vue 3, please visit GitHub at: https://github.com/vuejs/vue-next.

New Features

In the years since Vue 2, the landscape of client-side Web development has changed quite a bit. Coming from a small upstart to a fully-fledged SPA library with a lot of community support, Vue has really grown up. Now with version 3 on the horizon, the team wanted to support a number of features to augment the library, simplify coding on Vue, and adopt modern techniques of Web development.

Below, I've dug into the major new features, but this list isn't exhaustive. It should cover the big items that will impact how you develop Vue applications going forward.

Global Mounting

One of the first changes you'll see is that mounting your root Vue object should be clearer than it was in version 2. The Vue object now supports a createApp function that allows you to mount it directly onto an element.

Vue.createApp(App).mount("#theApp");

The fluent syntax creates an application by passing in a component and allows you to mount it to a specific element (via a CSS Selector). This doesn't change how the applications are created, but it does make it clearer what is happening. In Version 2, there were just too many ways to kick off your project; I really like this change.

In this same way, createApp returns an object in which to do configuration at the app level (instead of version 2's global-only option). For example:

createApp(App)
  .mixin(...)
  .use(...)
  .component(...)
  .mount('#app')

Back in version 2, you'd add mix-ins, use plug-ins, and add components (etc.) to the global Vue object. This allows you to scope them to your application and should cause fewer conflicts between libraries.

Composition API

This feature is the one that caused the most controversy with the Vue community. The Composition API is simply a new way of developing components that's more obvious but is a stark change to the Options API (the Vue 2 default way to build a component).

The motivation behind the Composition API is to improve the quality of the code. It does this by allowing you to decouple features and improves the sharing of that logic. In Vue 2, developers were forced to rely on extending the this object that was passed around to extend and share logic. This approach caused problems in that it was more difficult to see features as they were added. This was especially exacerbated when using TypeScript (e.g., lack of typing). The upgrade also allows you to more obviously share features via standard JavaScript/TypeScript patterns instead of inventing new ones.

As another benefit of the Composition API is that types are easier to infer, which means better support for TypeScript. That was a big motivation for version 3. Let's see how it works in practice.

The Composition API works by changing from an options object to a composing of a Vue object. For example, the default Vue 2 way to create a component looks like this:

export default {
    data: () => {
        return { name: "Shawn" }  
    },  
    methods: {
        addCharacter() {
            this.name = this.name + "-";
        }  
    },  
    computed: {
        allCaps() {
            return this.name.value.toUpperCase();
        }  
    },  
    onMounted() {
        console.log("Mounted");  
    }
}

In contrast, the Composition API, simplifies that all into a method called setup that has you compose the same thing:

export default {
    setup() {
        const name = ref("Shawn");
        const allCaps = computed(() => name.value.toUpperCase());    
        const address = reactive({
            address: "123 Main Street",
            city: "Atlanta",
            state: "GA",
            postalCode: "12345"
        });
        const favoriteColors = reactive(["blue", "red"]);
        
        function addCharacter() {
            this.name = this.name + "-";    
        }
        
        onMounted(() => {
            console.log("Mounted");    
        });
        
        return { name, address, favoriteColors, allCaps, addCharacter }  
    },
}

The difference here is that you're generating an object that you return with the interface for the component. Because it's all uses the same scope, you can easily use closures to more easily share data instead of depending on the magic of the this property (the computed value is just getting the name via a closure). This should also allow you to decompose a component into several files, if necessary, and to manage large components, which was difficult with the Options API.

Reactivity

The way that reactivity worked in version 2 of Vue wasn't exposed to the developer. It was trying to hide the details, which caused more confusion than it should have. To remedy this, Vue 3 supports several ways of wrapping objects to ensure that they are reactive.

For scalar types, you can wrap them with a ref function. This makes them mutable and you read or write the property directly (if necessary) with .value:

const name = ref("Shawn");
const allCaps = computed(() => {name.value.toUpperCase()});

For templates, objects that are wrapped as ref don't need to use .value, as those are unwrapped by default:

<template>  
    <div>
        <label>Name: </label>
        <input v-model="name">
        <div>{{ name }}</div>
        <div>{{ allCaps }}</div>
        <button @click="addCharacter">Add</button>
    </div>
</template>
<script>

For objects, you would wrap it in a reactive wrapper. This wrapper makes a deep proxy for the object so that the entire object is reactive:

const address = reactive({
  address: "123 Main Street",
  city: "Atlanta",
  state: "GA",
  postalCode: "12345"
});

The object returned in the set-up function (from the Composition API) is automatically wrapped in a reactive object, so you don't need to do it unless you have your own reactive objects.

For arrays, reactive also works. You just need to wrap it in the same way inside of setup():

const favoriteColors = reactive(["blue", "red"]);

Wrapping arrays with reactive makes it more obvious when you modify the array. It may seem like this is a lot like RxJs because it is. My understanding is that you can opt to use RxJs, too. The reactivity is the requirement for how Vue 3 works. Although my example shows reactivity in a lot of places, in most cases, libraries can hide some of these details (e.g., Vuex).

Composition Components

Along with the changes coming to the composition APIs, many of the utility components that were hung on the Vue object (e.g., $watch, computed) are now separate components to make that code more readable:

For example, if you import watch, you can then watch one or more refs or reactive properties:

import { ref, reactive, computed, watch } from "vue";

// Watch a scalar 
refwatch(name, (name, prevName) => {  
    console.log(`Name changed to: ${name}`);
});

// watch a property of a reactive 
objectwatch(() => address.postalCode, (curr, prev) => {
    let msg = `Postal Code: ${prev} to ${curr}`;
    return console.log(msg);  
});

Similarly, computed is now an importable component:

import { ref, reactive, computed, watch } from "vue";
export default {
  setup() {
    const name = ref("Shawn");
    const allCaps = computed(() => {
      return name.value.toUpperCase();
    });
    return {
      name,
      allCaps
    }
  },
}

This should make the composition of your components a lot clearer and the code that much cleaner (instead of relying on a heavily overloaded this property.

Filters

One of the big surprises in Vue 3 is that the concept of filters is going away. There are some justifications for this but the main one is that Vue 3 wants the inside of a bindings to be simply executable JavaScript. Let me show you what I mean. Here is a simple filter usage in Vue 2:

<div>{{ name | uppercase }}</div>

This syntax was an exception to most of the ways that binding worked. This is because the syntax using the pipe (|) character was confusing and not valid JavaScript. The team decided that we should do the same thing without resorting to some special syntax. Because the binding can be any valid JavaScript, you can accomplish the same thing with computed properties or just simple functions. In this example, you can see that the uppercase filter is changed to a function that returns uppercase:

export default {  
    setup() { 
        const name = ref("Shawn");
        
        function uppercase(val) {
            return val.toUpperCase();
        }
        
        return { name, uppercase };  
    }
};

In Vue 2, I'm used to registering global filters. Instead, I think an approach is to just create your filters as an importable object and then just use the spread operator to add them to the binding object. For example, here's a small version of a library that could hold one or more filters:

// filters.js
export default {
  uppercase(val) {
    return val.toUpperCase();
  }
};

Then I could just import it and apply it:

import filters from "./filters";
export default {
  setup() {
    const name = ref("Shawn");
    return {
      name,
      ...filters
    };
  }
};

Then you can just use it as a function in the binding scope:

<div>{{ uppercase(name) }}</div>

Because the binding scope is just JavaScript, if you need to specify additional parameters, you can. Go nuts with it. It's more obvious and reduces complexity. After being scared at first, I like it.

Suspense

The Vue 3 team is making an effort to learn from other ecosystems. An example of this is the new support for Suspense (like React's common pattern). The problem that Suspense is trying to solve is to allow you to specify some template to be shown in case your components need to do any asynchronous work (e.g., calling the server) before being ready. In this case, Suspense will do this for you.

Let's start with an example. Say I have a simple component that has to do something; it makes its set-up use async and await so it can handle the call. In this example, I'm doing a simple timeout:

<template>  
    <div>
        <div>See this works now...</div>  
    </div>
</template>
<script>
    export default { 
        name: "ShowSomething",  
        async setup() {
            await new Promise(res => {
                return setTimeout(res, 2000);    
            });
            return {};
        }
    }
</script>

Then, on a parent component that uses this component, there normally wouldn't be any mechanism to know it's asynchronous. So you bring in the component to the parent component:

import ShowSomething from "./components/showSomething";

export default {
    setup() { ... },
    components: { ShowSomething }
};

Instead of writing a simple template that uses the component, you use a suspense block to specify two separate templates (marked default and fallback):

<template>
  <Suspense>
    <template #default>
      <div>
        <ShowSomething />
      </div>
    </template>
    <template #fallback>
      <div>
        <div>Please wait...</div>
      </div>
    </template>
  </Suspense>
</template>

In this case, you can see that what will happen is that until the ShowSomething component is done with its setup, the fallback template will be used. When it's complete, it switches to the default template. Neat!

Teleport

Another interesting feature that started its life in React is the idea of Teleport (or Portals as React calls it). The basic idea of teleport is to be able to render some part of a component in a DOM element that exists outside the current scope. For example, you can add a Teleport element with some content that you want to render:

<div>  
  <Teleport to="#appTitle">
    <h3>{{ name }}</h3>
  </Teleport>
...

And on your webpage, you might have a div with the ID of appTitle:

<p>
  Teleport Should Appear here, 
  not inside the component:
</p>
<div id="appTitle"></div>

When rendered, the contents will be “teleported” to the appTitle div and shown in that part of the UI—it doesn't need to be inside your application at all. Ta-da!

Routing

Although Vue Routing has been upgraded to Vue 3, there were some changes to the API to make it more consistent—overall, the goal was to make sure that it was more compatible with TypeScript. Routing is, on the whole, backward compatible with Vue Routing version 3 (which was used in Vue 2). There are some breaking changes, but they are relatively minor including:

  • Specifying the routing mode is now done via a new history property instead of a mode property
  • The base of routing is now specified when you specify history
  • Catch-all routes have changed format
  • Transitions must now wait until the router is ready

You can review the breaking changes on the repository: https://github.com/vuejs/vue-router-next.

Vuex

The overall API for Vuex has been kept for this new version of Vuex for Vue 3. But some of the ways you wire-up the store are different. For example, although you can still create a Vuex Store by calling new Vuex.Store, they're suggesting that you use the createStore exported function to better align with the way that Vue Router. So, in version 2, you'd create a new store like this:

import Vuex from 'vuex'

export default new Vuex.Store({
  state: {
  },
  mutations: {
  },
  actions: {
  },
  modules: {
  }
});

In contrast, in version 3, they suggest that you create it like so:

import { createStore } from 'vuex'

export default createStore({
  state: {
  },
  mutations: {
  },
  actions: {
  },
  modules: {
  }
});

With the changes to global mounting, this changes how you'd wire up the store as well:

import { createApp } from 'vue';
import App from './App.vue'
import store from './store'
createApp(App)
  .use(store)
  .mount('#app')

Except for these minor changes, your Vuex code should just continue to work.

Where Are We?

Let me be honest. I'm sure that since we're not at release yet, I'm missing a couple of breaking changes. The purpose of this article isn't to be completely exhaustive but instead to prepare you for the major changes coming to Vue. On the whole, I really like the new changes and think they are a definite improvement in the library. If you don't agree, don't worry. You can stay with Vue 2 as long as you like. They are back-porting some of the major changes to allow you to continue using Vue 2 if you like, although the back-porting will happen sometime after Vue 3 releases.

I hope this article has helped gird you for the upcoming changes and, at best, gets you excited to try the new version of Vue.