skip to content

Script Setup

<script setup> exposes its top-level bindings to the template, stays closed to parents by default and hosts compile-time macros like defineProps. Interviewers ask what the compiler rewrites.

part ofVue.jsoverview, primer and where to startread it →
on this pageshow

explore

questions

6

In a Vue 3 <script setup> component, how do top-level variables, functions and imports reach the template, and how often does that code run?

level: juniorimportance: must knowfreq 72%

answer

  1. no return statement needed
  2. imports count as bindings
  3. components used as variables
  4. vName for local directives
  5. per instance, not per import

basics

~20 s

Every top-level binding in <script setup>, including variables, functions and imports, is usable in the template with no return object; imported components are used directly as tags. The block compiles into setup(), so it runs once per component instance.

solid answer

~40 s

`<script setup>` is compile-time sugar: its body becomes the component's `setup()` function, and the compiler makes every **top-level binding** available to the template, so there is no `return { ... }`. That includes `const` and `let` variables, function declarations and **imports**: an imported helper can be called in a template expression, and an imported component is used directly as a tag, `<MyComponent />`, with no `components` registration. Local custom directives follow a naming rule instead: a binding named `vFocus` is used as `v-focus`. Refs are unwrapped in the template as usual. Because the code is the body of `setup()`, it runs **every time an instance is created**, unlike a normal `<script>`, which runs once when the module is first imported.

code

vue · 21 lines
vue
<script setup lang="ts">
import { ref } from 'vue'
import { formatPrice } from './format'
import PriceTag from './PriceTag.vue'

const quantity = ref(1)

function increment() {
  quantity.value++
}

const vAutofocus = {
  mounted: (el: HTMLElement) => el.focus(),
}
</script>

<template>
  <input v-autofocus v-model.number="quantity" />
  <button @click="increment">+1</button>
  <PriceTag :label="formatPrice(quantity * 9.99)" />
</template>

go deeper

for a junior

Recall that top-level variables, functions and imports are all visible to the template, imported components are used as tags directly, and the code runs once per instance.

for a middle

Explain that the block compiles into setup(), why imports and the vName directive convention work, and how per-instance execution differs from a normal script block.

for a senior

Spot per-instance versus module-level state mistakes and side effects registered once per instance without cleanup, which multiply as instances mount.

for a principal

Set conventions on what belongs in module scope versus setup scope across a codebase, since shared module state behaves like a singleton under SSR and in tests.

## What `<script setup>` is `<script setup>` is a **compile-time syntactic sugar** for using the Composition API inside a Vue single-file component (SFC). You opt in with the `setup` attribute on the `<script>` tag. The SFC compiler takes the block's body and turns it into the component's `setup()` function. There is no runtime feature called script setup: by the time the browser sees the code, it is an ordinary component definition. ## Which bindings the template can see Any **top-level binding** declared in the block is usable in the template: - **Variables**: `const msg = 'Hello!'` and `let count = 0` at the top level. - **Reactive state**: `const count = ref(0)`, used as `{{ count }}` because refs are automatically unwrapped in templates. - **Function declarations**: `function save() {}` can be an event handler, `@click="save"`. - **Imports**: an imported function can be called directly in a template expression, such as `{{ capitalize(name) }}`, with no `methods` option in between. - **Imported components**: `import UserCard from './UserCard.vue'` makes `<UserCard />` available. Think of the tag as a variable reference, which is also why `<component :is="cond ? Foo : Bar" />` works with imported bindings. - **Namespaced components**: `import * as Form from './form-components'` allows `<Form.Input>`. Bindings nested inside functions or blocks are not top-level and are not exposed. ## Local custom directives Directives have a naming rule rather than a registration step. A top-level binding whose name starts with `v` followed by an uppercase letter is a local directive: ```vue <script setup> const vFocus = { mounted: (el) => el.focus() } </script> <template> <input v-focus /> </template> ``` An imported directive can be renamed on import to fit the scheme: `import { focus as vFocus } from './directives'`. ## How often the code runs | Block | Compiled into | Runs | |---|---|---| | normal `<script>` | module-level code | once, when the module is first imported | | `<script setup>` | the body of `setup()` | once **per component instance** | This difference matters in practice: 1. A `ref` created at the top of `<script setup>` is **per instance**: two `<Counter />` elements each get their own `count`. 2. A side effect written at the top of the block, such as registering a listener, runs for **every** instance and must be cleaned up per instance. 3. State meant to be shared across all instances, such as a module-level cache, belongs in a normal `<script>` block or a separate module, not in `<script setup>`. 4. `setup()` itself runs once per instance, not on every re-render; re-renders call the render function only. ## Why the template can see these bindings The Vue docs list performance as one of the advantages: the template is compiled into a render function **in the same scope** as the setup code, without an intermediate proxy. In other words, when the template is inlined, the render function is a closure inside `setup()` and reads your bindings directly. During development the compiler may instead return the bindings to a separately compiled template, but the observable rule, that top-level bindings are visible to the template, is the same. ## Common mistakes - **Returning an object anyway.** A `return` at the top level of `<script setup>` is not how bindings reach the template; delete it and rely on top-level declarations. - **Registering imported components.** There is no `components` option to fill in; the import is the registration. - **Declaring shared state in the wrong block.** A counter meant to be per instance must be declared in `<script setup>`; one meant to be shared must not be. - **Expecting nested bindings to be visible.** A variable declared inside a function or an `if` block is local to it and invisible to the template. - **Naming a local directive `focusDirective`.** Without the `v` prefix and uppercase letter, the compiler treats it as an ordinary variable and `v-focus` fails to resolve. ## Summary `<script setup>` removes the return object: everything declared at the top level, including imports and components, is visible to the template, and local directives are recognised by the `vName` convention. The code is the body of `setup()`, so it runs once for each component instance, which is the key difference from a normal `<script>` block.

  • Two <Counter /> instances built with <script setup> share a count that should be separate. What went wrong?
    The state was probably declared in a normal `<script>` block or in an imported module, which runs once per module and is therefore shared. State declared at the top of `<script setup>` is created inside `setup()`, so each instance gets its own; move the `ref` there.
  • Why must a local custom directive in <script setup> be named like vFocus?
    There is no `directives` option to register it under a string key, so the compiler recognises directives by naming convention: a top-level binding named `v` plus an uppercase letter, such as `vFocus`, is matched to `v-focus` in the template. An imported directive can be aliased on import to fit the scheme.

saying these in an interview costs you the question

  • You still need to return an object from <script setup> for the template to see bindings.
  • Imported components must be registered in a components option before use.
  • Code in <script setup> runs once, when the file is first imported.
  • Imported helper functions cannot be called directly in the template.
  • The body of <script setup> re-runs on every re-render.
open as a page

In Vue 3's <script setup>, why are defineProps, defineEmits and the other define* macros never imported, and what restrictions follow from that?

level: middleimportance: must knowfreq 58%

basics

~20 s

The define* macros are compiler hints that @vue/compiler-sfc recognises and compiles away, so they need no import. Their arguments are hoisted to module scope, so they cannot reference variables declared in <script setup>, and they only work inside <script setup>.

open as a page

In a Vue SFC, when do you still need a normal <script> block beside <script setup>, and what rules govern combining the two?

level: middleimportance: should knowfreq 45%

basics

~20 s

Use a normal <script> beside <script setup> for named exports, module-scope code that must run once, and, before Vue 3.3, options like inheritAttrs. Both blocks must share the same lang, and props or emits never go in the normal block.

open as a page

In Vue 3, what does it mean that a <script setup> component is closed by default, and what does a parent's template ref to it actually see?

level: middleimportance: should knowfreq 50%

basics

~20 s

A <script setup> component exposes none of its bindings on its public instance, so a parent's template ref or $parent sees only built-in properties like $el and $props. Bindings become visible only through the defineExpose macro.

open as a page

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%

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.

open as a page

In Vue's <script setup>, what does a top-level await compile to, and why does the compiler rewrite each such await?

level: middleimportance: nice to knowfreq 28%

basics

~20 s

A top-level await makes the compiled setup() an async setup(). The compiler wraps each top-level awaited expression with Vue's withAsyncContext helper so the current component instance is restored after the await, letting hooks and watchers registered later still attach to the component.

open as a page