When it comes to building components, Vue.js offers two different approaches: the Options API and the Composition API. Is one better than the other? Is the Options API obsolete? Is it really necessary to make the switch? We’ll answer those questions and more in the next post, where we’ll look at their differences, what each one brings, and as a main approach, we’ll do an exercise to migrate from the Options API to the Composition API through a component.
This post assumes you’ve been tasked with migrating your project, but if you’re new to Vue.js and unsure which approach to choose, it’ll also be helpful.
Let’s start by understanding these APIs.
The Options API was introduced in Vue 2 and is still supported in Vue 3. It defines Vue components by organizing their logic with predefined options like data, methods, computed, watch, and lifecycle hooks. This approach centers around “component instances”, which alignss better with class-based thinking for users from OOP backgrounds. It’s also more beginner-friendly, abstracting reactivity details and enforcing code organization with option groups. Some key characteristics of this approach are:
data, reactive logic in the computed property).On the other side, the Composition API in Vue 3 is a new way to build components that addresses some limitations of the Options API. It defines a component’s logic using imported API functions. In Vue’s Single-File Components (SFC), Composition API is typically used with <script setup>. The setup attribute is a hint that makes Vue perform compile-time transforms that allow us to use Composition API with less boilerplate. For example, imports and top-level variables / functions declared in <script setup> are directly usable in the template.
Composition API centers around declaring reactive state variables in a function scope and composing state from multiple functions to handle complexity. It requires understanding reactivity in Vue for effective use, but its flexibility enables powerful patterns for organizing and reusing logic [2].
Before discussing which approach is better and determining the most suitable choice, let us examine the code we intend to migrate and review these differences closely.
The project structure is similar for both approaches. The main difference is where we store our shared logic: a mixins directory for the Options API and a composables directory for the Composition API, but we’ll get into that later.
├── App.vue
├── assets
├── components
│ ├── FloatingConfigurator.vue
│ └── dashboard
│ ├── BestSellingWidget.vue
│ ├── NotificationsWidget.vue
│ ├── RecentSalesWidget.vue
│ ├── RevenueStreamWidget.vue
│ └── StatsWidget.vue
├── layout
│ ├── AppFooter.vue
│ ├── AppLayout.vue
│ ├── AppTopbar.vue
│ └── composables
│ └── layout.js
├── main.js
├── router
│ └── index.js
├── service
│ └── ProductService.js
└── views
├── Dashboard.vue
├── composables
│ └── chart.js
└── pages
└── NotFound.vue
(Some files and folders were omitted for simplicity).
This is a web application that serves a company that sells products. It includes a dashboard with key information like recent sales, revenue, registered customers, etc.

This web application is a modified template taken from PrimeVue: a complete UI suite for Vue.js consisting of a rich set of UI components, icons, blocks, and application templates.
As mentioned above, the Options API organizes component logic into clearly defined options, offering a familiar and structured approach to managing component behavior. The RevenueStreamWidget.vue component reflects this organization, thus making it the perfect choice for our exercise.
This widget fetches and displays revenue chart data, offering functionalities such as refreshing the chart, showing loading skeletons, and displaying total revenue. Here’s the component defined using the Options API:
<script>
import useLayoutMixin from '@/layout/mixins/layout';
import useChartMixin from '@/views/mixins/chart';
import { Skeleton } from 'primevue';
export default {
components: { Skeleton },
mixins: [useLayoutMixin, useChartMixin],
props: {
title: {
type: String,
default: 'Revenue Stream'
}
},
data() {
return {
loading: false,
error: null,
totalRevenue: null
};
},
computed: {
source() {
return [this.getPrimary, this.isDarkTheme];
},
formattedTotalRevenue() {
return `$${this.totalRevenue.toLocaleString()}`;
}
},
methods: {
async fetchData() {
try {
this.loading = true;
this.error = null;
await new Promise((resolve) => setTimeout(resolve, 1000));
this.chartData = this.setChartData();
this.chartOptions = this.setChartOptions();
this.calculateTotalRevenue();
} catch (err) {
this.error = 'Failed to load chart data.';
} finally {
this.loading = false;
}
},
refreshChartData() {
this.fetchData();
}
},
mounted() {
this.fetchData();
},
watch: {
source(newSource, oldSource) {
this.fetchData();
}
}
};
</script>
<template>
<div class="card">
<div class="flex justify-between items-center mb-6">
<div class="font-semibold text-xl">{{ title }}</div>
<button :disabled="loading" @click.prevent="fetchData" class="w-12 h-12 flex items-center justify-center bg-blue-100 dark:bg-blue-400/10 rounded-full mr-4 shrink-0">
<i class="pi pi-undo !text-xl text-gray-500"></i>
</button>
</div>
<template v-if="chartData && !loading">
<Chart type="bar" :data="chartData" :options="chartOptions" class="h-80" />
<div class="text-right mt-4 font-semibold">Total Revenue: {{ formattedTotalRevenue }}</div>
</template>
<div v-else-if="loading" class="text-gray-500 flex justify-between gap-2">
<Skeleton v-for="n in 12" height="22rem" width="2rem" class="mb-2"></Skeleton>
</div>
</div>
</template>
We will now break down this component and gradually migrate it to the Composition API. We’ll walk through each section step-by-step, highlighting the differences and benefits introduced by the Composition API. To follow along with this process, remember to set up the project properly by following the README.
Migrating a Vue component from the Options API to the Composition API can seem challenging at first, but breaking it into smaller steps makes the process manageable. Let’s walk through the migration of the RevenueStreamWidget.vue component step by step.
First, identify the core structure of the component. With the Options API, everything is neatly organized into sections like data, computed, methods, and lifecycle hooks. When migrating to the Composition API, you’ll consolidate this logic into the setup function, where related pieces of functionality are grouped.
<script setup>
import { Skeleton } from 'primevue';
import { computed, onMounted, ref, watch } from 'vue';
import { useLayout } from '@/layout/composables/layout';
import { useChart } from '@/views/composables/chart';
</script>
In the Options API, props are declared in a dedicated props object. With the Composition API, they are defined using defineProps inside the <script setup> block.
const { title = 'Revenue Stream' } = defineProps({
title: String
});
This approach makes props destructuring and default values more concise.
In the Options API, the data function returns an object to define component state. In the Composition API, state is explicitly declared using ref or reactive.
const loading = ref(false);
const error = ref(null);
const totalRevenue = ref(null);
Each state variable is wrapped in ref, making them reactive and exposing their values with .value.
In the Composition API, computed properties are declared using the computed function:
const formattedTotalRevenue = computed(() => `$${totalRevenue.value.toLocaleString()}`);
Notice how totalRevenue.value is used because ref values require .value to access their contents.
The mounted lifecycle hook in the Options API is replaced by onMounted in the Composition API. This hook executes when the component is mounted.
onMounted(() => fetchData());
Methods in the Options API are defined under a methods object. In the Composition API, they are simply standalone functions:
async function fetchData() {
try {
loading.value = true;
error.value = null;
await new Promise((resolve) => setTimeout(resolve, 1000));
chartData.value = setChartData();
chartOptions.value = setChartOptions();
const datasets = chartData.value?.datasets || [];
totalRevenue.value = calculateTotalRevenue(datasets);
} catch (err) {
error.value = 'Failed to load chart data.';
} finally {
loading.value = false;
}
}
This structure allows for better modularity and reuse, as functions can easily be extracted into composables.
The watch API in the Composition API replaces the watch option in the Options API. While in the Options API, we needed to define a computed function that uses the properties we wanted to watch.
computed: {
source() {
return [this.getPrimary, this.isDarkTheme];
}
},
watch: {
source(newSource, oldSource) {
this.fetchData();
}
}
In the Composition API, watching multiple sources becomes straightforward since the watch function takes as its first argument a property (or multiple properties using an array) to be watched.
watch([getPrimary, isDarkTheme], () => fetchData());
This is probably the trickiest of the steps to migrate a component from the Options API to the Composition API. When building front-end applications, we often need to reuse logic for common tasks. In our code, we need to control the layout in many places, so we extract a couple of reusable functions for that. In Vue.js, a way to achieve this was using mixins, which allows us to extract component logic into reusable units.
Inside the RevenueStreamWidget.vue component, we’re using two mixins, the layout and the chart.
// mixins/chart.js
export default {
data() {
return {
chartData: null,
chartOptions: null
};
},
methods: {
setChartData() {
const documentStyle = getComputedStyle(document.documentElement);
return {
labels: ['Q1', 'Q2', 'Q3', 'Q4'],
datasets: [
...
]
};
},
setChartOptions() {
const documentStyle = getComputedStyle(document.documentElement);
const borderColor = documentStyle.getPropertyValue('--surface-border');
const textMutedColor = documentStyle.getPropertyValue('--text-color-secondary');
return {
...
};
},
calculateTotalRevenue() {
const datasets = this.chartData?.datasets || [];
this.totalRevenue = datasets.reduce((total, dataset) => {
return total + dataset.data.reduce((sum, value) => sum + value, 0);
}, 0);
}
}
};
// mixins/layout.js
import { reactive } from 'vue';
import { updatePreset } from '@primevue/themes';
export const sharedLayoutState = reactive({
layoutConfig: {
primary: 'emerald',
darkTheme: false
},
colors: [
{
name: 'sky',
palette: { 50: '#f0f9ff', 100: '#e0f2fe', 200: '#bae6fd', 300: '#7dd3fc', 400: '#38bdf8', 500: '#0ea5e9', 600: '#0284c7', 700: '#0369a1', 800: '#075985', 900: '#0c4a6e', 950: '#082f49' }
},
...
]
});
export default {
computed: {
isDarkTheme() {
return sharedLayoutState.darkTheme;
},
getPrimary() {
return sharedLayoutState.primary;
}
},
methods: {
toggleDarkMode() {
if (!document.startViewTransition) {
this.executeDarkModeToggle();
return;
}
document.startViewTransition(() => this.executeDarkModeToggle());
},
executeDarkModeToggle() {
sharedLayoutState.darkTheme = !sharedLayoutState.darkTheme;
document.documentElement.classList.toggle('app-dark');
},
updateColors() {
sharedLayoutState.primary = sharedLayoutState.primary === 'sky' ? 'emerald' : 'sky';
updatePreset(this.getPresetExt());
},
getPresetExt() {
const color = sharedLayoutState.colors.find((c) => c.name === sharedLayoutState.primary);
return {
...
};
}
}
};
As you can see from the above, the mixin is just an object that wraps related logic in a similar way components are created in the Options API. In this case, our layout mixin defines the methods and computed properties. Then, we import this object in RevenueStreamWidget.vue and we provide it under the mixins property.
export default {
mixins: [useLayoutMixin, useChartMixin],
...
}
In that sense, mixins can be problematic because it’s unclear which options are available in a component, or what’s accessible on the component instance (this). For example, someone could define a key in the data option that matches a key in a mixin, causing unexpected behavior.
Now, let’s see how things change using composables.
// composables/chart.js
export function useChart() {
function setChartData() {
const documentStyle = getComputedStyle(document.documentElement);
return {
labels: ['Q1', 'Q2', 'Q3', 'Q4'],
datasets: [
...
]
};
}
function setChartOptions() {
const documentStyle = getComputedStyle(document.documentElement);
const borderColor = documentStyle.getPropertyValue('--surface-border');
const textMutedColor = documentStyle.getPropertyValue('--text-color-secondary');
return {
...
};
}
function calculateTotalRevenue(datasets) {
return datasets.reduce((total, dataset) => {
return total + dataset.data.reduce((sum, value) => sum + value, 0);
}, 0);
}
return { setChartData, setChartOptions, calculateTotalRevenue };
}
As you can see, the composition API lets you organize your component code into smaller functions based on logical concerns. Now it’s easier to trace the implementation and understand the component’s behavior.
// composables/layout.js
import { computed, reactive, ref } from 'vue';
import { updatePreset } from '@primevue/themes';
const colors = ref(...);
const layoutConfig = reactive({
primary: 'emerald',
darkTheme: false
});
function getPresetExt() {
const color = colors.value.find((c) => c.name === layoutConfig.primary);
return {
semantic: {
primary: color.palette,
colorScheme: {
...
}
}
};
}
export function useLayout() {
const toggleDarkMode = () => {
if (!document.startViewTransition) {
executeDarkModeToggle();
return;
}
document.startViewTransition(() => executeDarkModeToggle(event));
};
const executeDarkModeToggle = () => {
layoutConfig.darkTheme = !layoutConfig.darkTheme;
document.documentElement.classList.toggle('app-dark');
};
const updateColors = () => {
layoutConfig.primary = layoutConfig.primary === 'sky' ? 'emerald' : 'sky';
updatePreset(getPresetExt());
};
const isDarkTheme = computed(() => layoutConfig.darkTheme);
const getPrimary = computed(() => layoutConfig.primary);
return {
layoutConfig,
isDarkTheme,
getPrimary,
toggleDarkMode,
updateColors
};
}
Then, we can explicitly use these functions inside the components, solving the inconvenience previously mentioned regarding mixins.
const chartData = ref(null);
const chartOptions = ref(null);
const { getPrimary, isDarkTheme } = useLayout();
const { setChartOptions, setChartData, calculateTotalRevenue } = useChart();
Congrats on making it this far! With all the pieces of information in place, we’ve completed the migration from the Options API to the Composition API. Now, what happens to the template? Fortunately, templates don’t need to change when moving from one API to another.
Let’s take a look at our transformed component.
<script setup>
import { Skeleton } from 'primevue';
import { computed, onMounted, ref, watch } from 'vue';
import { useLayout } from '@/layout/composables/layout';
import { useChart } from '@/views/composables/chart';
onMounted(() => fetchData());
const { title = 'Revenue Stream' } = defineProps({
title: String
});
const loading = ref(false);
const error = ref(null);
const totalRevenue = ref(null);
const chartData = ref(null);
const chartOptions = ref(null);
const formattedTotalRevenue = computed(() => `$${totalRevenue.value.toLocaleString()}`);
const { getPrimary, isDarkTheme } = useLayout();
const { setChartOptions, setChartData, calculateTotalRevenue } = useChart();
async function fetchData() {
try {
loading.value = true;
error.value = null;
await new Promise((resolve) => setTimeout(resolve, 1000));
chartData.value = setChartData();
chartOptions.value = setChartOptions();
const datasets = chartData.value?.datasets || [];
totalRevenue.value = calculateTotalRevenue(datasets);
} catch (err) {
error.value = 'Failed to load chart data.';
} finally {
loading.value = false;
}
}
watch([getPrimary, isDarkTheme], () => fetchData());
</script>
<template>
<div class="card">
<div class="flex justify-between items-center mb-6">
<div class="font-semibold text-xl">{{ title }}</div>
<button :disabled="loading" @click.prevent="fetchData" class="w-12 h-12 flex items-center justify-center bg-blue-100 dark:bg-blue-400/10 rounded-full mr-4 shrink-0">
<i class="pi pi-undo !text-xl text-gray-500"></i>
</button>
</div>
<template v-if="chartData && !loading">
<Chart type="bar" :data="chartData" :options="chartOptions" class="h-80" />
<div class="text-right mt-4 font-semibold">Total Revenue: {{ formattedTotalRevenue }}</div>
</template>
<div v-else-if="loading" class="text-gray-500 flex justify-between gap-2">
<Skeleton v-for="n in 12" height="22rem" width="2rem" class="mb-2"></Skeleton>
</div>
</div>
</template>
Migrating from the Options API to the Composition API involves more than just rewriting code. It’s about rethinking how you organize and structure your component logic. The effort can be worthwhile, especially when the result is a more modular, maintainable codebase, as we’ve seen in this post.
This process may seem like a tough task, but it’s manageable if you break it into smaller steps. First, analyze the component you’re working with, identifying its state, computed properties, methods, and lifecycle hooks. Then, translate these into the Composition API equivalents using ref, computed, and watch for reactivity, along with lifecycle functions like onMounted.
Now, someone might say that one API is better than the other, but that depends entirely on your needs. Both the Options API and the Composition API have their strengths, and Vue’s flexibility allows you to choose the one that works best for your situation or even combine them in the same project. On one hand, If you’re building a complex application or seeking advanced patterns for managing shared logic, the Composition API is a better fit. It excels in projects prioritizing modularity and scalability, especially with TypeScript.
On the other hand, if you’re working on a small, straightforward project or if your team is new to Vue, the Options API might be a better starting point. Its declarative structure keeps things organized and easy to follow, allowing you to focus on delivering features without worrying about reactivity details.
In addition, the good news is that The Options API is not obsolete, nor is it going away anytime soon. Vue 3 continues to fully support the Options API, and the Vue team has made it clear that the Composition API is an addition, not a replacement [3].
See you in the next one!