The 5 ways to Define a Component in Vue 3

Fotis Adamakis
5 min readJan 2, 2023

Updated version ▶️ The Anatomy of a Vue 3 Component

Vue is evolving, and there are currently multiple ways to define a component in version 3. From Options to Composition to Class API, things are quite different and can get confusing if you are just getting started. Let’s define a simple component and refactor it using all the available methods.

1. Options API

This is the most common way to declare a component in Vue. Available since version 1, you are most probably familiar with it already. Everything is declared inside an object, and the data is made reactive by Vue behind the scenes. It’s not that flexible, since it uses mixins for sharing behaviour.

<script>
import TheComponent from './components/TheComponent.vue'
import componentMixin from './mixins/componentMixin.js'

export default {
name: 'OptionsAPI',
components: {
TheComponent,
AsyncComponent: () => import('./components/AsyncComponent.vue'),
},
mixins: [componentMixin],
props: {
elements: {
type: Array,
},
counter: {
type: Number,
default: 0,
},
},
data() {
return {
object: {
variable: true,
},
}
},
computed: {
isEmpty() {
return this.counter === 0
},
},
watch: {
counter() {
console.log('Counter value changed')
},
},
created() {
console.log('Created hook called')
},
mounted() {
console.log('Mounted hook called')
},
methods: {
getParam(param) {
return param
},
emitEvent() {
this.$emit('event-name')
},
},
}
</script>
<template>
<div class="wrapper">
<TheComponent />
<AsyncComponent v-if="object.variable" />
<div class="static-class-name" :class="{ 'dynamic-class-name': object.variable }">
Dynamic attributes example
</div>
<button @click="emitEvent">Emit event</button>
</div>
</template>

<style lang="scss" scoped>
.wrapper {
font-size: 20px;
}
</style>

2. Composition API

Composition API was introduced in Vue 3 after many discussions, feedback from the community and, surprisingly, a lot of drama, in this RFC. The motivation was to provide a more flexible API and better TypeScript support. This approach relies heavily on the setup lifecycle hook.

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

import useMixin from './mixins/componentMixin.js'
import TheComponent from './components/TheComponent.vue'

export default defineComponent({
name: 'CompositionAPI',
components: {
TheComponent,
AsyncComponent: () => import('./components/AsyncComponent.vue'),
},
props: {
elements: Array,
counter: {
type: Number,
default: 0,
},
},
setup(props, { emit }) {
console.log('Equivalent to created hook')

const enabled = ref(true)
const object = reactive({ variable: false })

const { mixinData, mixinMethod } = useMixin()

const isEmpty = computed(() => {
return props.counter === 0
})

watch(
() => props.counter,
() => {
console.log('Counter value changed')
}
)

function emitEvent() {
emit('event-name')
}
function getParam(param) {
return param
}

return {
object,
getParam,
emitEvent,
isEmpty
}
},
mounted() {
console.log('Mounted hook called')
},
})
</script>

<template>
<div class="wrapper">
<TheComponent />
<AsyncComponent v-if="object.variable" />
<div class="static-class-name" :class="{ 'dynamic-class-name': object.variable }">
Dynamic attributes example
</div>
<button @click="emitEvent">Emit event</button>
</div>
</template>

<style scoped>
.wrapper {
font-size: 20px;
}
</style>

As you can tell, with this hybrid approach, a lot of boilerplate code is needed, and the setup function can get out of hand pretty quickly. This might be a good intermediate step when moving to Vue 3, but there is syntactic sugar available that makes everything cleaner.

3. Script setup

In Vue 3.2 a less verbose syntax was introduced. By adding the setup attribute in the script element, everything in the script section is automatically exposed to the template. A lot of boilerplate can be removed this way.

<script setup>
import {
ref,
reactive,
defineAsyncComponent,
computed,
watch,
onMounted,
} from "vue";

import useMixin from "./mixins/componentMixin.js";
import TheComponent from "./components/TheComponent.vue";
const AsyncComponent = defineAsyncComponent(() =>
import("./components/AsyncComponent.vue")
);

console.log("Equivalent to created hook");
onMounted(() => {
console.log("Mounted hook called");
});

const enabled = ref(true);
const object = reactive({ variable: false });

const props = defineProps({
elements: Array,
counter: {
type: Number,
default: 0,
},
});

const { mixinData, mixinMethod } = useMixin();

const isEmpty = computed(() => {
return props.counter === 0;
});

watch(() => props.counter, () => {
console.log("Counter value changed");
});

const emit = defineEmits(["event-name"]);
function emitEvent() {
emit("event-name");
}
function getParam(param) {
return param;
}
</script>

<script>
export default {
name: "ComponentVue3",
};
</script>

<template>
<div class="wrapper">
<TheComponent />
<AsyncComponent v-if="object.variable" />
<div
class="static-class-name"
:class="{ 'dynamic-class-name': object.variable }"
>
Dynamic attributes example
</div>
<button @click="emitEvent">Emit event</button>
</div>
</template>

<style scoped>
.wrapper {
font-size: 20px;
}
</style>

4. Reactivity Transform

Update 26/1/2023: this was very controversial and was dropped! This leaves the script setup the clear and obvious answer to this article.

There is an issue with the script setup demonstrated in the following snippet.

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

const counter = ref(0)
counter.value++

function increase() {
counter.value++
}

const double = computed(() => {
return counter.value * 2
})
</script>


<template>
<div class="wrapper">
<button @click="increase">Increase</button>
{{ counter }}
{{ double }}
</div>
</template>

As you have noticed, accessing the reactive counter with the .value feels unnatural and a common source of confusion and misstypings. There is an experimental solution that leverages compile time transforms to fix this. Reactivity transform is an opt-in built step that adds this suffix automatically and makes the code look much cleaner.

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

let counter = $ref(0)
counter++

function increase() {
counter++
}

const double = computed(() => {
return counter * 2
})
</script>


<template>
<div class="wrapper">
<button @click="increase">Increase</button>
{{ counter }}
{{ double }}
</div>
</template>

$ref requires a build step but removes the necessity of .value when accessing a variable. It is globally available when enabled.

5. Class API

The Class API has been available for a long time. Usually paired with Typescript was a solid option for Vue 2 and was seriously considered as the default Vue 3 syntax. But after many long discussions, it was dropped in favour of the Composition API. It is available in Vue 3, but the tooling is significantly lacking, and the official recommendation is to move away from it. In any case, if you really like using classes, your components will look something like this.

<script lang="ts">
import { Options, Vue } from 'vue-class-component';

import AnotherComponent from './components/AnotherComponent.vue'

@Options({
components: {
AnotherComponent
}
})
export default class Counter extends Vue {
counter = 0;

get double(): number {
return this.counter * 2;
}
increase(): void {
this.quantity++;
}
}
</script>


<template>
<div class="wrapper">
<button @click="increase">Increase</button>
{{ counter }}
{{ double }}
</div>
</template>

Conclusion

Which is the best then? It depends is the typical response, although not really in this case. When transferring from Vue 2, options and class APIs can be utilized as intermediary steps, but they shouldn’t be your first choice. Composition API setup is the only option if you can’t have a build phase, but since most projects are generated with Webpack or Vite, utilizing script setup is both possible and actually encouraged since most of the accessible documentation utilizes this approach.

--

--

« Senior Software Engineer · Author · International Speaker · Vue.js Athens Meetup Organizer »