skip to content

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.