Vue 3 Script Setup and defineModel in 2026: Modern Syntax and Interview Questions

Master Vue 3 script setup syntax and defineModel for two-way binding. Learn reactive props destructure, TypeScript patterns, and prepare for Vue interview questions.

Vue 3 Script Setup and defineModel syntax diagram

Vue 3 script setup syntax transforms how components are written by eliminating boilerplate and improving TypeScript integration. Combined with defineModel, introduced in Vue 3.3 as experimental and stabilized in Vue 3.4, building components with two-way data binding requires far less code than the Options API equivalent.

Vue 3.5 Reactive Props Destructure

Starting in Vue 3.5, props can be destructured directly from defineProps while preserving reactivity. This eliminates the need for toRefs in most cases.

Script Setup Fundamentals in Vue 3.5

The <script setup> syntax is compile-time sugar for the Composition API inside Single File Components. Variables declared at the top level are automatically exposed to the template, and imports are directly usable without registration.

vue
<!-- UserCard.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { User } from '@/types'

// Props with TypeScript interface
const props = defineProps<{
  user: User
  showEmail?: boolean
}>()

// Reactive state
const isExpanded = ref(false)

// Computed property
const displayName = computed(() => {
  return `${props.user.firstName} ${props.user.lastName}`
})

// Event emitter with typed payload
const emit = defineEmits<{
  select: [userId: string]
  delete: [userId: string]
}>()

// Methods are plain functions
function handleSelect() {
  emit('select', props.user.id)
}
</script>

<template>
  <div class="user-card" @click="handleSelect">
    <h3>{{ displayName }}</h3>
    <p v-if="showEmail">{{ user.email }}</p>
    <button @click.stop="isExpanded = !isExpanded">
      {{ isExpanded ? 'Collapse' : 'Expand' }}
    </button>
  </div>
</template>

The compiler handles export statements and component registration automatically. This reduces the cognitive load compared to explicitly returning values from a setup() function.

Reactive Props Destructure (Vue 3.5+)

Before Vue 3.5, destructuring props broke reactivity because JavaScript destructuring creates a snapshot of the value at that moment. Vue 3.5 introduces compiler-level tracking that preserves reactivity for destructured props.

vue
<!-- SearchInput.vue -->
<script setup lang="ts">
import { watch } from 'vue'

// Destructure with defaults - stays reactive in 3.5+
const { query = '', placeholder = 'Search...' } = defineProps<{
  query?: string
  placeholder?: string
}>()

// Watch works on destructured props
watch(
  () => query,
  (newQuery) => {
    console.log('Query changed:', newQuery)
  }
)
</script>

<template>
  <input
    :value="query"
    :placeholder="placeholder"
    @input="$emit('update:query', ($event.target as HTMLInputElement).value)"
  />
</template>

This syntax requires Vue 3.5 or later. The Vue 3.5 release announcement documents the complete list of reactive props destructure behavior.

defineModel for Two-Way Binding

Before defineModel, implementing v-model on a custom component required declaring a prop and emitting an update event manually. This pattern appears in countless Vue codebases:

vue
<!-- The old way - verbose but explicit -->
<script setup lang="ts">
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()

function updateValue(event: Event) {
  emit('update:modelValue', (event.target as HTMLInputElement).value)
}
</script>

With defineModel, the same component becomes significantly shorter:

vue
<!-- TextInput.vue -->
<script setup lang="ts">
// defineModel creates a ref that syncs with v-model
const model = defineModel<string>({ default: '' })
</script>

<template>
  <input
    v-model="model"
    type="text"
    class="text-input"
  />
</template>

The parent component uses standard v-model syntax:

vue
<!-- ParentForm.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import TextInput from './TextInput.vue'

const username = ref('')
</script>

<template>
  <TextInput v-model="username" />
  <p>Username: {{ username }}</p>
</template>

defineModel returns a ref that stays synchronized with the parent's bound value. Mutations to this ref automatically emit the update event.

Ready to ace your Vue.js / Nuxt.js interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Named Models and Multiple v-model Bindings

Components sometimes need multiple two-way bindings. Vue supports named v-model bindings, and defineModel handles them by accepting a name argument.

vue
<!-- DateRangePicker.vue -->
<script setup lang="ts">
// Named models for multiple v-model bindings
const startDate = defineModel<Date>('start', { required: true })
const endDate = defineModel<Date>('end', { required: true })

// Validation computed
const isValidRange = computed(() => {
  if (!startDate.value || !endDate.value) return true
  return startDate.value <= endDate.value
})
</script>

<template>
  <div class="date-range-picker">
    <input
      type="date"
      :value="startDate?.toISOString().split('T')[0]"
      @input="startDate = new Date(($event.target as HTMLInputElement).value)"
    />
    <span>to</span>
    <input
      type="date"
      :value="endDate?.toISOString().split('T')[0]"
      @input="endDate = new Date(($event.target as HTMLInputElement).value)"
    />
    <p v-if="!isValidRange" class="error">End date must be after start date</p>
  </div>
</template>

The parent binds both models:

vue
<DateRangePicker v-model:start="filterStart" v-model:end="filterEnd" />

v-model Modifiers with defineModel

Vue's built-in modifiers like .trim, .number, and .lazy work automatically with defineModel. Custom modifiers require explicit handling through the second element of the returned tuple.

vue
<!-- CurrencyInput.vue -->
<script setup lang="ts">
// Access modifiers via the second return value
const [model, modifiers] = defineModel<number>({
  default: 0,
  // Transform functions for custom modifiers
  set(value) {
    // Round to 2 decimal places if 'round' modifier is present
    if (modifiers.round) {
      return Math.round(value * 100) / 100
    }
    return value
  }
})
</script>

<template>
  <div class="currency-input">
    <span>$</span>
    <input
      type="number"
      step="0.01"
      :value="model"
      @input="model = parseFloat(($event.target as HTMLInputElement).value) || 0"
    />
  </div>
</template>

Usage with the custom modifier:

vue
<CurrencyInput v-model.round="price" />

TypeScript Patterns for Script Setup

Type safety in <script setup> components relies on generics passed to compiler macros. The pattern varies slightly between props, emits, and models.

vue
<!-- GenericList.vue -->
<script setup lang="ts" generic="T extends { id: string }">
import { computed } from 'vue'

// Generic component props
const props = defineProps<{
  items: T[]
  selected?: T
}>()

const emit = defineEmits<{
  select: [item: T]
  remove: [id: string]
}>()

// Slots can be typed too
defineSlots<{
  default: (props: { item: T; index: number }) => any
  empty: () => any
}>()

const hasItems = computed(() => props.items.length > 0)
</script>

<template>
  <ul v-if="hasItems">
    <li
      v-for="(item, index) in items"
      :key="item.id"
      :class="{ selected: selected?.id === item.id }"
      @click="emit('select', item)"
    >
      <slot :item="item" :index="index">
        {{ item.id }}
      </slot>
    </li>
  </ul>
  <div v-else>
    <slot name="empty">No items</slot>
  </div>
</template>

Generic components, available since Vue 3.3, enable reusable typed components without sacrificing type inference in the consuming code. For deeper patterns, the Vue documentation on TypeScript covers edge cases.

Common Interview Questions on Script Setup

Interviewers testing Vue knowledge often focus on the differences between script setup and the Options API, as well as the mechanics of reactivity.

Question: Why does destructuring props break reactivity, and how does Vue 3.5 solve this?

In JavaScript, destructuring creates new variables holding the values at destructure time. If props.count changes later, a destructured const { count } = props still holds the old value. Vue 3.5's compiler transforms the destructured variables into getters that re-evaluate when accessed, preserving reactivity without runtime overhead.

Question: What happens under the hood when defineModel is used?

The compiler expands defineModel into a prop declaration and an emit registration. At runtime, it creates a ref whose getter reads the prop value and whose setter calls emit('update:modelValue', newValue). This ref is not a true copy of the prop but a proxy that forwards reads and writes appropriately.

Question: When should a component use script setup versus a regular setup function?

Script setup is preferred for most single-file components because it reduces boilerplate and improves IDE support. A regular setup() function is necessary when the component needs to expose methods to parent components via defineExpose in complex patterns, or when building renderless components that return render functions directly. For interview preparation, explore more Vue composables patterns.

Migrating from Options API to Script Setup

Existing codebases often contain Options API components that benefit from migration. The conversion follows a predictable pattern:

vue
<!-- Before: Options API -->
<script lang="ts">
import { defineComponent, PropType } from 'vue'

export default defineComponent({
  props: {
    items: { type: Array as PropType<string[]>, required: true }
  },
  emits: ['select'],
  data() {
    return { searchTerm: '' }
  },
  computed: {
    filteredItems() {
      return this.items.filter(item =>
        item.toLowerCase().includes(this.searchTerm.toLowerCase())
      )
    }
  },
  methods: {
    selectItem(item: string) {
      this.$emit('select', item)
    }
  }
})
</script>
vue
<!-- After: Script Setup -->
<script setup lang="ts">
import { ref, computed } from 'vue'

const props = defineProps<{ items: string[] }>()
const emit = defineEmits<{ select: [item: string] }>()

const searchTerm = ref('')

const filteredItems = computed(() =>
  props.items.filter(item =>
    item.toLowerCase().includes(searchTerm.value.toLowerCase())
  )
)

function selectItem(item: string) {
  emit('select', item)
}
</script>

The migrated version is shorter and type inference works without PropType casts. The Vue team provides vue-codemod for automated migrations, though manual review remains necessary for complex components.

Performance Considerations in Script Setup

Script setup components compile to more efficient code because the compiler knows the exact shape of the component at build time. Specifically:

  • Template bindings resolve at compile time rather than runtime property lookup
  • Unused imports are tree-shaken more effectively
  • The lack of a wrapping function reduces closure overhead

For applications prioritizing bundle size, script setup with Vapor Mode (available in Vue 3.6+) eliminates the virtual DOM entirely for opted-in components, generating direct DOM operations instead.

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Key Takeaways for Vue 3 Script Setup and defineModel

  • Script setup reduces component boilerplate by automatically exposing top-level bindings to templates and handling component registration
  • defineModel replaces the manual prop + emit pattern for v-model, returning a ref that syncs with the parent's bound value
  • Vue 3.5 introduces reactive props destructure, allowing const { prop } = defineProps() without losing reactivity
  • Named models support multiple v-model bindings on a single component using defineModel('name')
  • TypeScript generics work with defineProps, defineEmits, defineSlots, and generic components via the generic attribute
  • Migration from Options API follows a mechanical pattern: data becomes refs, computed stays computed, methods become functions, and the compiler handles exports
Daily challenge

Can you spot the bug in Vue.js / Nuxt.js?

One real snippet, one hidden bug, one attempt a day. No account needed to try.

Anthony Fillion-Maillet

Written by

Anthony Fillion-Maillet

Founder of SharpSkill

Full-stack developer for over 10 years. Runs SharpSkill and answers for everything published here.

Updated on September 18, 2026

Tags

#vue
#vue3
#script-setup
#definemodel
#composition-api
#typescript

Share

Related articles