defineExpose and Template Refs on Child Components in Vue, Done Right
AI generated
{ }
Vue.js · defineExpose · Template Refs · script setup
defineExpose and Template Refs
on child components, done right

A component in script setup is a closed box by default: neither its reactive variables nor its functions are reachable from a parent through a template ref, even when the ref is set correctly. That is deliberate design, protecting against uncontrolled access to internal implementation details, but it can be selectively lifted with defineExpose when a parent genuinely needs to call a specific method on a child directly, say to validate a form or open a modal. Understanding where that access makes sense and where it would be better replaced by props and events keeps component interfaces from becoming either needlessly rigid or needlessly tangled.

14 min read defineExpose · Template refs Vue 3 · script setup · Composition API

1. Why script setup components are encapsulated by default

In the classic options API, a component instance was largely open from the outside: a template ref could reach practically any data property or method directly from the parent, which was flexible but also invited manipulating a component's internal implementation details from outside, even though they were meant to be private. script setup deliberately reverses this behavior: by default, the public instance of a script setup component is completely empty, so a template ref returns an object with no accessible properties or methods.

This decision follows the same principle as private fields in object-oriented languages: a component itself defines which part of its internal logic counts as its public interface, rather than every local variable automatically becoming visible from outside. For most components this is not an issue, since communication flows through props downward and events upward. Only once a parent genuinely needs to act imperatively on a child, say to trigger a specific action precisely, does the default encapsulation become a relevant hurdle.

2. defineExpose: basics and syntax

defineExpose is a compiler macro available exclusively inside script setup, taking an object with the properties and methods that should actually be visible from outside. Calling defineExpose({ validate, reset }) makes exactly those two functions available on the public instance, while every other local variable, function and reactive state remains fully private and unreachable from the parent.

Like defineProps or defineEmits, defineExpose must be called directly at the top level of script setup, not inside a condition or a nested function, because the compiler statically analyzes this call at compile time. If defineExpose is never called, the component simply stays fully encapsulated as described, which is also the desired default behavior for the vast majority of components.


<script setup lang="ts">
import { ref } from 'vue'

const errorMessage = ref('')

function validate(): boolean {
  errorMessage.value = ''
  // ... actual validation logic
  return errorMessage.value === ''
}

function reset() {
  errorMessage.value = ''
}

defineExpose({ validate, reset })
</script>

3. Accessing a child component from the parent via a template ref

To access a child component's exposed methods from the parent, a template ref is first set up as usual, either through a ref attribute plus a matching ref() call in script setup, or, since Vue 3.5, optionally through useTemplateRef(). The name of the ref attribute in the template must exactly match the name of the local variable holding the ref, otherwise formRef.value stays null even after mounting.

Once the child component is mounted, formRef.value returns exactly the object the child exposed through defineExpose, in our example an object with validate and reset. A call like formRef.value?.validate() in the parent then directly invokes the child's method, completely independent of any props or events connection between the two components, which is why this access path is called an imperative API, as opposed to declarative communication through props and events.


<script setup lang="ts">
import { ref } from 'vue'
import FormChild from './FormChild.vue'

const formRef = ref<InstanceType<typeof FormChild> | null>(null)

function handleSubmit() {
  if (formRef.value?.validate()) {
    // form is valid, proceed with submission
  }
}
</script>

<template>
  <FormChild ref="formRef" />
  <button @click="handleSubmit">Submit</button>
</template>

4. Typing the ref with InstanceType and ComponentExposed

For formRef.value in the parent to be correctly typed and for the IDE to offer autocompletion for validate and reset, the ref is typically declared as ref | null>(null). InstanceType automatically infers the type of the public instance from the imported component, including every property and method that component exposed through defineExpose, provided the child component itself is written in TypeScript.

For generic components, or cases where InstanceType is not precise enough, the community library vue-component-type-helpers offers ComponentExposed, specifically designed for the type exposed through defineExpose and delivering more accurate results than InstanceType in some edge cases. For most projects, though, InstanceType is entirely sufficient and remains the more established approach that works without an additional dependency.

5. Practical example: a form with an exposed validate() method

A particularly common, legitimate use case for defineExpose is a reusable form field or an entire form section that encapsulates its own validation logic but needs to be centrally triggered by a parent form component when the user clicks submit. Each field component exposes its own validate() method, which internally checks whether the current value is valid and returns a boolean.

The parent form component holds a separate template ref for each field and, on submit, calls every field's validate() method in turn, collects the results, and blocks the actual submission as soon as at least one field fails validation. This pattern works noticeably more robustly than centralized, monolithic validation logic in the parent, because each field encapsulates its own validation rules while still being orchestrated centrally, without validation details needing to leak out through props.


<script setup lang="ts">
import { ref } from 'vue'
import FormField from './FormField.vue'

const nameFieldRef = ref<InstanceType<typeof FormField> | null>(null)
const emailFieldRef = ref<InstanceType<typeof FormField> | null>(null)

function submitForm() {
  const isValid = [nameFieldRef, emailFieldRef].every(
    (field) => field.value?.validate() === true,
  )
  if (isValid) {
    // all fields valid, submit the form
  }
}
</script>

6. Practical example: a modal with exposed open()/close() methods

A second classic example is a modal component whose visibility is controlled internally through a local, reactive state, but that needs to be opened and closed imperatively from the parent, say in response to a button click somewhere else entirely in the template. Instead of passing visibility outward as a v-model prop, the modal instead exposes two simple methods, open() and close(), which internally just toggle the local isVisible state.

This approach works especially well when the modal itself manages further internal details the parent should not care about, such as a built-in fade animation that must finish before close() actually removes the element from the DOM. The imperative API of open() and close() keeps these details entirely inside the modal component, while the parent only needs to know the two method names, without worrying about the internal animation logic.

7. When this access becomes an anti-pattern versus props/events

defineExpose becomes problematic as soon as it is used to replace regular, reactive data flow that should really be modeled cleanly through props and events. If, for instance, a reactive state like currentValue is exposed through defineExpose so the parent can read it directly instead of having it emitted through an event, a hidden, hard-to-trace dependency emerges: changes on the child only reach the parent if the parent actively and repeatedly reads formRef.value.currentValue, which contradicts Vue's declarative, reactive nature.

As a rule of thumb: for data that changes over time and needs to be reflected reactively in the parent, props downward and events upward are almost always the better choice, because Vue's reactivity system tracks that data flow automatically. defineExpose, by contrast, is well suited for one-off, imperative actions like validate(), open(), close() or focus(), triggered once and not representing a continuous, reactive data flow. Once the boundary between the two cases blurs, the child component effectively becomes an extension of the parent rather than a standalone, reusable unit with a clear interface.

8. defineExpose compared to the options API's expose option

The options API has a similar concept through the expose option, specified as an array of property and method names, for example expose: ['validate', 'reset']. The behavior is conceptually identical to defineExpose: only the named entries become visible on the public instance, while everything else stays unreachable through a template ref, even if it exists inside the component as a data property or method.

The practical difference lies mainly in the syntax and the point of declaration: while the options API variant expects a static array of strings, defineExpose works with an actual object of locally defined constants, which in TypeScript projects allows a more precise, automatically inferred typing of the exposed interface, without duplicating property names as strings that can drift out of sync.

9. Common mistakes and conclusion

The most common mistake is forgetting defineExpose entirely and then wondering why formRef.value.validate is undefined in the parent, even though the method visibly exists inside the child component. A second common mistake is accessing formRef.value before the child component is actually mounted, say inside the parent's onMounted while the child is still hidden behind a v-if, which likewise results in a null value that needs to be guarded against.

The takeaway: defineExpose is a deliberately narrow tool for one-off, imperative interfaces between parent and child components, not a general workaround for the normal props-and-events data flow. Using defineExpose exclusively for one-time actions like validate(), focus() or open()/close(), and consistently routing reactive data through props and events, keeps component interfaces both flexible and easy to reason about.

Use case Recommended mechanism Why Example
Validate a form externally defineExpose with validate() One-off, imperative action triggered by the parent formRef.value?.validate()
Open/close a modal defineExpose with open()/close() Visibility logic stays encapsulated internally modalRef.value?.open()
Report a selection value to the parent Props and events (v-model) Continuous, reactive data flow @update:modelValue
Report a loading state to the parent Props and events Reactive state that changes over time emit('loading-change', true)

Mironsoft

Vue architecture, Composition API, and Nuxt performance

Vue applications that don't get more complicated with every feature?

We review existing Vue and Nuxt projects for unstructured composables, unnecessary reactivity, and bloated bundles, then build an architecture that absorbs new features without making the codebase harder to follow.

Architecture Review

Checking composables, state management, and component structure for maintainability.

Performance Audit

Systematically optimizing reactivity overhead, bundle size, and Nuxt rendering strategy.

Nuxt Integration

Building robust, type-safe SSR/SSG setup and API integration.

10. Summary

defineExpose and Template Refs: The Essentials at a Glance

Default behavior

script setup components are fully encapsulated without defineExpose, a template ref returns an empty object.

defineExpose

Compiler macro that selectively exposes individual methods and properties on the public instance.

Typing

InstanceType infers the correct type of the exposed interface for the parent's ref.

Anti-pattern

Reactive data belongs in props and events, defineExpose is meant for one-off, imperative actions.

11. FAQ: defineExpose and Template Refs: The Essentials at a Glance

1Why does a template ref on a script setup component return an empty object by default?
Because script setup components are fully encapsulated by default. Without defineExpose, the component's public instance is deliberately empty, so internal implementation details are not accidentally reachable from outside.
2How do you expose specific methods on purpose?
With the compiler macro defineExpose, called with an object of the desired properties and methods, for example defineExpose({ validate, reset }). Only these become reachable from the parent through a template ref.
3How does the parent access the exposed methods?
Through a normal template ref on the child component. Once the component is mounted, the ref returns exactly the object exposed through defineExpose, so formRef.value?.validate() can be called.
4How do you correctly type a ref on a child component?
With InstanceType, for example ref | null>(null). This automatically infers the type of the public instance, including the methods exposed through defineExpose.
5When does defineExpose become an anti-pattern?
When it is used to replace continuous, reactive data flow that should really go through props and events. If reactive state is only read via a ref instead of being emitted through an event, a hidden, hard-to-trace dependency emerges.
6Is there an equivalent to defineExpose in the options API?
Yes, the expose option, specified as an array of names, for example expose: ['validate', 'reset']. The behavior is conceptually identical, only the syntax and declaration style differ.
7What happens when you access formRef.value before the child component is mounted?
The ref is still null at that point, so calling a method either throws an error or must be guarded with optional chaining like formRef.value?.validate() to allow clean error handling.
8Can defineExpose also expose reactive refs, not just functions?
Yes, technically reactive refs can be exposed too. It is rarely a good idea, though, because the reactive connection then only becomes visible through active polling by the parent instead of automatically, which contradicts Vue's declarative philosophy.
9Does defineExpose always need to be called, even when exposing nothing?
No, if defineExpose is never called, the component simply stays fully encapsulated, which is the desired default for most components and requires no additional code.
10Is defineExpose well suited for library components used by external projects?
Yes, especially there it makes sense, because it explicitly defines a component's public, stable interface while internal implementation details stay protected and can change without breaking the public API.