Vue 3.2 - Using Composition API with Script Setup

Introduction

Vue 3 introduced the Composition API as a new way to work with reactive state in a Vue application. Rather than organizing code by functionality, (data, computed, methods, watch, etc), you can group code by feature (users, API, form). This allows for a greater amount of flexibility while building a Vue application. We've already talked about the Composition in other articles (if you haven't read them, check them out!), but with the release of Vue 3.2, another Composition-related feature has been released as stable - <script setup>.

In short, <script setup> allows developers to define a component without having to export anything from your JavaScript block - simply define your variables and use them in your template! This style of writing a component resembles Svelte in many ways, and is a massive improvement for anyone coming into Vue for the first time.

<script setup> Basics

Let's look at an example. If you were using the Options API (the standard of Vue 2), all of your single-file components would look something like this:

<template>
  <div>Hello, {{ name }}!</div>
  <input v-model="name" />
  <button :disabled="!isNamePresent" @click="submitName">Submit</button>
</template>

<script>
export default {
  data() {
    return {
      name: ''
    }
  },
  computed: {
    isNamePresent() {
      return this.name.length > 0
    }
  },
  methods: {
    submitName() {
      console.log(this.name)
    }
  }
}
</script>

We have our template (a simple form), and our script block. Within the script block, we export an object with three keys: name, computed, and methods. If you are familiar with Vue, this should look familiar to you. Now, let's switch this code to use the Composition API.

<template>
  <div>Hello, {{ name }}!</div>
  <input v-model="name" />
  <button :disabled="!isNamePresent" @click="submitName">Submit</button>
</template>

<script>
import { ref, computed } from 'vue'

export default {
  setup() {
    const name = ref('')
    const isNamePresent = computed(() => name.value.length > 0)

    function submitName() {
      console.log(name.value)
    }

    return {
      name,
      isNamePresent,
      submitName
    }
  }
}
</script>

Our component does the exact same thing as before. We define our state (name), a computed property (isNamePresent), and our submit function. If any of this is unfamiliar, check out my previous articles on the Vue Composition API. Rather than having to scaffold our application within an object being exported, we are free to define our variables as we want. This flexibility also allows us to extract repeated logic from the component if we want to, but in this case our component is pretty straightforward.

However, we still have that awkward export default statement. Our code all lives within the setup function, while the rest is really just boilerplate. Can't we just remove it? Actually, we can now! This is where <script setup> comes in. Let's switch to use script setup instead of the standard script block.

<template>
  <div>Hello, {{ name }}!</div>
  <input v-model="name" />
  <button :disabled="!isNamePresent" @click="submitName">Submit</button>
</template>

<script setup>
import { ref, computed } from 'vue'

const name = ref('')
const isNamePresent = computed(() => name.value.length > 0)

function submitName() {
  console.log(name.value)
}
</script>

Let's go over what changed here. First, we added the word "setup" to our script tag, which enables this new mode for writing Vue components. Second, we took our code from within the setup function, and replaced our existing exported object with just our code. And everything works as expected!

Note that everything declared within the script tags is available in the template of your component. This includes non-reactive variables or constants, as well as utility functions or other libraries. The major benefit of this is that you don't need to manually bind an external file (Constants.js, for example) as a value of your component - Vue handles this for you now.

Additional Features

You may be wondering how to handle some of the core aspects of writing Vue components, like utilizing other components or defining props. Vue 3.2 has us covered for those use cases as well! Let's take a look at some of the additional features provided by this approach to building Vue single-file components.

Defining Components

When using <script setup>, we don't have to manually define our imported components any more. By importing a component into the file, the compiler is able to automatically add it to our application. Let's update our component by abstracting the form into its own component. We'll call it Form.vue. For now, it will simply be the template, and we'll get to the logic in a moment.

<!-- Form.vue -->
<template>
  <form @submit.prevent="submitHandler">
    <label>Name
      <input type="text" />
    </label>
    <button>Submit</button>
  </form>
</template>

<script setup>
function submitHandler() {
  // Do something
}
</script>

<!-- App.vue -->
<template>
  <div>Hello, {{ name }}!</div>
  <Form />
</template>

<script setup>
import { ref } from 'vue'
import Form from './components/Form.vue'

const name = ref('')

function submitForm() {
  console.log(name.value)
}
</script>

That's it! Our component now has to be imported into our Vue file, and it's automatically available in our template. No more components block taking up space in our file!

Now, we need to pass name into our child component as a prop. But wait, we can't define props! We don't have an object to add the props option to! Also, we need to emit that the form was submitted so that we can trigger our submission. How can we define what our child component emits?

defineProps and defineEmits

We can still define our components props and emits by using new helper methods defineProps and defineEmits. From the Vue docs, "defineProps and defineEmits are compiler macros only usable inside <script setup>. They do not need to be imported, and are compiled away when <script setup> is processed." These compile-time functions take the same arguments as the standard keys would use with a full export object. Let's update our app to use defineProps and defineEmits.

<!-- Form.vue -->
<template>
  <form @submit.prevent="submitHandler">
    <label>Name
      <input v-model="name" type="text" />
    </label>
    <button>Submit</button>
  </form>
</template>

<script setup>
import { computed } from 'vue'
const props = defineProps({
  modelValue: {
    type: String,
    default: ''
  }
})
const emit = defineEmits(['update:modelValue', 'submit']);

const name = computed({
  get () {
    return props.modelValue
  },
  set(val) {
    emit('update:modelValue', val);
  }
})

function submitHandler() {
  emit('submit')
}
</script>

<!-- App.vue -->
<template>
  <div>Hello, {{ name }}!</div>
  <Form v-model="name" @submit="submitForm" />
</template>

<script setup>
import { ref } from 'vue'
import Form from './components/Form.vue'

const name = ref('')

function submitForm() {
  console.log(name.value)
}
</script>

Let's go over what changed here.

  • First, we used defineProps to expect a modelValue (the expected prop for use with v-model in Vue 3).
  • We then defined our emits with defineEmits, so that we are both reporting what this component emits, and are also getting access to the emit function (previously available on `this.$emit).
  • Next, we create a computed property that utilizes a custom getter and setting. We do this so we can easily use v-model on our form input, but it's not a requirement. The getter returns our prop, where the setter emits the update event to our parent component.
  • Last of all, we hook up our submitHandler function to emit a submit event as well.

Our App.vue component is more or less as we left it, with the addition of v-model="name" and @submit="submitForm" to the Form child component. With that, our application is working as expected again!

Other Features

There are a lot more features available to us here, but they have fewer use cases in a typical application.

  • Dynamic Components - Since our components are immediately available in the template, we can utilize them when writing a dynamic component (<component :is="Form" />, for example).
  • Namespaced Components - If you have a number of components imported from the same file, these can be namespaced by using the import * as Form syntax. You then have access to <Form.Input> or <Form.Submit>, for example, without any extra work on your part.
  • Top-Level Await - If you need to make an API request as part of the setup for a component, you are free to use async/await syntax at the top level of your component - no wrapping in an async function required! Keep in mind that a component that utilizes this must be wrapped externally by a <Suspense> component - read more here to learn how to use Suspense in Vue.

Another point to keep in mind is that you aren't locked into using <script setup>. If you are using this new syntax for a component and run into a case where you aren't able to get something done, or simply want to use the Options syntax for a particular case, you are free to do so by adding an additional <script> block to your component. Vue will mix the two together for you, so your Composition code and Options code can remain separate. This can be extremely useful when using frameworks like Nuxt that provide additional methods to the standard Options syntax that are not exposed in <script setup>. See the Vue docs for a great example of this.

Conclusion

This is a big step forward for Vue and the Composition API. In fact, Evan You has gone on the record as saying this is intended to be the standard syntax for Vue single-file components going forward. From a discussion on Github:

There's some history in this because the initial proposal for Composition API indicated the intention to entirely replace Options API with it, and was met with a backlash. Although we did believe that Composition API has the potential to be "the way forward" in the long run, we realized that (1) there were still ergonomics/tooling/design issues to be resolved and (2) a paradigm shift can't be done in one day. We need time and early adopters to validate, try, adopt and experiment around the new paradigm before we can confidently recommend something new to all Vue users.

That essentially led to a "transition period" during which we intentionally avoided declaring Composition API as "the new way" so that we can perform the validation process and build the surrounding tooling /ecosystem with the subset of users who proactively adopted it.

Now that <script setup> has shipped, along with improvements in IDE tooling support, we believe Composition API has reached a state where it provides superior DX and scalability for most users. But we needed time to get to this point.

Earlier in that same thread, Evan expressed his views on what development looks like going forward for Vue:

The current recommended approach is:

  • Use SFC + <script setup> + Composition API
  • Use VSCode + Volar (or WebStorm once its support for <script setup> ships soon)
  • Not strictly required for TS, but if applicable, use Vite for build tooling.

If you're looking to use Vue 3 for either a new or existing application, I highly recommend trying out this new format for writing Vue single-file components. Looking to try it out? Here's a Stackblitz project using Vite and the example code above.


This Dot Labs is a modern web consultancy focused on helping companies realize their digital transformation efforts. For expert architectural guidance, training, or consulting in React, Angular, Vue, Web Components, GraphQL, Node, Bazel, or Polymer, visit thisdotlabs.com.

This Dot Media is focused on creating an inclusive and educational web for all. We keep you up to date with advancements in the modern web through events, podcasts, and free content. To learn, visit thisdot.co.

You might also like