Vue 3 has not been officially released yet, but the maintainers have released beta versions for us punters to try and provide feedback on.

If you're wondering what the key features and main changes of Vue 3 are, I'll highlight them in this article by walking you through the creation of a simple app using Vue 3 beta 9.

I'm going to cover as much new stuff as I can including fragments, teleport, the Composition API, and several more obscure changes. I'll do my best to explain the rationale for the feature or change as well.

Table of contents:


    What we'll build

    We're going to build a simple app with a modal window feature. I chose this because it conveniently allows me to showcase a number of Vue 3 changes.

    Here's what the app looks like in it's opened and closed states so you can picture in your mind what we're working on:

    Vue 3 app modal

    Vue 3 installation and setup

    Rather than installing Vue 3 directly, let's clone the project vue-next-webpack-preview which will give us a minimal Webpack setup including Vue 3.

    $ git clone https://github.com/vuejs/vue-next-webpack-preview.git vue3-experiment
    $ cd vue3-experiment
    $ npm i
    

    Once that's cloned and the NPM modules are installed, all we need to do is remove the boilerplate files and create a fresh main.js file so we can create our Vue 3 app from scratch.

    $ rm -rf src/*
    $ touch src/main.js
    

    Now we'll run the dev server:

    $ npm run dev
    

    Creating a new Vue 3 app

    Straight off the bat, the way we bootstrap a new Vue app has changed. Rather than using new Vue(), we now need to import the new createApp method.

    We then call this method, passing our Vue instance definition object, and assign the return object to a variable app.

    Next, we'll call the mount method on app and pass a CSS selector indicating our mount element, just like we did with the $mount instance method in Vue 2.

    src/main.js

    import { createApp } from "vue";
    
    const app = createApp({
      // root instance definition
    });
    
    app.mount("#app");
    

    Reason for change

    With the old API, any global configuration we added (plugins, mixins, prototype properties etc) would permanently mutate global state. For example:

    src/main.js

    // Affects both instances
    Vue.mixin({ ... })
    
    const app1 = new Vue({ el: '#app-1' })
    const app2 = new Vue({ el: '#app-2' })
    

    This really shows up as an issue in unit testing, as it makes it tricky to ensure that each test is isolated from the last.

    Under the new API, calling createApp returns a fresh app instance that will not be polluted by any global configuration applied to other instances.

    Learn more: Global API change RFC.

    Adding state properties

    Our modal window can be in one of two states - opened, or closed. Let's manage this with a boolean state property modalOpen which we'll give an initial value of false.

    Under Vue 2, we could do this by creating a data property on our app instance and assigning an object to this where our modalOpen property would be declared i.e.:

    src/main.js

    const app = createApp({
      data: {
        modalOpen: false
      }
    });
    

    This is no longer allowed. Instead, data must be assigned a factory function which returns the state object.

    This is what you had to do for Vue components, but now it's enforced for Vue app instances as well.

    src/main.js

    const app = createApp({
      data: () => ({
        modalOpen: false
      })
    });
    

    Reason for change

    The advantage of using an object for data rather than a factory function is that, firstly, it was syntactically simpler, and secondly, you could share top-level state between multiple root instances e.g.:

    src/main.js

    const state = {
      sharedVal: 0
    };
    
    const app1 = new Vue({ state });
    const app2 = new Vue({ state });
    
    // Affects both instances
    app1._data.sharedVal = 1;
    

    The use case for this is rare and can be worked around. Since having two types of declarations is not beginner-friendly, it was decided to remove this feature.

    Learn more: Data object declaration removed RFC

    Before we move on, let's also add a method to toggle the modalOpen value. This is no different from Vue 2.

    src/main.js

    const app = createApp({
      data: () => ({
        modalOpen: true  
      }),
      methods: {
        toggleModalState() {
          this.modalOpen = !this.modalOpen;
        }
      }
    });
    

    Using a root component

    If you go to the browser now and check the console, you'll see the warning "Component is missing render function", since we haven't yet defined a template for the root instance.

    The best practice for Vue 2 is to create a minimal template for the root instance and create an App component where the main app markup will be declared.

    Let's do that here, as well.

    $ touch src/App.vue
    

    Now we can get the root instance to render that component. The difference is that with Vue 2, we'd normally use a render function for doing this:

    src/main.js

    import App from "./App.vue";
    
    const app = createApp({
      ...
      render: h => h(App)
    });
    
    app.mount("#app");
    

    We can still do that, but Vue 3 has an even easier way - making App a root component. To do this, we can remove the root instance definition and instead pass the App component.

    src/main.js

    import App from "./App.vue";
    
    const app = createApp(App);
    
    app.mount("#app");
    

    This means the App component is not just rendered by the root instance but is the root instance.

    While we're at it, let's simply the syntax a little by removing the app variable:

    src/main.js

    createApp(App).mount("#app");
    

    Moving over to the root component now, let's re-add the state and method to this component:

    src/App.vue

    <script>
    export default {
      data: () => ({
        modalOpen: true  
      }),
      methods: {
        toggleModalState() {
          this.modalOpen = !this.modalOpen;
        }
      }
    };
    </script>
    

    Let's also make a new component for the modal feature:

    $ touch src/Modal.vue
    

    For now, we'll provide a minimal template including a slot for content. This ensures our modal is reusable. We'll add more to this component later.

    src/Modal.vue

    <template>
      <div class="modal">
        <slot></slot>
      </div>
    </template>
    

    Multi-root templates

    Let's now create the template for our root component. We'll create a button to open the modal which will trigger the toggleModalState method.

    We'll also use our just-created modal component which will be rendered conditional on the value of modalOpen. Let's also insert a paragraph of text into the slot for content.

    src/App.vue

    <template>
      <button @click="toggleModalState">Open modal</button>
      <modal v-if="modalOpen">
        <p>Hello, I'm a modal window.</p>
      </modal>
    </template>
    <script>
    import Modal from "./Modal.vue";
    export default {
      components: {
        Modal
      },
      ...
    }
    </script>
    

    Notice anything odd about this template? Look again. I'll wait.

    That's right - there are two root elements. In Vue 3, thanks to a feature called fragments, it is no longer compulsory to have a single root element!

    Are You Ready For Vue 3?

    Join our free four-part email course to learn the key changes in Vue 3 that you need to be aware of!

    This subscription also includes Vue.js Developers promotional emails. You can opt-out at any time. View our privacy policy .

    Refactoring with Composition API

    The flagship feature of Vue 3 is the Composition API. This new API allows you to define component functionality using a setup function rather than with properties you add to the component definition object.

    Let's now refactor our App component to use the Composition API.

    Before I explain the code, be clear that all we're doing is refactoring - the functionality of the component will be the same. Also note that the template is not changed as the Composition API only affects the way we define the component functionality, not the way we render it.

    src/App.vue

    <template>
      <button @click="toggleModalState">Open modal</button>
      <modal v-if="modalOpen">
        <p>Hello, I'm a modal window.</p>
      </modal>
    </template>
    <script>
    import Modal from "./Modal.vue";
    import { ref } from "vue";
    export default {
      setup () {
        const modalOpen = ref(false);
        const toggleModalState = () => {
          modalOpen.value = !modalOpen.value;
        };
        return {
          modalOpen,
          toggleModalState
        }
      },
      components: {
        Modal
      }
    };
    </script>
    

    setup method

    Firstly, notice we import the ref function which allows us to define a reactive variable modalOpen. This variable is equivalent to this.modalOpen.

    The toggleModalState method is just a plain JavaScript function. However, notice that to change the value of modalOpen in the method body, we need to change its sub-property value. That's because reactive variables created using ref are wrapped in an object. This is necessary to retain their reactivity as they're passed around.

    It's best to consult the Vue Composition API docs if you want a detailed explanation of how refs work.

    Finally, we return modalOpen and toggleModalState from the setup method, as these are the values that get passed to the template when it's rendered.

    Reason for change

    Keep in mind that the Composition API is not a change as it's purely optional to use. The main motivation is to allow for better code organization and the reuse of code between components (as mixins are essentially an anti-pattern).

    You'd be correct in thinking that refactoring the App component in this example to use the Composition API is unnecessary. But, if this were a much larger component, or we needed to share its features with other components, that's when you'd see its usefulness.

    Providing a more in-depth example is beyond the scope of this blog post, so if you're interested to learn more about uses of the new API, check out my other article When To Use The New Vue Composition API (And When Not To).

    Teleporting content

    If you've ever created a modal feature before, you'll know that it's commonly positioned just before the closing </body> tag.

    <body>
      <div>
        <!--main page content here-->
      </div>
      <!--modal here-->
    </body>
    

    This is done because modals usually have a page-covering background (see the image at the beginning if you don't know what I mean). To implement this with CSS, you don't want to have to deal with parent elements positioning and z-index stacking context, and so the simplest solution is to put the modal at the very bottom of the DOM.

    This creates an issue with Vue.js, though, which assumes the UI will be built as a single tree of components. To allow segments of the tree to be moved elsewhere in the DOM, a new teleport component has been added in Vue 3.

    To use the teleport, let's first add an element to the page where we want our modal content to be moved to. We'll go to index.html and place a div with ID modal-wrapper adjacent to Vue's mounting element.

    index.html

    <body>
      ...
      <div id="app"></div><!--Vue mounting element-->
      <div id="modal-wrapper">
        <!--modal should get moved here-->
      </div>
    </body>
    

    Now, back in App.vue, we're going to wrap the modal content in the teleport component. We'll also need to specify a to attribute which will be assigned a query selector identifying the target element, in this case, #modal-wrapper.

    src/App.vue

    <template>
      <button @click="toggleModalState">Open modal</button>
      <teleport to="#modal-wrapper">
        <modal v-if="modalOpen">
          <p>Hello, I'm a modal window.</p>
        </modal>
      </teleport>
    </template>
    

    And that's it. Any content within the teleport will be rendered within the target element. However, it will still function like it was in it's original position in the hierarchy (regarding props, events etc).

    So after you've saved your code, reload the page, inspect the DOM in dev tools, and be amazed!

    Learn more: Teleport RFC

    Emitting an event

    Let's now add a button to our modal allowing it to be closed. To do this, we're going to add a button element to the modal tempate with a click handler that emits an event close.

    src/Modal.vue

    <template>
      <div class="modal">
        <slot></slot>
        <button @click="$emit('close')">Dismiss</button>
      </div>
    </template>
    

    This event is will then be captured by the parent component and will toggle the value of modalOpen, logically making it false and causing the window to close.

    src/App.vue

    <template>
      ...
        <modal 
          v-if="modalOpen" 
          @close="toggleModalState"
        >
          <p>Hello, I'm a modal window.</p>
        </modal>
      </teleport>
    </template>
    

    So far, this feature is identical as it would be in Vue 2. However, in Vue 3 it's now recommended that you explicitly state a component's events using the new emits component option. Just like with props, you can simply create an array of strings to name each event the component will emit.

    src/Modal.vue

    <template>...</template>
    <script>
    export default {
      emits: [ "close" ]
    }
    </script>
    

    Reason for change

    Imagine opening the file of a component that someone else wrote, and seeing its props and events explicitly declared. Immediately, you would understand the interface of this component i.e. what it's meant to send and receive.

    In addition to providing self-documenting code, you can also use the events declaration to validate your event payload, though I couldn't find a reason to do that in this example.

    Learn more: Emits Option RFC

    Styling slot content

    To make our modal reusable, we've provided a slot for content. Let's begin to style that content by adding a style tag to the component.

    It's a good practice to use scoped CSS in our components to ensure the rules we provide don't have unintended effects on other content in the page.

    Let's make it so any paragraph text that gets put into the slot is italic. To do this, we'll create a new CSS rule using the p selector.

    src/Modal.vue

    <template>...</template>
    <script>...</script>
    <style scoped>
      p {
        font-style: italic;
      }
    </style>
    

    If you try this, you'll see that it doesn't work. The issue is that scoped styling is determined at compile time when the slot content still belongs to the parent.

    The solution provided by Vue 3 is to provide a pseudo selector ::v-slotted() allowing you to target slot content with scoped rules in the component providing the slot.

    Here's how we use it:

    src/Modal.vue

    <style scoped>
      ::v-slotted(p) {
        font-style: italic;
      }
    </style>
    

    Vue 3 also includes some other new scoped styling selectors ::v-deep and ::v-global which you can learn more about here: Scoped Styles RFC

    Other changes

    Well, that's all the new features I could feasibly cover in a simple example. I got most of the main ones in, but here are some that I thought important enough to mention before concluding the article that you can research for yourself:

    Added:

    Removed:

    Changed:

    There are also various changes regarding Vue Router that you can check out here.


    Anthony Gore

    About Anthony Gore

    I'm Anthony Gore and I'm here to teach you Vue.js! Through my books, online courses, and social media, I aim to turn you into a Vue.js expert.

    I'm a Vue Community Partner, curator of the weekly Vue.js Developers Newsletter, and the creator of Vue.js Developers.

    If you enjoyed this article, show your support by buying me a coffee. You might also enjoy taking one of my online courses!