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.
| Interaction | Recommended Interface |
|---|---|
| Show/hide a modal | v-model or open prop + close event |
| Focus an input | Exposed focus() method |
| Validate a form | Exposed validate() method |
| Scroll to an item | Exposed scrollTo(index) method |
| Play/pause media | Exposed play()/pause() methods |
| Two-way bound value | v-model |
| Reset a form | Exposed 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
| Need | Use |
|---|---|
| Pass data down | Props |
| Pass data up | Events |
| Two-way bind | v-model |
| Imperative action on a child | defineExpose |
| Share state across tree | Provide/inject |
| Share state across app | Pinia |
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
| Aspect | Value |
|---|---|
| Where | Top level of <script setup> |
| Argument | Object with exposed properties |
| Exposed types | Refs, functions, reactive objects, computed, plain values |
| Parent access | childRef.value.<property> |
| Default | Nothing is exposed |
| Availability | <script setup> only |
What to Expose
| Type | Example |
|---|---|
| Imperative method | focus(), reset(), validate() |
| Media control | play(), pause(), seek() |
| Navigation | scrollTo(index), next(), prev() |
| Read-only state | selected, isValid |
| Modal control | show(), hide() |
What Not to Expose
| Type | Example |
|---|---|
| Raw DOM nodes | inputEl, dialogEl |
| Internal state refs | count, items |
| Low-level setters | setState, setItems |
| Implementation details | Anything the parent cannot use safely |
Interface Decision
| Need | Interface |
|---|---|
| Data down | Props |
| Data up | Events |
| Two-way value | v-model |
| Imperative child action | defineExpose |
| Cross-tree state | Provide/inject |
| Global state | Pinia |
Parent Ref Typing
| Form | Type |
|---|---|
| Untyped | ref(null) |
| Typed | ref<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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Parent ref is null | Child not mounted yet | Use onMounted or optional chaining |
| Method not found | Not listed in defineExpose | Add it to the exposed object |
| Macro error | defineExpose imported or conditional | Call at top level, no import |
| Type error | Parent ref not typed | Use InstanceType<typeof Child> |
| Ref not unwrapped | Exposing the ref object itself | List it in the object; it unwraps |
| Read-only value mutated | Exposed a writable ref | Expose a computed with a getter |
| Options API confusion | Mixing setup and Options | Use 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
| Item | Value |
|---|---|
defineExpose() | Compiler macro that publishes selected bindings |
| Location | Top level of <script setup> |
| Default | Nothing is exposed |
| Parent access | Through a template ref |
| Exposed types | Refs (unwrapped), functions, reactive objects, computed |
| Options API equivalent | expose: ['focus'] |
| Typing | InstanceType<typeof Child> |
| Safe access | Optional chaining on the parent ref |
| Preferred alternative | Props, events, v-model |
Key takeaways:
<script setup>components are closed by default. Nothing is reachable from the parent unless it is listed indefineExpose. This is deliberate: the public interface is a decision, not an accident.defineExposeis 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
refgives the parent the value, not the ref object. Exposing acomputedwith a getter only gives the parent a read-only value. - The parent must handle the pre-mount null. The ref is
nulluntil the child mounts. Optional chaining oronMountedguards the call. - Prefer props and events when the interaction is declarative.
defineExposeis 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-modelis 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!