Upgrading a Vue 2 Options API Component to Vue 3 Composition API With TypeScript — The Mistakes Nobody Warns You About

this.$emit becomes defineEmits, this.$refs becomes useTemplateRef, mixins become composables, and filters are just gone — the complete migration guide for the specific patterns that break silently, compiled without errors, and only showed bugs in production three weeks later.


The official Vue 3 migration guide is thorough and well-written. It tells you what changed. What it doesn’t tell you is which of those changes compile without errors and silently produce wrong behaviour at runtime — the category that costs three debugging sessions before you find the pattern. This post is that list. Every item here is something that passed vue-tsc, passed unit tests written against the Options API component, and then exhibited wrong behaviour in production in a way that took time to trace back to the migration itself.

This isn’t a “here’s the new API” post. It’s a “here’s what looks right, compiles cleanly, and is wrong” post.


The this Problem Nobody Thinks About First

In the Options API, this is the component instance. It’s also the escape hatch, the dependency injection mechanism, and the implicit context for everything — data, methods, computed, refs, emit, router, store, plugins. In the Composition API with <script setup>, there is no this. There’s no component instance in scope during setup.

This sounds obvious. The non-obvious part is how many places this appeared in Options API code that didn’t look like this:

// Vue 2 Options API — this is implicit in all of these
export default {
  mounted() {
    this.$el.querySelector('.focus-target').focus()     // DOM access
    this.$refs.myInput.focus()                          // ref access
    this.$emit('ready')                                  // event emission
    this.$router.push('/dashboard')                     // navigation
    this.$store.dispatch('user/fetch')                  // store dispatch
    this.$nextTick(() => { /* ... */ })                 // next tick
    this.$watch('count', (val) => { /* ... */ })        // programmatic watch
  }
}

Every one of these has a replacement. None of the replacements are this.anything. The migration isn’t finding each this.$x and replacing it with the correct import — it’s internalizing that none of these are properties of the component anymore. They’re functions you import and call.


defineEmits — The Type You’re Probably Getting Wrong

The runtime syntax works. The type-based syntax is where the mistakes cluster.

// ❌ Runtime syntax — works, but TypeScript can't infer emit argument types
const emit = defineEmits(['update:modelValue', 'change', 'blur'])

// ✅ Type-based syntax — full type safety on every emit call
const emit = defineEmits<{
  'update:modelValue': [value: string]
  'change': [value: string, previousValue: string]
  'blur': []
}>()

With the runtime syntax, emit('update:modelValue', 42) compiles. The parent component expects a string. The bug is live.

With the type-based syntax (Vue 3.3+ named tuple syntax), emit('update:modelValue', 42) is a type error. TypeScript catches it before the test suite does.

The named tuple syntax deserves attention specifically. The older call signature syntax still works:

// Older call signature syntax — still valid, more verbose
const emit = defineEmits<{
  (e: 'update:modelValue', value: string): void
  (e: 'change', value: string, previousValue: string): void
  (e: 'blur'): void
}>()

// Named tuple syntax (Vue 3.3+) — shorter, more readable, same type safety
const emit = defineEmits<{
  'update:modelValue': [value: string]
  'change': [value: string, previousValue: string]
  'blur': []
}>()

Both are correct. The named tuple syntax is what Vue’s own source now uses.

The silent bug that appears in v-model migrations specifically:

// Vue 2 Options API — v-model emits 'input' with the new value
this.$emit('input', newValue)

// Vue 3 Composition API — v-model emits 'update:modelValue'
// ❌ This compiles and appears to work — v-model on the parent binds, but
//    doesn't update. The parent's model variable never changes.
emit('input', newValue) // wrong event name in Vue 3

// ✅ Correct
emit('update:modelValue', newValue)

The input event doesn’t error. It fires. The parent just doesn’t have a listener for it, so nothing happens. v-model looks like it’s bound, the input component renders correctly, and the parent value never updates. This was one of the three-week production bugs.


defineProps With TypeScript — The withDefaults Pattern

// ❌ Runtime object syntax — no TypeScript inference on props inside the component
const props = defineProps({
  modelValue: { type: String, required: true },
  placeholder: { type: String, default: 'Enter text...' },
  disabled: { type: Boolean, default: false },
})

// ✅ Type-based with withDefaults — full inference, no runtime validators needed
interface Props {
  modelValue: string
  placeholder?: string
  disabled?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  placeholder: 'Enter text...',
  disabled: false,
})

The type-based syntax gives you IDE autocompletion inside the component for props.modelValue — you get string, not string | undefined. The runtime syntax gives you any inside the component without additional configuration.

The specific mistake that bites: optional props in the interface without withDefaults are typed as T | undefined even when you’ve declared a default at the runtime level. TypeScript doesn’t see the runtime default — it only sees the interface. Use withDefaults so the TypeScript type matches the runtime behaviour.

// ❌ Optional without withDefaults — props.placeholder is string | undefined
//    even though the runtime default means it's always a string
interface Props {
  placeholder?: string
}
const props = defineProps<Props>()
// Inside the component: props.placeholder could be undefined (TypeScript says)
// At runtime: props.placeholder is always 'Enter text...'
// → TypeScript's model is wrong. Bugs follow.

// ✅ withDefaults makes the types accurate
const props = withDefaults(defineProps<Props>(), {
  placeholder: 'Enter text...',
})
// Now props.placeholder is string (not string | undefined). Accurate.

useTemplateRef — The Vue 3.5 Upgrade You Might Have Missed

Before Vue 3.5, template refs in <script setup> required declaring a ref with the exact same variable name as the ref attribute in the template:

// Vue 3.0–3.4 — ref variable name must match template ref string exactly
const input = ref<HTMLInputElement | null>(null)
// In template: <input ref="input" />
// If names diverge, the ref silently stays null

Vue 3.5 introduced useTemplateRef(), which decouples the variable name from the template ref string:

// Vue 3.5+ — useTemplateRef
import { useTemplateRef } from 'vue'

// Variable name and template ref string are independent
const emailInput = useTemplateRef<HTMLInputElement>('email-field')
// In template: <input ref="email-field" />

With @vue/language-tools 2.1+, the type of emailInput is automatically inferred from the element the ref attribute is used on. You don’t need the explicit <HTMLInputElement> generic — vue-tsc knows it’s an input.

The migration mistake: leaving the old pattern in place without the null guard:

// Vue 3 with old ref pattern — this is null before onMounted
const input = ref<HTMLInputElement | null>(null)

// ❌ Called outside onMounted or before it fires
function focusInput() {
  input.value.focus() // TypeError: Cannot read properties of null
}

// ✅ Always guard template refs
function focusInput() {
  input.value?.focus() // optional chaining — safe if called early
}

With useTemplateRef, the TypeScript type is still nullable before mount. The null guard requirement doesn’t go away — useTemplateRef just makes the naming safer. The optional chaining on input.value?.focus() is always correct.

For component refs — accessing a child component’s exposed methods:

// ✅ Accessing a child component's exposed interface via useTemplateRef
import type { InstanceType } from 'vue'
import ModalComponent from './ModalComponent.vue'

const modal = useTemplateRef<InstanceType<typeof ModalComponent>>('modal')

// ModalComponent must use defineExpose() for this to work
// If defineExpose() is absent, modal.value is an empty proxy — methods aren't there
function openModal() {
  modal.value?.open() // only works if ModalComponent calls defineExpose({ open })
}

The defineExpose requirement is the one most developers forget. In <script setup>, nothing is exposed to parent refs by default. A parent component that accesses childRef.value.someMethod() gets undefined unless the child explicitly exposes it.

<!-- ChildComponent.vue -->
<script setup lang="ts">
function open() { /* ... */ }
function close() { /* ... */ }

// Without this, parent refs get an empty proxy
defineExpose({ open, close })
</script>

reactive() vs ref() — The Destructuring Trap

This is the most common reactivity bug in migrated code and it compiles without warning.

// ❌ Destructuring a reactive object loses reactivity
const state = reactive({
  count: 0,
  name: 'Sadique',
  loading: false,
})

const { count, name } = state
// count and name are now plain numbers/strings — not reactive
// Changing state.count doesn't update count. Template doesn't update.
// ✅ Option 1: Always access through the reactive object
state.count++
// Template: {{ state.count }}

// ✅ Option 2: Use ref() for individual values — destructuring is safe
const count   = ref(0)
const name    = ref('Sadique')
const loading = ref(false)
// These are independent reactive refs — safe to use directly
// ✅ Option 3: Use toRefs() when you need to destructure a reactive object
import { toRefs } from 'vue'
const state = reactive({ count: 0, name: 'Sadique' })
const { count, name } = toRefs(state)
// count and name are now Ref<number> and Ref<string> — reactive
// count.value++ updates state.count and triggers template updates

The Pinia-specific version of this trap:

// ❌ Destructuring a Pinia store loses reactivity
const userStore = useUserStore()
const { userName, isLoggedIn } = userStore // plain values, not reactive

// ✅ Use storeToRefs() for reactive destructuring from Pinia
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const { userName, isLoggedIn } = storeToRefs(userStore)
// Actions can be destructured directly — they're regular functions
const { logout, fetchUser } = userStore

Mixins to Composables — The Pattern That Looks the Same But Isn’t

A Vue 2 mixin and a Vue 3 composable accomplish the same goal — sharing logic between components — but the mechanism is different in ways that produce different bugs.

// Vue 2 mixin — merges into the component instance
const TimestampMixin = {
  data() {
    return {
      lastUpdated: null,
    }
  },
  methods: {
    updateTimestamp() {
      this.lastUpdated = new Date()
    },
  },
}

export default {
  mixins: [TimestampMixin],
  methods: {
    save() {
      // ... save logic
      this.updateTimestamp() // works — merged into this
    }
  }
}
// Vue 3 composable — explicit return, explicit import
// useTimestamp.ts
import { ref } from 'vue'

export function useTimestamp() {
  const lastUpdated = ref<Date | null>(null)

  function updateTimestamp() {
    lastUpdated.value = new Date()
  }

  return { lastUpdated, updateTimestamp }
}

// Component
import { useTimestamp } from '@/composables/useTimestamp'

const { lastUpdated, updateTimestamp } = useTimestamp()

function save() {
  // ... save logic
  updateTimestamp() // explicit call, explicit origin
}

The structural difference: mixins merge silently, composables import explicitly. This changes the bugs. Mixin bugs are name collisions — two mixins define data.loading and one silently overwrites the other. Composable bugs are variable shadowing — you import loading from two composables with the same name.

// ❌ Composable name collision — both useAuth and useForm return loading
const { loading } = useAuth()
const { loading } = useForm()  // ← SyntaxError: Identifier 'loading' has already been declared

// ✅ Rename on destructure
const { loading: authLoading }  = useAuth()
const { loading: formLoading }  = useForm()

TypeScript catches this at compile time (duplicate declaration error). The mixin version of this — two mixins both declaring data.loading — didn’t error. One silently won. The composable version errors loudly. That’s the improvement.

The lifecycle hook difference:

// Vue 2 mixin lifecycle — merged with component lifecycle, both run
const LogMixin = {
  mounted() {
    console.log('LogMixin mounted')
  }
}
// Component's mounted() also runs — both fire

// Vue 3 composable lifecycle — works the same, but explicitly
function useLogging() {
  onMounted(() => {
    console.log('useLogging mounted')
  })
}
// Call useLogging() in setup — its onMounted runs alongside the component's own onMounted

The key difference is that a composable’s onMounted only runs when the composable is called inside setup() (or <script setup>). If a composable is instantiated conditionally or outside setup, its lifecycle hooks don’t register. Mixins always ran regardless.


Filters Are Gone — And the Wrong Replacement Breaks Reactivity

Vue 2 filters were removed in Vue 3. There is no compatibility layer. Every {{ value | formatCurrency }} in every template needs a replacement.

The two correct replacements, and the wrong one:

// Vue 2 filter
filters: {
  formatCurrency(value: number): string {
    return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
      .format(value)
  }
}
// Template: {{ price | formatCurrency }}

// ✅ Option 1: Computed property (when the value is reactive state)
const formattedPrice = computed(() =>
  new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
    .format(price.value)
)
// Template: {{ formattedPrice }}

// ✅ Option 2: Plain function imported and called in the template
// utils/formatters.ts
export function formatCurrency(value: number): string {
  return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
    .format(value)
}
// In <script setup>: import { formatCurrency } from '@/utils/formatters'
// Template: {{ formatCurrency(price) }}

// ❌ Option 3: Method on the component — works but doesn't scale
methods: {
  formatCurrency(value) { /* ... */ }
}
// Fine for one-off cases; wrong when the formatter is shared across ten components

The reactivity trap with filter replacements: a plain function called in the template is re-called on every render, but it doesn’t trigger renders on its own. A computed property derived from a reactive value triggers a re-render when the value changes. The distinction matters when the formatted value needs to update when underlying data changes.

// ❌ This won't update the display when price.value changes
function formattedPrice() {
  return formatCurrency(price.value)
}
// Template: {{ formattedPrice() }}
// This actually works — Vue tracks reactive dependencies inside template expressions
// But it re-runs on every render even when price hasn't changed

// ✅ This caches and updates only when price.value changes
const formattedPrice = computed(() => formatCurrency(price.value))
// Template: {{ formattedPrice }}

The functional approach in the template re-runs every render. The computed approach runs only when dependencies change. For format functions that are pure and cheap (like number formatting), the difference is imperceptible. For format functions that do heavier work (markdown rendering, complex date localization), the computed approach is meaningfully faster.


watch — The Immediate and Deep Gotchas

Vue 2’s watch with immediate: true ran the handler before the component mounted. Vue 3’s watch behaves the same way, but the default for deep changed and the timing of immediate relative to component lifecycle works differently in ways that matter for data fetching.

// ❌ Shallow watch on an object — doesn't fire when nested properties change
const user = ref({ name: 'Sadique', address: { city: 'Delhi' } })

watch(user, (newUser) => {
  // This only fires when user.value itself is replaced, not when user.value.address.city changes
  saveUser(newUser)
})

// ✅ Deep watch — fires on any nested change
watch(user, (newUser) => {
  saveUser(newUser)
}, { deep: true })

// ✅ Better: watch the specific nested property
watch(() => user.value.address.city, (newCity) => {
  updateCity(newCity)
})
// The watchEffect alternative — tracks dependencies automatically
watchEffect(() => {
  // Runs immediately on setup, and again when any reactive dependency accessed here changes
  // No need for { immediate: true } — it's implicit
  console.log('User city:', user.value.address.city)
})

The immediate: true timing issue with data fetching:

// ❌ Fetching with immediate watch — fires before onMounted, before DOM is ready
watch(userId, async (id) => {
  user.value = await fetchUser(id)
}, { immediate: true })
// This works for data fetching, but runs before the component is mounted
// Accessing template refs inside this handler will find them null

// ✅ For initial data fetching, use onMounted
onMounted(async () => {
  user.value = await fetchUser(props.userId)
})

// Then watch for subsequent changes
watch(() => props.userId, async (id) => {
  user.value = await fetchUser(id)
})

$nextTick — The Import Nobody Remembers

// Vue 2
this.$nextTick(() => {
  this.$refs.input.focus()
})

// Vue 3 — nextTick is imported, not a component method
import { nextTick } from 'vue'

await nextTick()
emailInput.value?.focus()
// Or with a callback
nextTick(() => {
  emailInput.value?.focus()
})

The version that silently does nothing:

// ❌ this.$nextTick doesn't exist in <script setup> — no error, just undefined
// In a migrated component where 'this' somehow resolves...
// this.$nextTick would be undefined and the callback never fires

In <script setup>, there is no this at all. Calling this.$nextTick would be a ReferenceError (this is undefined in a module). The migration error that actually happens is pasting Options API code into setup() where this is undefined and not all callsites are caught by TypeScript if the type of this isn’t enforced.


Vue Router and Vuex/Pinia — No More this.$router and this.$store

// Vue 2 — accessed via this
this.$router.push('/dashboard')
this.$route.params.id
this.$store.state.user.name
this.$store.dispatch('user/fetch', id)
this.$store.commit('user/setName', name)

// Vue 3 Composition API — imported composables
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'

const router = useRouter()
const route  = useRoute()
const userStore = useUserStore()

// Navigation
router.push('/dashboard')
// Route params
const id = route.params.id as string
// Store
const name = userStore.name
await userStore.fetchUser(id)
userStore.setName(name)

The migration mistake that compiles: useRoute() returns a reactive route object, but route params are strings in Vue Router 4, not typed based on your route definition. Casting route.params.id as string without validating is a bug waiting for a route that doesn’t have that param.

// ✅ Validate route params
const id = computed(() => {
  const rawId = route.params.id
  if (!rawId || Array.isArray(rawId)) {
    throw new Error('Invalid route: missing or malformed id param')
  }
  return rawId
})

The Migration Order That Minimizes Broken Time

Migrating a complex component all at once maximizes the number of things that can go wrong simultaneously. The order that produces the fewest debugging sessions:

1. Add <script setup lang="ts"> alongside or replacing the Options API block
   → Start with props and emits — defineProps and defineEmits first

2. Migrate data() to ref() / reactive() declarations
   → Don't touch the template yet — Options API and Composition API
     can coexist temporarily with the defineComponent wrapper

3. Migrate computed properties — one at a time, verify each
   → Computed with TypeScript inference requires explicit return types
     only when the inferred type isn't what you want

4. Migrate methods — rename, remove 'this.', verify template bindings
   → Template refs: switch to useTemplateRef() and add null guards

5. Migrate lifecycle hooks
   → mounted → onMounted, etc.
   → Watch for any $nextTick calls inside hooks

6. Migrate watchers
   → Immediate watchers: consider whether watchEffect is cleaner
   → Add { deep: true } explicitly where deep watching was implicit

7. Replace filters with computed or imported functions

8. Replace mixins with composable imports
   → Rename on destructure where names conflict

9. Run vue-tsc — fix type errors before testing
   → TypeScript errors surface the real structural problems

10. Run the test suite — expect failures where behaviour changed
    → $emit event name changes (input → update:modelValue) surface here

This order keeps the component functional after each step. Migrating props before data before computed means you always have a working component to test against, even if it’s using a mix of old and new patterns temporarily.


The Class of Bugs That vue-tsc Won’t Catch

After a migration, vue-tsc --noEmit with zero errors is a good sign. It’s not a guarantee. The category of bugs vue-tsc doesn’t catch:

Runtime behaviour that compiles cleanly:
→ Wrong emit event name (input vs update:modelValue)
→ Reactive object destructured without toRefs — reads work, writes don't update
→ Template ref accessed before onMounted — null at call time
→ defineExpose missing — parent ref gets empty proxy
→ watch without { deep: true } on a nested object — misses property changes
→ Pinia store destructured without storeToRefs — loses reactivity
→ Filter replaced with a method instead of a composable — doesn't scale
→ nextTick called as this.$nextTick — silently undefined in setup()

Every item in that list compiles. Every item in that list has caused a production bug during a Vue 2 → 3 migration. The test suite is the only tool that catches them before production does, and only if the test suite exercises reactive updates, not just rendering.

Leave a Reply

Your email address will not be published. Required fields are marked *