skip to content

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%

answer

  1. the public instance is empty
  2. an expose() call with no arguments
  3. built-in $ properties remain
  4. an API boundary, not privacy

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.

solid answer

~50 s

With an explicit `setup()` or the Options API, the public instance a parent reaches through a template ref, `$parent` or `$root` proxies the component's state, so the parent can read data and call methods. A `<script setup>` component is **closed by default**: none of its top-level bindings appear on that public instance. When the template is compiled separately, the usual development setup, the compiler emits an `expose()` call with no arguments; when the template is inlined the bindings never leave the `setup()` closure at all. So `childRef.value.reset()` is `undefined` unless the child opts in with `defineExpose`. What remains are the built-in `$` properties such as `$el`, `$props` and `$emit`. Options API code in the same component cannot read `<script setup>` variables through `this` either. Closedness is an API boundary for callers, not a security mechanism.

code

vue · 30 lines
vue
<!-- SearchBox.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const query = ref('')
function clear() {
  query.value = ''
}
</script>

<template>
  <input v-model="query" />
</template>

<!-- Parent.vue -->
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue'
import SearchBox from './SearchBox.vue'

const box = useTemplateRef<any>('box')

onMounted(() => {
  console.log(box.value?.$el)   // the <input> element
  console.log(box.value?.clear) // undefined: SearchBox is closed by default
})
</script>

<template>
  <SearchBox ref="box" />
</template>

go deeper

for a junior

Recall that a <script setup> component does not expose its variables or functions to a parent's template ref unless it opts in.

for a middle

Explain the mechanism: an argument-less expose() or an inlined template keeps bindings off the public instance, while built-in $ properties stay reachable.

for a senior

Anticipate what breaks when a component moves to <script setup>: parent ref calls, mixins reading this, and tests poking internals, and fix them at the API level.

for a principal

Use closed-by-default to argue for props, events and slots as the component contract, and keep imperative exposed methods rare and documented.

## The public instance Every Vue component instance has a **public instance**: the object a parent receives from a template ref (`ref="child"`), from `$parent`, or from `$root`. With the Options API or an explicit `setup()` that returns an object, that public instance proxies the component's state. A parent can read `child.value.count` or call `child.value.reset()`, which makes every internal binding part of the component's de facto API. ## What "closed by default" changes The Vue docs state that components using `<script setup>` are **closed by default**: the public instance retrieved through template refs or `$parent` chains will **not** expose any of the bindings declared inside `<script setup>`. | Component style | What a parent's template ref sees | |---|---| | Options API | data, computed, methods and built-in `$` properties | | explicit `setup()` returning bindings, no `expose()` | returned bindings and built-in `$` properties | | `<script setup>` with no `defineExpose` | built-in `$` properties only | | `<script setup>` with `defineExpose({ reset })` | `reset` and built-in `$` properties | ## How the compiler achieves it There are two compilation modes, and both end up closed: 1. **Separate template (typical in development).** The compiled `setup()` returns all bindings so the separately compiled template can read them. To keep them off the public instance, the compiler inserts a call to the setup context's `expose()` with **no arguments**. Exposing an empty set makes Vue hand out a restricted proxy instead of the full instance proxy. 2. **Inlined template.** The template is compiled into a render function inside the `setup()` closure, and `setup()` returns that function. The bindings are ordinary local variables, so there is nothing on the instance to expose. Either way, the restricted proxy still answers the built-in public properties: `$el`, `$props`, `$attrs`, `$slots`, `$refs`, `$parent`, `$root`, `$emit`, `$nextTick` and the rest. Only your bindings are hidden. ## Consequences you meet in practice - **Parent calls to child methods break** when a child is rewritten from the Options API to `<script setup>`: `formRef.value.validate` becomes `undefined`. The child must opt in, or the interaction should move to props and events. - **Options API code cannot see script setup variables.** The docs note that variables created inside `<script setup>` are not added as properties to the component instance, so a `mounted()` option or a mixin in the same component cannot reach them through `this`. Mixing the two styles this way is strongly discouraged. - **Tests that poke instance internals** through the public instance see the same restriction. ## Why Vue made this the default - **Explicit API surface.** A component's contract is its props, events and slots; anything else a parent can reach becomes an accidental dependency that blocks refactoring. - **Safer refactors.** Renaming a local variable inside `<script setup>` cannot break a parent. - **Performance and tooling.** Inlining the template into the setup closure avoids an intermediate proxy for template access, and a smaller public surface is easier to type. ## Interview traps - **"Closed" is about bindings, not about the ref.** The template ref still resolves to a component proxy; only the component's own variables and functions are missing from it. - **Explicit `setup()` is not closed automatically.** A component that returns bindings from `setup()` and never calls `expose()` still has them on its public instance. - **`$parent` is affected too.** A child reaching up with `$parent.someMethod()` into a `<script setup>` parent finds nothing, which is usually a sign the interaction should be an event. ## What closed does not mean Closedness restricts the documented public instance; it is not a privacy or security boundary. Values are still reachable through development tools and internal properties, and any data rendered into the DOM is visible to users. Treat it as an API design default, not as protection. ## Summary A `<script setup>` component hides its bindings from template refs and `$parent` unless it explicitly exposes them. The compiler achieves this with an argument-less `expose()` call or by keeping bindings in the render closure, and the parent's ref sees only the built-in `$` properties.

  • Does a <script setup> component with no defineExpose hide $el and $props from a parent's ref too?
    No. Closedness hides only the component's own bindings. The restricted proxy Vue returns still resolves the built-in public properties, including `$el`, `$props`, `$attrs`, `$slots`, `$emit` and `$nextTick`.
  • A mixin's mounted() hook reads this.items, which is declared in the same component's <script setup>. Why is it undefined?
    Variables created inside `<script setup>` are not added as properties of the component instance, so Options API code, including mixins, cannot reach them through `this`. The Vue docs strongly discourage mixing the two styles; move the logic into the setup code or a composable.

A closed component is like a house seen from the street: anyone can see its address and front door, the built-in $el and $props, but not the rooms inside unless the owner deliberately opens a window with defineExpose.

saying these in an interview costs you the question

  • A parent can call any function defined in a child's <script setup> through a template ref.
  • Closed by default also hides $el, $props and the other built-in properties.
  • Closed by default is a security feature that keeps component data private.
  • Options API hooks in the same component can read <script setup> variables through this.
  • An explicit setup() that returns bindings is closed by default in the same way.