skip to content

When migrating a Vue 3 component from an explicit setup() function to <script setup>, what changes mechanically, and what typically breaks for its callers?

level: seniorimportance: should knowfreq 42%

answer

  1. the return object disappears
  2. context arguments become macros
  3. registration becomes imports
  4. parents lose access to internals

basics

~20 s

The setup body moves to the top level: the return object goes, props and emits become macros, components and directives become imports. What breaks is outside access: parents calling child methods through refs, and Options API code reading setup state.

solid answer

~40 s

Mechanically, the body of `setup(props, { emit, attrs, slots, expose })` moves to the top level of `<script setup>`: the `return { ... }` object disappears because top-level bindings reach the template; the `props` and `emits` options become `defineProps` and `defineEmits`; `attrs` and `slots` come from `useAttrs()` and `useSlots()` or `$attrs` and `$slots` in the template; the `components` and `directives` options become imports, with local directives renamed to `vName`; remaining options go to `defineOptions()` (3.3+). What breaks is what callers relied on: the component is **closed by default**, so a parent calling `childRef.value.open()` gets `undefined` until the child uses `defineExpose`; Options API code or mixins reading setup state through `this` stop seeing it; and option arguments that referenced setup variables now fail to compile.

code

ts · 20 lines
ts
// Before: explicit setup() in Dialog.vue's <script lang="ts">
import { defineComponent, ref } from 'vue'
import CloseButton from './CloseButton.vue'

export default defineComponent({
  components: { CloseButton },
  props: { title: String },
  emits: ['close'],
  setup(props, { emit }) {
    const visible = ref(false)
    function open() {
      visible.value = true
    }
    function close() {
      visible.value = false
      emit('close')
    }
    return { visible, open, close }
  },
})

go deeper

for a junior

Recall the basic mapping: the return object goes away, props and emits become defineProps and defineEmits, and imported components need no registration.

for a middle

Explain how attrs, slots, expose, directives and leftover options map to their script setup forms, including defineOptions in 3.3+.

for a senior

Audit the boundary before merging: parent template refs, $parent access, mixins using this, hoisted macro arguments and changed props-destructure behaviour.

for a principal

Plan an incremental migration of a large codebase: which components to convert first, how to catch broken ref callers, and when to replace exposed methods with props and events.

## Why teams migrate An explicit `setup()` function works, but it repeats every binding twice (declared, then returned), needs the `components` option to register what it imports, and gives TypeScript less to infer. `<script setup>` compiles to the same kind of `setup()` function while removing that boilerplate. A migration is mostly mechanical, and the few non-mechanical parts are exactly what an interviewer probes. ## The mechanical mapping | Explicit `setup()` component | `<script setup>` equivalent | |---|---| | `props: { ... }` option and `setup(props)` | `const props = defineProps({ ... })` | | `emits: [...]` and `setup(_, { emit })` | `const emit = defineEmits([...])` | | `setup(_, { attrs, slots })` | `useAttrs()`, `useSlots()`, or `$attrs` and `$slots` in the template | | `setup(_, { expose })` with `expose({ open })` | `defineExpose({ open })` | | `return { count, inc }` | nothing: top-level bindings are visible to the template | | `components: { UserCard }` | `import UserCard from './UserCard.vue'` | | `directives: { focus }` | a binding named `vFocus` | | `inheritAttrs`, `name`, custom options | `defineOptions({ ... })` in 3.3+, or a normal `<script>` | | `async setup()` | top-level `await` in the block | Steps in order: 1. Move the body of `setup()` to the top level and delete the `return` statement. 2. Replace the `props` and `emits` options with the macros, then delete the `export default` object. 3. Turn component and directive registrations into imports, renaming local directives to the `vName` form. 4. Move leftover options into `defineOptions()`. 5. Search every caller for template refs to this component and for `$parent` access. ## What breaks, and why - **Parent calls through template refs.** A component with an explicit `setup()` that returns bindings and never calls `expose()` has its returned bindings on its public instance, so a parent's `childRef.value.open()` worked. A `<script setup>` component is **closed by default**, so the same call now finds `undefined`. Either expose the method deliberately with `defineExpose`, or better, replace the imperative call with a prop or an event. - **Options API code in the same component.** Mixins, or a normal `<script>` with `methods` and hooks, can no longer read the state through `this`, because `<script setup>` variables are not added to the instance. - **Option arguments that referenced setup variables.** In `setup()` you could compute something and pass it around freely; macro arguments are hoisted to module scope, so a `defineProps` default that referenced a setup-scope variable becomes a compile error. Move the value into an import or compute it later. - **Destructured props.** Code that destructured `props` in `setup()` lost reactivity; in 3.5 destructuring the result of `defineProps` is compiled to keep it. Behaviour can therefore change subtly in either direction, so re-check watchers on props after migrating. - **Named exports from the `.vue` file.** Moving code into `<script setup>` cannot keep an `export`; the compiler rejects it. Keep exports in a normal `<script>`. ## Before and after, at the call sites | Caller pattern | Before (explicit `setup()`, no `expose()`) | After (`<script setup>`, no `defineExpose`) | |---|---|---| | `childRef.value.open()` | works | `open` is `undefined` | | `this.$parent.close()` from a child | works | `close` is `undefined` | | `<Dialog :title="t" @close="onClose">` | works | works | The table is the argument for leaning on props and events during a migration: the declarative interface survives unchanged, while every imperative reach into the instance has to be re-examined. ## Verifying the migration - Type-check the template, since `vue-tsc` now sees the real types of every binding. - Grep for `ref="` usages of the component and for `$parent` in its children. - Run the component's tests; tests that reached into the instance may now need the component to expose what they read, or better assertions against rendered output. ## Summary Migrating to `<script setup>` is a mechanical mapping from options and the setup context to macros, imports and top-level bindings. The breakage is at the boundary: the component closes its public instance and moves its option arguments to module scope, so callers and hoisted options are what to audit.

  • After migration, a parent's dialogRef.value.open() throws 'is not a function'. What is the cleanest fix?
    The migrated child is closed by default, so `open` is no longer on its public instance. The quick fix is `defineExpose({ open })` in the child. The cleaner fix is often to drive visibility with a prop or `v-model` from the parent, so no imperative method is needed at all.
  • The old component had a mixin that read this.visible in its mounted hook. What happens after migrating?
    `<script setup>` variables are not added to the component instance, so the mixin's `this.visible` is `undefined`. Rewrite the mixin as a composable called from `<script setup>`, which is the Composition API replacement for mixins.

saying these in an interview costs you the question

  • You must keep the return object so the template can see the bindings.
  • Parents' template ref calls keep working after migration without any change.
  • The components option is still required to use imported child components.
  • Mixins can keep reading setup state through this after migration.
  • defineProps defaults can reference any variable computed earlier in the block.