When migrating a Vue 3 component from an explicit setup() function to <script setup>, what changes mechanically, and what typically breaks for its callers?
answer
- the return object disappears
- context arguments become macros
- registration becomes imports
- parents lose access to internals
basics
~20 sThe 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 sMechanically, 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// 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
Recall the basic mapping: the return object goes away, props and emits become defineProps and defineEmits, and imported components need no registration.
Explain how attrs, slots, expose, directives and leftover options map to their script setup forms, including defineOptions in 3.3+.
Audit the boundary before merging: parent template refs, $parent access, mixins using this, hoisted macro arguments and changed props-destructure behaviour.
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.