From options API to script setup using the composition API

Constantin Druc · 24 Feb, 2022

If you are using or want to use the new vue composition API, script setup is now the recommended syntax for it. And that comes with a couple of advantages:

  • less boilerplate
  • better typescript support
  • slight performance benefits

In this post, we’ll take the video player component I use here on tallpad, which is currently written using the options API, and convert it to the new script setup syntax using the new composition API.

The template part is irrelevant as it doesn’t have to change when you switch to the script setup syntax; so I’ll just add an empty tag; just pretend it’s there and it has stuff in it. You’ll find the full snippets by clicking on the “Code” tab above.

<template><!-- [tl! collapse:start] -->
<div class="relative aspect-w-16 aspect-h-9">
<div class="flex absolute inset-0 justify-center items-center">
<LoadingIcon class="w-14 h-14 text-white animate-spin"></LoadingIcon>
</div>
<slot></slot>
</div>
<div class="flex justify-end">
<div class="grid grid-cols-5 w-full max-w-xl divide-x divide-gray-700">
<button
class="inline-flex justify-center items-center p-2 space-x-1 text-base font-medium text-white cursor-pointer hover:text-orange-600 focus:outline-none"
@click="toggleSpeed()">
<span class="hidden sm:inline-block">Speed:</span><span>{{ currentSpeed }}x</span>
</button>
<button
class="inline-flex justify-center items-center p-2 space-x-1 text-base font-medium text-white cursor-pointer hover:text-orange-600 focus:outline-none"
@click="goBack(5)">
<BackwardIcon class="w-4 h-4"/>
<span>5s</span>
</button>
<button
class="inline-flex justify-center items-center p-2 space-x-1 text-base font-medium text-white cursor-pointer hover:text-orange-600 focus:outline-none"
@click="goForward(5)">
<span>5s</span>
<ForwardIcon class="w-4 h-4"/>
</button>
<button
class="inline-flex justify-center items-center p-2 space-x-1 text-base font-medium text-white cursor-pointer hover:text-orange-600 focus:outline-none"
@click="goBack(10)">
<BackwardIcon class="w-4 h-4"/>
<span>10s</span>
</button>
<button
class="inline-flex justify-center items-center p-2 space-x-1 text-base font-medium text-white cursor-pointer hover:text-orange-600 focus:outline-none"
@click="goForward(10)">
<span>10s</span>
<ForwardIcon class="w-4 h-4"/>
</button>
</div>
</div>
</template><!-- [tl! collapse:end] -->
<script>
import axios from "axios";
import Player from '@vimeo/player';
import {throttle} from 'lodash';
import ForwardIcon from './svgs/ForwardIcon';
import BackwardIcon from './svgs/BackwardIcon';
import LoadingIcon from './svgs/LoadingIcon';
export default {
props: {
lessonId: Number,
trackProgress: Boolean
},
components: {
BackwardIcon,
ForwardIcon,
LoadingIcon
},
data() {
return {
speedIndex: 1,
player: null,
speedOptions: [0.75, 1, 1.25, 1.5, 1.75, 2.0],
}
},
created() {
if (window.localStorage.getItem('speedIndex')) {
this.speedIndex = parseInt(window.localStorage.getItem('speedIndex'));
}
},
mounted() {
player = new Player(this.$slots.default()[0].el);
player.on('play', () => player.setPlaybackRate(this.currentSpeed));
if (this.trackProgress) {
player.on('progress', throttle((event) => {
axios.put(`/lessons/${this.lessonId}/progress`, {percent: event.percent});
}, 10000));
player.on('ended', (event) => {
axios.put(`/lessons/${this.lessonId}/progress`, {percent: 1});
});
}
},
unmounted() {
player.off('play');
player.off('progress');
player.off('ended');
},
methods: {
toggleSpeed() {
if (typeof speedOptions[this.speedIndex + 1] !== "undefined") {
this.speedIndex += 1;
} else {
this.speedIndex = 0;
}
window.localStorage.setItem('speedIndex', this.speedIndex);
player.setPlaybackRate(this.currentSpeed);
},
async goForward(seconds) {
const currentTime = await player.getCurrentTime();
const totalDuration = await player.getDuration();
player.setCurrentTime(Math.min(totalDuration, (currentTime + seconds)));
},
async goBack(seconds) {
const currentTime = await player.getCurrentTime();
player.setCurrentTime(Math.max(0, (currentTime - seconds)));
}
},
computed: {
currentSpeed() {
return speedOptions[this.speedIndex];
}
}
}
</script>

You can place the script tag anywhere you want, but the convention seems to be to have it set at the start of the file. So we’ll move it and also add a setup attribute to it.

<script setup>...</script>
<template>...</template>

Moving on, to define the component props, we can use the defineProps function, which is a vue compiler macro and doesn’t need to be imported from anywhere, and which takes the props object as an argument. We’ll store that into a props constant to be used later.

const props = defineProps({ // [tl! ++]
lessonId: Number, // [tl! ++]
trackProgress: Boolean // [tl! ++]
}); // [tl! ++]
export default {
props: { // [tl! --]
lessonId: Number, // [tl! --]
trackProgress: Boolean // [tl! --]
} // [tl! --]
//...
}

Now, for my favorite part of this new script setup syntax, we no longer need to register the components, so we can completely delete the components object. As long as you import those components, they will be readily available to use in your template.

export default {
components: { // [tl! --]
BackwardIcon, // [tl! --]
ForwardIcon, // [tl! --]
LoadingIcon // [tl! --]
}, // [tl! --]
//...
}

For the data function, we’ll grab the object properties and convert them into reactive values using the ref function.

However, if you take a close look at the initial code snippet, you will find that not all properties should necessarily be reactive. The player and speedOptions values never change. Well, player does change, but that’s just because I need to wait for the component to mount to create a new instance of the Vimeo Player using the default slot element. Other than that, those values never change, so we’ll just turn them into plain, non-reactive javascript values.

const speedIndex = ref(1); // [tl! ++]
let player = null; // [tl! ++]
const speedOptions = [0.75, 1, 1.25, 1.5, 1.75, 2.0]; // [tl! ++]
export default {
data() { // [tl! --]
return { // [tl! --]
speedIndex: 1, // [tl! --]
player: null, // [tl! --]
speedOptions: [0.75, 1, 1.25, 1.5, 1.75, 2.0], // [tl! --]
} // [tl! --]
} // [tl! --]
//...
}

The created lifecycle method no longer exists in the vue3 composition API. It has been replaced with the setup function. This means we can grab the code under created, paste it directly into our script setup, and replace the old data properties with the new reactive values.

if (window.localStorage.getItem('speedIndex')) { // [tl! ++]
speedIndex.value = parseInt(window.localStorage.getItem('speedIndex')); // [tl! ++]
} // [tl! ++]
export default {
created() { // [tl! --]
if (window.localStorage.getItem('speedIndex')) { // [tl! --]
this.speedIndex = parseInt(window.localStorage.getItem('speedIndex')); // [tl! --]
} // [tl! --]
} // [tl! --]
//...
}

Moving on, for the mounted lifecycle method, we can use the onMounted hook and then replace the old reactive references using this. for data properties and props. for the component props.

Then, to get the slot element, we'll use the useSlot() helper, and finally, we'll need to convert the `this.currentSpeed` computed property to use the new composition API.

const speedIndex = ref(1);
let player = null;
const speedOptions = [0.75, 1, 1.25, 1.5, 1.75, 2.0];
const currentSpeed = computed(() => speedOptions[speedIndex.value]); // [tl! ++]
onMounted(() => { // [tl! add:start]
player = new Player(useSlots().default()[0].el);
player.on('play', () => player.setPlaybackRate(currentSpeed.value));
if (props.trackProgress) {
player.on('progress', throttle((event) => {
axios.put(`/lessons/${props.lessonId}/progress`, {percent: event.percent});
}, 10000));
player.on('ended', () => {
axios.put(`/lessons/${props.lessonId}/progress`, {percent: 1});
});
}
}); // [tl! add:end]
export default {
mounted() { // [tl! remove:start]
player = new Player(this.$slots.default()[0].el);
player.on('play', () => player.setPlaybackRate(this.currentSpeed));
if (this.trackProgress) {
player.on('progress', throttle((event) => {
axios.put(`/lessons/${this.lessonId}/progress`, {percent: event.percent});
}, 10000));
player.on('ended', (event) => {
axios.put(`/lessons/${this.lessonId}/progress`, {percent: 1});
});
}
}, // [tl! remove:end]
//...
computed: { // [tl! remove:start]
currentSpeed() {
return speedOptions[this.speedIndex];
}
} // [tl! remove:end]
}

For the unmounted lifecycle method, we can use the onUnmounted hook, cut and paste everything there and replace the old this. references with the plain variable we defined above.

onUnmounted(() => { // [tl! add:start]
player.off('play');
player.off('progress');
player.off('ended');
}); // [tl! add:end]
export default {
unmounted() { // [tl! remove:start]
player.off('play');
player.off('progress');
player.off('ended');
}, // [tl! remove:end]
//...
}

We can now move on to methods. I’ll cut and paste the first function, add the function keyword, and replace the old references with the new ones.

function toggleSpeed() { // [tl! add:start]
if (typeof speedOptions[speedIndex.value + 1] !== "undefined") {
speedIndex.value += 1;
} else {
speedIndex.value = 0;
}
window.localStorage.setItem('speedIndex', speedIndex.value);
player.setPlaybackRate(currentSpeed.value);
} // [tl! add:end]
export default {
methods: {
toggleSpeed() { // [tl! remove:start]
if (typeof speedOptions[this.speedIndex + 1] !== "undefined") {
this.speedIndex += 1;
} else {
this.speedIndex = 0;
}
window.localStorage.setItem('speedIndex', this.speedIndex);
player.setPlaybackRate(this.currentSpeed);
}, // [tl! remove:end]
}
//...
}

We’ll do the same for the second function:

async function goForward(seconds) { // [tl! add:start]
const currentTime = await player.getCurrentTime();
const totalDuration = await player.getDuration();
player.setCurrentTime(Math.min(totalDuration, (currentTime + seconds)));
} // [tl! add:end]
export default {
methods: {
async goForward(seconds) { // [tl! remove:start]
const currentTime = await player.getCurrentTime();
const totalDuration = await player.getDuration();
player.setCurrentTime(Math.min(totalDuration, (currentTime + seconds)));
}, // [tl! remove:end]
}
//...
}

And the third; and we can completly delete the old export.

async function goBack(seconds) { // [tl! add:start]
const currentTime = await player.getCurrentTime();
player.setCurrentTime(Math.max(0, (currentTime - seconds)));
} // [tl! add:end]
export default { // [tl! remove:start]
methods: {
async goBack(seconds) {
const currentTime = await player.getCurrentTime();
player.setCurrentTime(Math.max(0, (currentTime - seconds)));
}
}
} // [tl! remove:end]

One last thing I want to improve is those two axios requests that update the video progress. We can remove that duplication by extracting a new updateProgress(percent) function, and inline the vimeo event listeners.

onMounted(() => {
player = new Player(useSlots().default()[0].el);
player.on('play', () => player.setPlaybackRate(currentSpeed.value));
if (props.trackProgress) {
player.on('progress', throttle((event) => { // [tl! remove:start]
axios.put(`/lessons/${props.lessonId}/progress`, {percent: event.percent});
}, 10000)); // [tl! remove:end]
player.on('progress', throttle((event) => updateProgress(event.percent), 10000)); // [tl! ++]
player.on('ended', () => { // [tl! remove:start]
axios.put(`/lessons/${props.lessonId}/progress`, {percent: 1});
}); // [tl! remove:end]
player.on('ended', () => updateProgress(1)); // [tl! ++]
}
});
function updateProgress(percent) { // [tl! add:start]
return axios.put(`/lessons/${props.lessonId}/progress`, {percent: percent});
} // [tl! add:end]

That was it. We’ve successfully converted a vue component written using the options API to the new vue 3 script setup syntax and composition API.

Make sure to check the Code tab at the start of the page to see the final result!