Skip to content
beginner Phase 3 · Reactive Refs and the Composition API

The script setup Syntax

Declare component logic with the compiler-friendly setup block.

40m
0 problems
Topic Progress 0%

Setup Without the Function: What the Compiler Writes for You

The Composition API's original form asked you to write a setup() function, return every binding the template needed, and remember that this was unavailable inside it:

<script>
import { ref } from 'vue'
export default {
  props: { start: { type: Number, default: 0 } },
  setup(props) {
    const count = ref(props.start)
    const increment = () => count.value++
    return { count, increment }     // forget this line and the template breaks at runtime
  }
}
</script>

<script setup> is the compiler sugar that deletes the ceremony while keeping the semantics. The same component:

<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{ start?: number }>()
const count = ref(props.start ?? 0)
const increment = () => count.value++
</script>

<template>
  <button @click="increment">{{ count }}</button>
</template>

Mechanically, the block compiles into the component's setup method: every top-level binding — refs, computed, functions, imported components, constants — is returned from setup and reachable from the template. The return statement existed only because the Options API needed an explicit hand-off; under script setup the compiler performs the hand-off statically, so an omitted binding is a compile-time template error instead of a silent undefined in production.

Three consequences follow from "the compiler knows the bindings statically", and all three show up in real projects:

  1. Better tree-shaking and smaller output. The compiler can see that a top-level const is never reassigned and never leaks outside the render path, so it emits efficient property accesses (_ctx.count vs defensive lookups) and drops entirely unused imports. A helper imported but not referenced from template or script never reaches the bundle.
  2. Bindings are exposed, but this still does not exist. Setup runs before the component instance is created, so there is no this to capture — not for props (props.x, never this.x), not for methods. Code migrated from the Options API needs its this. prefixes removed, which is mechanical but must not be forgotten when converting mixins.
  3. Declarations execute once per instance. Everything in the block is component-instance scope: two rendered <Counter /> tags get two count refs, exactly like two calls to a factory. Module-level code (outside the block) is shared — the place for pure helpers, never for state.

The classic form has not disappeared, and knowing when it still applies is fair game in review: a plain <script> block (without setup) exports options and can run logic before setup (custom beforeCreate-style logic, options computed from static metadata); both forms can coexist in one file, with the plain block supplying options and the setup block supplying bindings. But for components written today, script setup is the default — create-vue scaffolds it, ESLint expects it, and every example in the rest of this roadmap assumes it.

The last piece of the mental model: what the template can see is exactly { ...topLevelBindings }, nothing inherited from globals or the window. That is the same isolation you met when the app instance was per-app — components are per-instance scopes, and the compiler is what connects their scopes to templates. Next: the macros that declare props and emits without any of it being a runtime import.

defineProps and defineEmits: Compile-Time Macros, Not Imports

defineProps and defineEmits look like function calls and are not: they are compile-time macros that the SFC compiler consumes and erases. You never import them from vue, you never see them in the output, and calling them outside a <script setup> block is an error. Their whole job is to carry type information and declarations to the compiler.

<script setup lang="ts">
// no import — the compiler recognizes these names
const props = defineProps<{
  title: string
  price?: number
  currency?: 'USD' | 'EUR'
}>()

const emit = defineEmits<{
  (e: 'update:price', value: number): void
  (e: 'cancel'): void
}>()

function apply() {
  emit('update:price', (props.price ?? 0) * 2)   // typed: wrong payload = compile error
}
</script>

<template>
  <button @click="apply">{{ title }}: {{ price }}</button>
</template>

Props declared with types are compiler-inferred at build time: the SFC compiler extracts the literal type, generates the equivalent runtime props: { title: String, ... } declaration for validation, and marks the rest as attrs. That is why the type argument must be a literal inline type — a interface Props extends Base import works, but defineProps<T>() with a generic parameter resolved elsewhere leaves the compiler nothing to extract.

Defaults and destructuring have one sharp rule each. Defaults go through withDefaults, because destructuring props in the setup signature loses reactivity (a trap you will meet in Common Reactivity Pitfalls):

const props = withDefaults(defineProps<{ currency?: 'USD' | 'EUR' }>(), {
  currency: 'USD'
})

And never mutate a prop. Props flow one way; the compile-time types make props.x = 1 a type error, and at runtime it warns. To let a parent control a value, declare a matching emit — defineModel in newer Vue (3.4+) collapses the whole modelValue / update:modelValue dance into one macro:

<script setup lang="ts">
const model = defineModel<number>()   // prop + emit pair in one line
</script>
<template>
  <input type="number" :value="model" @input="model = +$event.target.value" />
</template>

Emits typing deserves the same care as props: the call-signature overload form above gives emit('update:price', 5) a checked payload and makes emit('nope') a compile error — real protection, since a typo'd event name is otherwise a silent no-op at runtime. Also declare emitted events even when you never call emit in the component body but pass the listener down (v-bind="$attrs"), or the parent's @event binding will not be recognized as a declared event.

One more macro completes the set: defineExpose controls what a parent can read through a template ref. By default a script setup component exposes nothing — its internals are closed, and parentRef.value.someMethod() is undefined. defineExpose({ validate, focus }) publishes exactly those members. Exposing narrowly is deliberate design: the public surface of a component becomes something you can grep and review, instead of every internal ref leaking to every parent.

The interview-complete answer ties macros to their nature: they are erased at compile time, they exist to move information (types, defaults, exposes) from your source into the generated component options, and their "no import" rule is what lets the SFC compiler statically analyze a component in one pass.

The Edges of script setup: Names, Slots, Generics, and Async

<script setup> trades a little expressiveness for a lot of boilerplate removal. Knowing where the edges are — and which of them newer macros have rounded — saves the afternoon you would otherwise spend on a compile error with a bad message.

Component options have no home in the setup block. You cannot write name, inheritAttrs: false, or custom option keys there; the script setup block accepts bindings, not options. Vue 3.3 added defineOptions to cover exactly this gap:

<script setup lang="ts">
defineOptions({ name: 'CheckoutSummary', inheritAttrs: false })
const attrs = useAttrs()   // now you own where the fallthrough lands
</script>

The name override matters beyond vanity: it decides the devtools label and the KeepAlive include/exclude cache keys, both of which otherwise derive from the filename. inheritAttrs: false pairs with manual useAttrs() placement when the fallthrough attributes must land on an inner element instead of the root — the wrapper-div problem from attribute fallthrough, solved without a second element.

Slots and attrs are not variables — you ask for them. Named slots and attribute bags are accessed through the useSlots() and useAttrs() composables:

<script setup lang="ts">
const slots = useSlots()
const hasFooter = !!slots.footer          // branch on slot presence
</script>

<template>
  <footer v-if="hasFooter"><slot name="footer" /></footer>
</template>

Use them for presence checks and manual invocation; rendering slots stays declarative with <slot> and <slot name="x">.

Generic components need the generic attribute (Vue 3.3+), which is a type-level construct only — it never exists at runtime:

<script setup lang="ts" generic="T extends { id: string }">
const props = defineProps<{ items: T[]; selected?: T }>()
</script>

<template>
  <li v-for="item in props.items" :key="item.id" :data-sel="item === selected">
    <slot :item="item" />   <!-- slot props keep the caller's exact T -->
  </li>
</template>

A list/table component that preserves the caller's item type through its slot props — no any, no casting at the call site — is the payoff, and it is a strong interview answer for "how do you type reusable components in Vue".

Async setup needs Suspense. An async setup() in the classic form, or a top-level await in script setup (with an async-aware SFC compiler), makes the component async: it suspends until the promise resolves. Vue 3 requires an enclosing <Suspense> with a <template #fallback> or the async component never commits. The common alternative — async inside the component (onMounted plus a status ref, or a composable like useAsyncData in Nuxt) — avoids the suspense tree entirely and is often the calmer design because loading and error states become explicit UI instead of an implicit fallback swap.

The last boundary is the one that keeps this syntax honest: script setup is a component scope, not a module. State declared there is per-instance and dies with the component; anything shared must climb to a ref in a composable, a Pinia store, or module scope (for pure functions only). The next three topics build the machinery that makes per-instance state reactive in the first place, starting with ref for Primitive State.

Quiz

1. Why does a `<script setup>` component not need a return statement?

Question 1 options

2. What are defineProps and defineEmits, and why must you not import them from 'vue'?

Question 2 options

3. A parent needs to read a child's `validate()` method through a template ref. With script setup, what must the child do?

Question 3 options

4. Where do the options `name`, `inheritAttrs: false`, and a custom `emits` declaration live in a script setup component?

Question 4 options

Flashcards

Question

What does <script setup> compile into, and what does that static knowledge buy?

Answer

It compiles the block into the component's setup function, exposing every top-level binding to the template without a return. Because the compiler knows the bindings statically, missing template bindings fail at compile time, unused imports tree-shake away, and property access is optimized — same Composition API semantics, less ceremony.

Question

What are defineProps/defineEmits, and what are their constraints?

Answer

Compile-time macros — never imported, erased at build. They take literal inline types (or runtime objects), from which the compiler generates props validation and typed emit signatures. Defaults go through withDefaults (not destructuring); emits use call-signature overloads for checked payloads. Mutating props is a type error and a runtime warning.

Question

How does a parent access a script setup child's methods through a ref?

Answer

It can't by default — the component's internals are closed to refs. The child must opt in with defineExpose({ methodA, methodB }), which publishes exactly those members. The narrow surface is intentional: the exposed API stays greppable and reviewable.

Question

How do template-used imports behave, and when do they reach the bundle?

Answer

Top-level imports referenced in the template compile into component registrations; imports used only in script stay ordinary references; imports used nowhere are dropped by tree-shaking. Global registration (app.component) exists for truly shared components, at the cost of a hidden dependency in the template.

Question

Name the main boundaries of script setup and the macro or pattern addressing each.

Answer

Options (name, inheritAttrs) → defineOptions · named slots/attrs in logic → useSlots()/useAttrs() · generic components → <script setup generic="T extends ..."> (type-only) · top-level await → requires <Suspense> with a fallback, or handle async inside via status ref + onMounted · shared state → composables or Pinia (block scope is per-instance).

Revision Notes

Key Takeaways

  • 1. script setup compiles to a setup function whose returned bindings the compiler derives statically — no return statement, compile-time errors for missing bindings, better tree-shaking
  • 2. defineProps/defineEmits/defineModel/defineExpose/defineOptions are erased macros that carry types and declarations to the compiler, which is why they are never imported
  • 3. Props are one-way and their types generate runtime validation; defaults need withDefaults; emits typing turns event-name typos into compile errors
  • 4. script setup components are closed by default — defineExpose publishes the public surface deliberately
  • 5. The block is per-instance scope: shared state must live in a composable, a store, or (for pure helpers) module scope

Interview Tips

  • • "What does script setup actually do?" — compile to a setup function with statically-derived returns; add the benefits (tree-shaking, compile-time binding checks) to show you understand the mechanism, not the syntax
  • • "Why can't I import defineProps?" — it's a compile-time macro erased by the SFC compiler; it must be a literal type so the compiler can generate runtime props metadata — connect macros to their erasure model
  • • "How do you expose methods to a parent ref?" — defineExpose, and note the design intent: narrow, reviewable public surface instead of leaking every ref
  • • "How do you type a generic list component?" — <script setup generic="T extends X"> with typed props and slot props preserving the caller's T — a strong differentiator answer

Cheat Sheet

The script setup Syntax

Shape:

<script setup lang="ts" generic="T">
import Child from './Child.vue'                    // template-used import = registration
const props = defineProps<{ title: string }>()     // macro: no import, erased at compile
const emit = defineEmits<{ (e: 'save', v: number): void }>()
const model = defineModel<number>()                // 3.4+: prop+emit pair
defineOptions({ name: 'X', inheritAttrs: false })
const count = ref(0)                               // top-level binding → template
</script>

Compilation facts: block → setup function · all top-level bindings returned statically (missing binding = compile error) · unused imports tree-shaken · this never exists · declarations run per instance.

Macros:

Macro Does Watch out
defineProps typed props + runtime validation literal inline/interface types only
withDefaults defaults for type props destructuring props loses reactivity
defineEmits typed emit signatures typo'd event = silent no-op without it
defineModel modelValue + update:modelValue shorthand one per v-model
defineExpose publish members to parent refs nothing is exposed without it
defineOptions name, inheritAttrs in-block name drives devtools + KeepAlive keys

Boundaries: slots/attrs in logic → useSlots() / useAttrs() · generics → generic="T" (type-level) · top-level await → needs <Suspense> · options → defineOptions · shared state → composables/Pinia, never module scope.

Continue Learning

Before You Start

Related Reading