| |

Vue.js 41 🟢 Component Instance Expose Control with defineExpose()

A parent component can reach into a child and call its methods or read its state by holding a template ref. In Vue 2, everything on the component instance was reachable by default. In Vue 3 with <script setup>, the script’s bindings are closed by default, and the parent sees an empty object unless the child explicitly exposes something. The defineExpose() macro is the mechanism that opens the door. It is a deliberate, minimal, opt-in API surface for imperative interactions, and understanding when to use it and when to reach for props and events instead is the difference between a clean component boundary and a leaky one.

Key point: defineExpose() is for imperative operations the parent cannot express declaratively. Focus, scroll, play, validate, reset. It is not a way to bypass props and events. If the parent needs data, use props. If the parent needs to react to something, use events. If the parent needs to trigger an action that has no declarative equivalent, expose a method.


Why defineExpose exists

The closed-by-default model. With <script setup>, every top-level binding is local to the component. The compiler does not attach them to the instance. A parent that holds a template ref receives a proxy with nothing on it, because nothing has been exposed. This is the default, and it is deliberate.

The leaky-API problem. In Vue 2 and in Options API components, everything was reachable. A parent could call any method, read any data property, and mutate internal state. This made refactoring dangerous: any rename was a breaking change, and the internal structure of a component was effectively its public API.

The imperative-need problem. Some operations are inherently imperative. Focusing an input, scrolling a container, starting playback on a media element, resetting a form, and triggering a canvas animation cannot be expressed as props. The parent needs to call a method, and the method has to be reachable.

The explicit-contract problem. defineExpose() turns the public surface into a deliberate decision. The developer lists what the parent may access. Everything else is private. A rename of an internal method is not a breaking change because the method was never exposed.

The testability problem. In unit tests, a component is often mounted and driven through its public interface. Exposed methods are the imperative parts of that interface. Without defineExpose(), the test must reach into internals or simulate DOM events, which is more brittle.

The TypeScript problem. In <script setup>, exposed values are typed. The parent’s ref receives a typed object, and calling an unknown method is a compile error. This is the same benefit as a typed function signature: the contract is checked at build time.


a. The basics of defineExpose

defineExpose() is a compiler macro, available only inside <script setup>. It takes an object whose properties become the component’s public interface.

The minimal usage

<script setup>
import { ref } from 'vue';

const inputEl = ref(null);

function focus() {
  inputEl.value?.focus();
}

defineExpose({ focus });
</script>

<template>
  <input ref="inputEl" />
</template>

The focus function is now reachable from the parent through a template ref. Everything else in the script remains private.

The parent side

The parent creates a ref, binds it to the child with ref="childRef", and calls the exposed method when needed.

<script setup>
import { ref } from 'vue';
import MyInput from './MyInput.vue';

const childRef = ref(null);

function focusChild() {
  childRef.value?.focus();
}
</script>

<template>
  <MyInput ref="childRef" />
  <button @click="focusChild">Focus</button>
</template>

The childRef.value is null until the child is mounted. The optional chaining protects against calling before mount.

What is exposed

The object passed to defineExpose() may contain refs, functions, computed properties, and plain values. A ref is unwrapped when accessed through the instance proxy, so the parent reads the value, not the ref object.

<script setup>
const count = ref(0);
const state = reactive({ status: 'idle' });
function reset() { count.value = 0; }

defineExpose({ count, state, reset });
</script>

The parent can read childRef.value.count (a number), read childRef.value.state.status, and call childRef.value.reset().

What is not exposed

Everything not listed in the defineExpose call is private. This includes imported components, local variables, and functions that are not part of the intended public interface.

<script setup>
const internalCounter = ref(0);  // Not exposed.
const displayed = ref('');       // Not exposed.
function focus() {}              // Exposed.

defineExpose({ focus });
</script>

The parent sees only focus. The internalCounter and displayed are invisible.

The macro restriction

defineExpose() is a macro. It cannot be imported, and it cannot be called conditionally or inside a function. It must be called at the top level of <script setup>.

<script setup>
// ❌ This fails: macros cannot be called conditionally.
if (someCondition) {
  defineExpose({ focus });
}
</script>

The compiler transforms the macro into a runtime call before the script executes, and the transformation requires a static top-level call.


b. Common patterns

The exposed interface serves specific kinds of interactions. The patterns below cover the cases that appear most often.

The focus-and-scroll pattern

An input component exposes focus(). A list component exposes scrollTo(index). A modal exposes show() and hide(). These are operations the parent triggers in response to user actions that have no declarative equivalent.

<script setup>
import { ref } from 'vue';

const modalEl = ref(null);

function show() { modalEl.value?.showModal(); }
function hide() { modalEl.value?.close(); }

defineExpose({ show, hide });
</script>

<template>
  <dialog ref="modalEl"><slot /></dialog>
</template>

The parent calls modalRef.value.show() when a button is clicked. The dialog’s open state is managed by the browser’s native <dialog> element, not by a prop.

The form-validation pattern

A form component exposes validate() and reset(). The parent calls validate() before submission, and reads the result to decide whether to proceed.

<script setup>
import { ref } from 'vue';

const email = ref('');
const errors = ref({});

function validate() {
  errors.value = {};
  if (!email.value.includes('@')) {
    errors.value.email = 'Invalid email';
  }
  return Object.keys(errors.value).length === 0;
}

function reset() {
  email.value = '';
  errors.value = {};
}

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

<template>
  <input v-model="email" />
  <p v-if="errors.email">{{ errors.email }}</p>
</template>

The parent’s submit handler calls formRef.value.validate() and only proceeds if it returns true.

The media-control pattern

A video or audio component exposes play(), pause(), and seek(time). These map to the underlying media element’s methods.

<script setup>
import { ref } from 'vue';

const videoEl = ref(null);

function play() { videoEl.value?.play(); }
function pause() { videoEl.value?.pause(); }
function seek(t) { if (videoEl.value) videoEl.value.currentTime = t; }

defineExpose({ play, pause, seek });
</script>

<template>
  <video ref="videoEl" src="..."></video>
</template>

The parent controls playback through the exposed methods, not through props.

The imperative-handle pattern

A component exposes a small, stable interface that hides its internals. The interface is the contract. Changing the internals does not change the contract.

<script setup>
import { ref, computed } from 'vue';

const items = ref([]);
const selectedIndex = ref(-1);

const selected = computed(() => items.value[selectedIndex.value] ?? null);

function selectNext() { selectedIndex.value = Math.min(selectedIndex.value + 1, items.value.length - 1); }
function selectPrev() { selectedIndex.value = Math.max(selectedIndex.value - 1, 0); }
function clear() { selectedIndex.value = -1; }

defineExpose({
  selected,
  selectNext,
  selectPrev,
  clear,
});
</script>

The parent sees selected, selectNext, selectPrev, and clear. The items array and the selectedIndex ref are private. The parent cannot corrupt the internal state directly.

The expose-with-ref pattern

When the exposed value is a ref, the parent receives the unwrapped value. When the parent needs to write to it, the exposed ref must be writable, which means it should be a ref, not a computed with only a getter.

<script setup>
const value = ref('');

defineExpose({ value });
</script>

The parent can set childRef.value.value = 'new', which updates the child’s internal ref. This is sometimes desired and sometimes not. If the value should be read-only from the parent, expose it as a computed with a getter only, or as a function that returns the value.


c. defineExpose versus alternatives

defineExpose() is one tool among several. The alternatives cover the same ground with different trade-offs.

Props and events

The declarative interface is props down and events up. A parent that needs to tell the child something sets a prop. A child that needs to tell the parent something emits an event. This is the default, and it should be the first choice.

<!-- Child -->
<script setup>
const props = defineProps({ open: Boolean });
const emit = defineEmits(['close']);
</script>

<template>
  <div v-if="props.open">
    <button @click="emit('close')">Close</button>
  </div>
</template>

The parent controls open and listens for close. No ref is needed.

The v-model pattern

For two-way binding of a value, v-model is the idiomatic interface. It compiles to a prop and an event, and the parent uses it like a built-in input.

<!-- Child -->
<script setup>
const model = defineModel();
</script>

<template>
  <input v-model="model" />
</template>
<!-- Parent -->
<MyInput v-model="text" />

defineModel is the modern replacement for the modelValue prop plus update:modelValue event pattern.

The template-ref pattern with defineExpose

When the parent needs to call a method, defineExpose is the tool. The decision is whether the interaction can be expressed declaratively. If it can, use props and events. If it cannot, expose a method.

InteractionRecommended Interface
Show/hide a modalv-model or open prop + close event
Focus an inputExposed focus() method
Validate a formExposed validate() method
Scroll to an itemExposed scrollTo(index) method
Play/pause mediaExposed play()/pause() methods
Two-way bound valuev-model
Reset a formExposed reset() method

The provide-inject alternative

For deep component trees, provide and inject pass values without prop drilling. A parent provides a value, and any descendant injects it. This is not a replacement for defineExpose, because inject is not an instance reference. But it is an alternative for sharing state.

<!-- Parent -->
<script setup>
provide('theme', ref('dark'));
</script>

<!-- Deep child -->
<script setup>
const theme = inject('theme');
</script>

The store alternative

For shared state across unrelated components, a store such as Pinia is the right tool. A store is not an instance; it is a module-level reactive object. Calling a store action from anywhere is idiomatic, and it does not require a ref to a component.

// store/counter.ts
export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  actions: {
    increment() { this.count++; },
  },
});
<script setup>
const counter = useCounterStore();
counter.increment();
</script>

The store replaces the need to reach into a sibling or a distant component.

The decision table

NeedUse
Pass data downProps
Pass data upEvents
Two-way bindv-model
Imperative action on a childdefineExpose
Share state across treeProvide/inject
Share state across appPinia

Complete Example Session

<!-- ============================================
     PART 1: A COMPONENT WITH AN EXPOSED METHOD
     ============================================ -->

<!-- MyInput.vue -->
<script setup>
import { ref } from 'vue';

const inputEl = ref(null);

function focus() {
  inputEl.value?.focus();
}

function selectAll() {
  inputEl.value?.select();
}

defineExpose({ focus, selectAll });
</script>

<template>
  <input ref="inputEl" type="text" />
</template>

<!-- ============================================
     PART 2: THE PARENT CALLS THE METHOD
     ============================================ -->

<!-- App.vue -->
<script setup>
import { ref } from 'vue';
import MyInput from './MyInput.vue';

const inputRef = ref(null);

function focusInput() {
  inputRef.value?.focus();
}
</script>

<template>
  <MyInput ref="inputRef" />
  <button @click="focusInput">Focus the input</button>
</template>

<!-- ============================================
     PART 3: EXPOSING A REF
     ============================================ -->

<!-- Counter.vue -->
<script setup>
import { ref } from 'vue';

const count = ref(0);
function increment() { count.value++; }

defineExpose({ count, increment });
</script>

<template>
  <p>{{ count }}</p>
</template>

<!-- Parent reads childRef.value.count (a number). -->

<!-- ============================================
     PART 4: EXPOSING A REACTIVE OBJECT
     ============================================ -->

<!-- Status.vue -->
<script setup>
import { reactive } from 'vue';

const state = reactive({ status: 'idle', progress: 0 });

function setProgress(p) { state.progress = p; }

defineExpose({ state, setProgress });
</script>

<template>
  <p>{{ state.status }}: {{ state.progress }}%</p>
</template>

<!-- Parent reads childRef.value.state.status. -->

<!-- ============================================
     PART 5: EXPOSING A COMPUTED VALUE
     ============================================ -->

<!-- Selector.vue -->
<script setup>
import { ref, computed } from 'vue';

const items = ref(['a', 'b', 'c']);
const index = ref(0);
const selected = computed(() => items.value[index.value]);

function next() { index.value = Math.min(index.value + 1, items.value.length - 1); }

defineExpose({ selected, next });
</script>

<template>
  <p>{{ selected }}</p>
</template>

<!-- Parent reads childRef.value.selected (the current item). -->

<!-- ============================================
     PART 6: FORM VALIDATION PATTERN
     ============================================ -->

<!-- LoginForm.vue -->
<script setup>
import { ref } from 'vue';

const email = ref('');
const password = ref('');
const errors = ref({});

function validate() {
  errors.value = {};
  if (!email.value.includes('@')) errors.value.email = 'Invalid email';
  if (password.value.length < 8) errors.value.password = 'Too short';
  return Object.keys(errors.value).length === 0;
}

function reset() {
  email.value = '';
  password.value = '';
  errors.value = {};
}

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

<template>
  <input v-model="email" placeholder="Email" />
  <input v-model="password" type="password" placeholder="Password" />
  <p v-if="errors.email">{{ errors.email }}</p>
  <p v-if="errors.password">{{ errors.password }}</p>
</template>

<!-- ============================================
     PART 7: THE PARENT USES validate()
     ============================================ -->

<!-- App.vue -->
<script setup>
import { ref } from 'vue';
import LoginForm from './LoginForm.vue';

const formRef = ref(null);

async function submit() {
  if (!formRef.value?.validate()) return;
  await fetch('/api/login', { method: 'POST', body: '...' });
  formRef.value.reset();
}
</script>

<template>
  <LoginForm ref="formRef" />
  <button @click="submit">Log in</button>
</template>

<!-- ============================================
     PART 8: MODAL PATTERN
     ============================================ -->

<!-- Modal.vue -->
<script setup>
import { ref } from 'vue';

const dialogEl = ref(null);

function show() { dialogEl.value?.showModal(); }
function hide() { dialogEl.value?.close(); }

defineExpose({ show, hide });
</script>

<template>
  <dialog ref="dialogEl"><slot /></dialog>
</template>

<!-- ============================================
     PART 9: TYPE SCRIPT TYPING FOR THE PARENT REF
     ============================================ -->

<!-- MyInput.vue (script setup with defineExpose) -->
<script setup lang="ts">
import { ref } from 'vue';

const inputEl = ref<HTMLInputElement | null>(null);

function focus() { inputEl.value?.focus(); }

defineExpose({ focus });
</script>

<!-- App.vue -->
<script setup lang="ts">
import { ref } from 'vue';
import MyInput from './MyInput.vue';

const inputRef = ref<InstanceType<typeof MyInput> | null>(null);

inputRef.value?.focus();  // Type-safe.
</script>

<!-- ============================================
     PART 10: WHAT TO EXPOSE AND WHAT TO HIDE
     ============================================ -->

<!-- Good: minimal, intentional, stable. -->
defineExpose({
  focus,       // Imperative action.
  validate,    // Imperative action.
  selected,    // Read-only computed.
});

<!-- Bad: leaking internals. -->
defineExpose({
  inputEl,     // Raw DOM node.
  internalCounter,  // Internal state.
  items,       // Internal data.
  setState,    // Low-level setter.
});

The ten parts showed an exposed method, the parent’s call, exposing a ref, a reactive object, a computed value, the form-validation pattern, the parent’s usage, the modal pattern, TypeScript typing for the ref, and the distinction between good and bad exposures.


Quick Reference

defineExpose API

AspectValue
WhereTop level of <script setup>
ArgumentObject with exposed properties
Exposed typesRefs, functions, reactive objects, computed, plain values
Parent accesschildRef.value.<property>
DefaultNothing is exposed
Availability<script setup> only

What to Expose

TypeExample
Imperative methodfocus(), reset(), validate()
Media controlplay(), pause(), seek()
NavigationscrollTo(index), next(), prev()
Read-only stateselected, isValid
Modal controlshow(), hide()

What Not to Expose

TypeExample
Raw DOM nodesinputEl, dialogEl
Internal state refscount, items
Low-level setterssetState, setItems
Implementation detailsAnything the parent cannot use safely

Interface Decision

NeedInterface
Data downProps
Data upEvents
Two-way valuev-model
Imperative child actiondefineExpose
Cross-tree stateProvide/inject
Global statePinia

Parent Ref Typing

FormType
Untypedref(null)
Typedref<InstanceType<typeof Child> | null>(null)

Best Practices

✅ Do This:

<!-- Expose only what the parent needs. -->
defineExpose({ focus, validate });  // ✅

<!-- Expose methods for imperative actions. -->
function focus() { inputEl.value?.focus(); }  // ✅

<!-- Expose a read-only computed for state. -->
const selected = computed(() => items.value[index.value]);
defineExpose({ selected });  // ✅

<!-- Use optional chaining on the parent side. -->
inputRef.value?.focus();  // ✅

<!-- Type the parent ref. -->
const inputRef = ref<InstanceType<typeof MyInput> | null>(null);  // ✅

<!-- Document the exposed API. -->
// Exposes: focus(), validate(), selected  // ✅

❌ Don’t Do This:

<!-- Don't expose everything. -->
defineExpose({
  inputEl, items, count, setState, internalFn,  // ❌
});

<!-- Don't use defineExpose for data flow. -->
defineExpose({ data });  // ❌ Use props

<!-- Don't call defineExpose conditionally. -->
if (x) defineExpose({ focus });  // ❌ Macro must be top-level

<!-- Don't forget optional chaining on the parent. -->
inputRef.value.focus();  // ❌ Null before mount

<!-- Don't expose the same thing through both props and expose. -->
props: ['value']; defineExpose({ value });  // ❌ Use one

Common Pitfalls

PitfallWhy It HappensFix
Parent ref is nullChild not mounted yetUse onMounted or optional chaining
Method not foundNot listed in defineExposeAdd it to the exposed object
Macro errordefineExpose imported or conditionalCall at top level, no import
Type errorParent ref not typedUse InstanceType<typeof Child>
Ref not unwrappedExposing the ref object itselfList it in the object; it unwraps
Read-only value mutatedExposed a writable refExpose a computed with a getter
Options API confusionMixing setup and OptionsUse expose option in Options API

Real-World Examples

1. Focus an Input

defineExpose({ focus });

2. Validate a Form

defineExpose({ validate, reset });

3. Control a Modal

defineExpose({ show, hide });

4. Scroll a List

defineExpose({ scrollTo });

5. Play/Pause Media

defineExpose({ play, pause });

6. Read-Only Selection

const selected = computed(() => items.value[index.value]);
defineExpose({ selected });

7. Parent Typed Ref

const childRef = ref<InstanceType<typeof Child> | null>(null);

8. Optional Chaining

childRef.value?.focus();

9. Options API Equivalent

expose: ['focus', 'reset'],

10. Test Usage

const wrapper = mount(MyInput);
wrapper.vm.focus();

Visual

The Exposed Boundary

┌────────────────────────────────────────────────────────────┐
│  defineExpose: THE PUBLIC INTERFACE                        │
│                                                            │
│  CHILD COMPONENT                                           │
│  ┌──────────────────────────────────────────────────┐     │
│  │  <script setup>                                   │     │
│  │    const inputEl = ref(null)      (private)       │     │
│  │    const internalState = ref({})  (private)       │     │
│  │    function focus() {}            (exposed)       │     │
│  │    function validate() {}         (exposed)       │     │
│  │    defineExpose({ focus, validate })              │     │
│  └──────────────────────────────────────────────────┘     │
│                        │                                   │
│                        │  Only focus and validate         │
│                        v                                   │
│  ┌──────────────────────────────────────────────────┐     │
│  │  PARENT ACCESS                                    │     │
│  │    childRef.value.focus()                         │     │
│  │    childRef.value.validate()                      │     │
│  │    childRef.value.inputEl    ← undefined          │     │
│  │    childRef.value.internalState ← undefined       │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  Everything not listed is invisible to the parent.         │
└────────────────────────────────────────────────────────────┘

The Interface Decision

┌────────────────────────────────────────────────────────────┐
│  CHOOSING THE INTERFACE                                    │
│                                                            │
│  What does the parent need?                                │
│                                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  Data passed down         → Props                 │     │
│  │  Notification up          → Events                │     │
│  │  Two-way value            → v-model               │     │
│  │  Imperative action        → defineExpose          │     │
│  │  Cross-tree sharing       → provide/inject        │     │
│  │  Global state             → Pinia                 │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  defineExpose is the last resort for imperative needs.     │
│  If the interaction can be declarative, use props/events.  │
└────────────────────────────────────────────────────────────┘

The Component Lifecycle and Refs

┌────────────────────────────────────────────────────────────┐
│  WHEN THE PARENT REF IS AVAILABLE                          │
│                                                            │
│  Parent setup()                                            │
│    childRef.value = null    ← Not mounted yet              │
│                                                            │
│         │  mount                                           │
│         v                                                  │
│  Child setup()                                             │
│    defineExpose({ focus })                                 │
│                                                            │
│         │  mount complete                                  │
│         v                                                  │
│  Parent onMounted()                                        │
│    childRef.value.focus()   ← Available                    │
│                                                            │
│  After unmount                                             │
│    childRef.value = null    ← Cleared                      │
│                                                            │
│  Always use optional chaining: childRef.value?.focus()     │
└────────────────────────────────────────────────────────────┘

Good vs Bad Exposure

┌────────────────────────────────────────────────────────────┐
│  WHAT TO EXPOSE                                            │
│                                                            │
│  GOOD                                                      │
│  ┌──────────────────────────────────────────────────┐     │
│  │  focus()          Imperative action               │     │
│  │  validate()       Imperative action               │     │
│  │  reset()          Imperative action               │     │
│  │  selected         Read-only computed              │     │
│  │  isValid          Read-only computed              │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  BAD                                                       │
│  ┌──────────────────────────────────────────────────┐     │
│  │  inputEl          Raw DOM node                    │     │
│  │  items            Internal data                   │     │
│  │  count            Internal state ref              │     │
│  │  setState()       Low-level setter                │     │
│  │  internalFn()     Implementation detail           │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  The exposed surface is the contract.                      │
│  Keep it minimal, intentional, and stable.                 │
└────────────────────────────────────────────────────────────┘

Summary

ItemValue
defineExpose()Compiler macro that publishes selected bindings
LocationTop level of <script setup>
DefaultNothing is exposed
Parent accessThrough a template ref
Exposed typesRefs (unwrapped), functions, reactive objects, computed
Options API equivalentexpose: ['focus']
TypingInstanceType<typeof Child>
Safe accessOptional chaining on the parent ref
Preferred alternativeProps, events, v-model

Key takeaways:

  • <script setup> components are closed by default. Nothing is reachable from the parent unless it is listed in defineExpose. This is deliberate: the public interface is a decision, not an accident.
  • defineExpose is for imperative operations. Focus, scroll, play, validate, reset, show, hide. These cannot be expressed as props because they are actions, not values.
  • The exposed surface is the component’s contract. It should be minimal, stable, and intentional. Internal state, raw DOM nodes, and low-level setters should not be exposed.
  • Refs are unwrapped in the exposed object. Exposing a ref gives the parent the value, not the ref object. Exposing a computed with a getter only gives the parent a read-only value.
  • The parent must handle the pre-mount null. The ref is null until the child mounts. Optional chaining or onMounted guards the call.
  • Prefer props and events when the interaction is declarative. defineExpose is the fallback for the cases where declarative does not work. If the parent wants to tell the child a value, use a prop. If the parent wants to know when something happens, listen to an event.
  • v-model is the idiomatic interface for two-way values. It compiles to a prop and an event, and it is what the parent expects for a bound input.
  • Type the parent ref. ref<InstanceType<typeof Child>> gives the parent a typed handle, and calling an unexposed method becomes a compile error. This is what makes the contract enforceable.

Remember: defineExpose() is the mechanism that opens a controlled window into a <script setup> component. Without it, the parent sees nothing. With it, the parent sees exactly what was listed. The interface is the contract, and it should contain only the imperative operations and read-only state that the parent genuinely needs. Use props for data flowing down, events for notifications flowing up, v-model for two-way bindings, and defineExpose only for the actions that have no declarative equivalent. The result is a component whose internals can change freely, whose public surface is documented by its exposure, and whose contract is enforced by TypeScript at compile time.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!