frontend / vue / 05_composition_vs_options.md

Composition API vs Options API (and Migration)

6 min read source

Composition API vs Options API (and Migration)

TL;DR

Options API: state in data(), methods in methods, computeds in computed, watchers in watch, lifecycle in mounted/updated/etc. Vue 2’s default; still supported in Vue 3. Composition API: everything in a setup function (or <script setup>) using ref, computed, watch, onMounted. Better for reuse via composables and TypeScript — the modern default. The two coexist; you can use either or both in the same project. Migration is incremental, file-by-file.

Interview Q&A

Q: Show the same component in both styles.

A:

Options API:

<script>
export default {
  data() {
    return { count: 0 };
  },
  computed: {
    double() { return this.count * 2; },
  },
  methods: {
    increment() { this.count++; },
  },
  mounted() {
    console.log("mounted");
  },
};
</script>

<template>
  <button @click="increment">{{ count }} (×2 = {{ double }})</button>
</template>

Composition API (<script setup>):

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

const count = ref(0);
const double = computed(() => count.value * 2);
const increment = () => { count.value++; };

onMounted(() => console.log("mounted"));
</script>

<template>
  <button @click="increment">{{ count }} (×2 = {{ double }})</button>
</template>

Same component; different style. <script setup> is the most concise — no boilerplate, top-level bindings are automatically exposed to the template.

Q: Why was the Composition API added?

A: Three concrete problems with the Options API at scale:

  1. Logic reuse is awkward. Mixins (Vue 2’s mechanism) have name collisions, opaque sources, and don’t compose. Higher-order components are clunky. Renderless components don’t fit all patterns.
  2. Related logic is scattered across options. A single feature might span data, computed, methods, mounted, watch — five separate blocks. In a large component, related concerns are split apart.
  3. TypeScript inference is limited by the Options API’s structure — this typing is complex, options are weakly typed.

Composition API solves these:

  1. Composables are just functions returning refs/methods — compose, no collisions, explicit sources.
  2. Related logic stays together in setup.
  3. Native TS — no special inference; functions and refs type naturally.

Q: What’s <script setup>?

A: A compile-time shorthand. The Composition API’s setup() is implicitly the whole script block; top-level bindings are auto-exposed to the template; macros like defineProps / defineEmits / defineExpose / defineOptions are compiler-handled (don’t need imports).

<script setup>
import { ref } from "vue";

const props = defineProps<{ title: string }>();
const emit  = defineEmits<{ submit: [value: string] }>();
defineExpose({ focus });           // expose methods to parent ref

const value = ref("");
function focus() { /* ... */ }
</script>

Without <script setup>, you’d write:

<script>
import { ref, defineComponent } from "vue";
export default defineComponent({
  props: { title: { type: String, required: true } },
  emits: ["submit"],
  setup(props, { emit, expose }) {
    const value = ref("");
    function focus() {}
    expose({ focus });
    return { value };
  },
});
</script>

<script setup> is the modern default. Almost no one writes raw setup() in new code.

Q: Can you use both APIs in the same project? Same component?

A: Both APIs in the same project: yes — file by file. Same component: technically yes (you can have a setup() and options like methods), but it’s confusing — pick one per component.

Q: How do composables compare to mixins?

A:

// composable — explicit, composable, TS-friendly
export function useMouse() {
  const x = ref(0), y = ref(0);
  const update = (e: MouseEvent) => { x.value = e.x; y.value = e.y; };
  onMounted(() => window.addEventListener("mousemove", update));
  onUnmounted(() => window.removeEventListener("mousemove", update));
  return { x, y };
}

// consumer
const { x, y } = useMouse();

vs Vue 2 mixin:

const MouseMixin = {
  data() { return { x: 0, y: 0 }; },
  mounted() { window.addEventListener("mousemove", this.update); },
  methods: { update(e) { this.x = e.x; this.y = e.y; } },
};

// consumer
export default { mixins: [MouseMixin] };
// this.x and this.y exist — but where from? Conflicts? Unclear.

Composables win on: explicit source, no naming conflicts, composable (one composable calls another), TS-typed, easy testing.

Q: When does Options API still make sense?

A:

  • Existing Vue 2 codebases — don’t rewrite for the sake of it.
  • Junior-heavy teams that find Options API’s structure easier to navigate.
  • Strict consistency — if the rest of the project is Options API, match it.

But: new Vue 3 projects almost universally start with <script setup> + Composition API.

Q: Migration path Vue 2 → Vue 3 with Composition API?

A:

  1. Upgrade to Vue 3 with Options API kept. Vue 3 supports the Options API fully — most components work as-is with minor changes (see 06_lifecycle_hooks.md for lifecycle rename: beforeDestroybeforeUnmount).
  2. Fix the Vue 3 breaking changes:
    • Global API moved (new Vue()createApp()).
    • v-model defaults changed (now uses modelValue/update:modelValue).
    • Filters removed.
    • Functional components are now plain functions.
    • $listeners merged into $attrs.
  3. Refactor to Composition API incrementally — leaf components first, then containers. Composables for shared logic emerge naturally.
  4. Convert SFCs to <script setup> as a cosmetic last step.

There’s no big-bang rewrite. Many production Vue 3 apps mix both styles for years.

Q: TypeScript ergonomics — what’s different?

A:

// Options API — props typing via runtime declaration + PropType
export default defineComponent({
  props: {
    user: { type: Object as PropType<User>, required: true },
    onSave: { type: Function as PropType<(u: User) => void> },
  },
});

// Composition API — pure TS, no runtime cast
<script setup lang="ts">
const props = defineProps<{
  user: User;
  onSave?: (u: User) => void;
}>();
</script>

Composition API with <script setup lang="ts"> is dramatically cleaner — pure type declarations, no runtime cast gymnastics.

Gotchas / edge cases

  • this doesn’t exist in setup() — there’s no component instance. Use the function parameter (setup(props, ctx)) for emit/attrs/slots/expose.
  • Top-level await in <script setup> turns the component into an async component (requires <Suspense> upstream).
  • Composables must be called synchronously during setup — not inside conditionals, async callbacks, or after await. Same constraint as React hooks; same reason (call order forms the “identity” for lifecycle binding).
  • Auto-imports (via unplugin-auto-import) are common in Vue 3 projects — ref, computed, etc. become globally available. Convenient but obscures origins; team convention call.
  • defineProps / defineEmits are compiler macros, not imports. Don’t try to assign them to variables or use them dynamically.
  • reactive props don’t exist — props are always read-only. To make a prop reactive locally, copy via ref(props.x) and watch to sync.

What a senior is expected to say

  • “Composition API + <script setup> is the modern default. Logic stays together, composables make reuse first-class, TypeScript inference is native — none of which Mixins solved.”
  • “Options API is still fully supported in Vue 3. Migration is incremental and file-by-file; there’s no rewrite mandate.”
  • <script setup> is the compile-time shorthand — top-level bindings auto-exposed, defineProps/defineEmits are macros, the whole script is the setup function.”
  • “Composables are plain functions returning refs and methods. They’re testable, typeable, and don’t have the name-collision problem mixins had.”
  • “Props are always read-only; need a reactive local copy, you ref(props.x) and sync with watch.”

Cross-references

Further reading