Vue 3's `expose`: Stop Letting Parent Components Poke Around in Your Component's Guts

Posted on Dec 10, 2025

You know that frustrating moment when you’re working with Vue components and suddenly a parent component is accessing internal methods it shouldn’t even know about? Yeah, that’s the problem Vue 3.2 solved with the expose feature.

Before Vue 3.2 came along, there was basically no component privacy. Everything you put in your setup function was fair game for parent components to grab. It’s like leaving your entire toolbox scattered on the workbench where anyone can grab whatever they want. Sure, it’s accessible, but it creates chaos. Parent components could depend on internal implementation details, refactoring became dangerous, and your component’s actual API—what you intended to expose—was buried under the noise.

The expose feature fixes this. It lets you explicitly say: “Here’s what I’m comfortable sharing with parent components. Everything else is off-limits.” It’s a small addition, but it fundamentally changes how you can design components. You get privacy by default, clarity about what’s public, and peace of mind knowing parent components can’t accidentally break your component’s internal logic.

If you’re building components for production—whether it’s a reusable component library or just trying to keep a large app maintainable—expose is one of those features that makes you wonder how you ever lived without it.

The Problem: Everything Was Public (And It Sucked)

Before Vue 3.2, component encapsulation didn’t exist. Let me show you what I mean.

In the Composition API, if you wrote something like this:

export default {
  setup() {
    const count = ref(0)
    const internalHelper = () => { /* something private */ }
    
    const increment = () => count.value++
    
    return {
      count,
      internalHelper,
      increment
    }
  }
}

Every single thing you returned—including internalHelper that you clearly only meant for internal use—was accessible to parent components through a template ref. There was no way to say “this is private.”

The Options API had the same problem. Every method, every computed property, every piece of data was just… out there. If someone needed to hack around in your component’s internals from the parent, they could.

This caused real problems:

  1. Refactoring became a nightmare - Change an internal method name? Time to hunt through your entire codebase for parent components secretly depending on it.

  2. Your API was unclear - Looking at a component, you couldn’t tell what was meant to be public versus what was implementation detail.

  3. Testing got messy - Tests would depend on internal methods, making tests brittle when you refactored.

  4. Library components were vulnerable - If you published a component library with no privacy, changing anything meant breaking users’ code.

Enter expose: Drawing a Clear Line

Vue 3.2 introduced a simple but powerful concept: components are private by default. If you want parent components to access something, you explicitly expose it. That’s it.

This single change is elegant because it flips the problem on its head. Instead of “how do I hide something?”, it’s “what do I intentionally want to share?”

How It Works: Three Different Ways

The approach depends on which component syntax you’re using. Let’s walk through them.

The Modern Way: Composition API with <script setup>

This is what you should be using for new projects. It’s clean, it’s clear, and it makes expose trivial:

<template>
  <div v-if="isOpen" class="modal">
    <div class="modal-content">
      <slot />
      <button @click="closeModal">Close</button>
    </div>
  </div>
</template>

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

const isOpen = ref(false)

function openModal() {
  isOpen.value = true
}

function closeModal() {
  isOpen.value = false
}

// That's it. Only these two functions are accessible from parent components.
defineExpose({
  openModal,
  closeModal,
})
</script>

See? You declare what gets exposed right there in defineExpose. Everything else—the isOpen state, any helper functions you don’t list—stays private. Parent components can’t access them, even if they try.

With TypeScript, you get an extra bonus: type safety for your exposed API:

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

const isOpen = ref(false)

function openModal() {
  isOpen.value = true
}

function closeModal() {
  isOpen.value = false
}

interface ModalAPI {
  openModal: () => void
  closeModal: () => void
}

defineExpose<ModalAPI>({
  openModal,
  closeModal,
})
</script>

Now TypeScript knows exactly what your component exposes. Parent components using your modal get full IDE autocomplete.

The Alternative: Composition API with setup() Function

Sometimes you need more control than <script setup> gives you. You can use the regular setup function with explicit context.expose():

export default {
  setup(props, { expose }) {
    const isOpen = ref(false)

    function openModal() {
      isOpen.value = true
    }

    function closeModal() {
      isOpen.value = false
    }

    expose({
      openModal,
      closeModal,
    })

    // Important: you still need to return what goes in the template
    return {
      isOpen,
      openModal,
      closeModal,
    }
  },
}

This is less common these days (most new projects use <script setup>), but it’s useful when you need dynamic exposure or complex initialization logic.

For Legacy Projects: Options API

If you’re maintaining a Vue 2 project migrating to Vue 3, or your team prefers the Options API, here’s the syntax:

export default {
  data() {
    return {
      isOpen: false,
      internalCounter: 0,
    }
  },
  methods: {
    openModal() {
      this.isOpen = true
    },
    closeModal() {
      this.isOpen = false
    },
    incrementCounter() {
      this.internalCounter++
    },
  },
  // Just list the method names you want to expose
  expose: ['openModal', 'closeModal'],
}

Notice that internalCounter and incrementCounter stay private. Parent components can’t touch them.

You can also expose computed properties:

export default {
  data() {
    return {
      count: 0,
    }
  },
  computed: {
    doubleCount() {
      return this.count * 2
    },
  },
  methods: {
    increment() {
      this.count++
    },
  },
  expose: ['increment', 'doubleCount'],
}

How Parent Components Use Exposed Methods

Once you’ve exposed something, parent components can access it through a template ref:

<template>
  <div>
    <button @click="openChildModal">Open Modal</button>
    <ChildModal ref="modalRef" />
  </div>
</template>

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

const modalRef = ref()

function openChildModal() {
  // Only exposed methods work
  modalRef.value.openModal() // ✅ Works
  
  // This would be undefined
  console.log(modalRef.value.isOpen) // ❌ undefined - not exposed
}
</script>

The parent can only call what you explicitly exposed. Trying to access anything else returns undefined.

When expose Was Added (And Why It Matters)

Vue 3.2 arrived on August 5, 2021, and it marked a turning point. Until then, <script setup> was experimental, and expose didn’t exist.

Before Vue 3.2, if you wanted any kind of privacy in a Composition API component, you had to use workarounds. The official solution—context.expose() in the setup function—existed, but nobody really used it. It felt clunky.

When Vue 3.2 made <script setup> official and added defineExpose, it changed how developers write components. Suddenly, privacy wasn’t a workaround anymore—it was the default.

The Gotcha: Top-Level Await and defineExpose

Here’s something that catches people off guard. When you use await at the top level of your <script setup>, defineExpose might not work correctly:

<script setup lang="ts">
// ❌ DON'T do this
const data = await fetch('/api/data').then(r => r.json())

function doSomething() {
  // perform operation
}

defineExpose({
  doSomething,
})
</script>

When you have top-level await, Vue wraps your component in a Suspense boundary. The exposed methods might not be properly available to parent components—or you’ll get weird behavior.

The fix is simple: move async operations to lifecycle hooks:

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

const data = ref(null)
const loading = ref(true)

function doSomething() {
  // perform operation
}

// ✅ This works - defineExpose at top level
defineExpose({
  doSomething,
})

// Handle async stuff here instead
onMounted(async () => {
  try {
    const response = await fetch('/api/data')
    data.value = await response.json()
  } finally {
    loading.value = false
  }
})
</script>

Keep defineExpose synchronous at the top level. Put your async stuff in onMounted or another lifecycle hook. Problem solved.

The Interesting Part: How Test Frameworks Treat Unexposed Methods

Here’s something that surprised a lot of people: when you write tests, unexposed methods aren’t accessible. This sounds like a limitation, but it’s actually a feature.

When you use Vue Test Utils with Vitest:

// Component: ChildComponent.vue
<script setup lang="ts">
const internalHelper = () => {
  return 'internal value'
}

const publicMethod = () => {
  return internalHelper()
}

defineExpose({
  publicMethod,
})
</script>

// Test: ChildComponent.spec.ts
import { mount } from '@vue/test-utils'
import ChildComponent from './ChildComponent.vue'

describe('ChildComponent', () => {
  it('should access exposed methods', () => {
    const wrapper = mount(ChildComponent)
    
    // ✅ This works
    expect(wrapper.vm.publicMethod()).toBe('internal value')
    
    // ❌ This returns undefined
    expect(wrapper.vm.internalHelper).toBeUndefined()
  })
})

You can only test what you explicitly exposed. Internal methods? Not accessible.

This might feel restrictive at first, but it’s actually good. It forces you to test your component’s actual public API—the interface it presents to users—rather than poking around in implementation details. When your implementation changes but the public API stays the same, your tests still pass. That’s exactly what you want.

The same applies to Options API components:

// Component
export default {
  methods: {
    internalHelper() {
      return 'internal value'
    },
    publicMethod() {
      return this.internalHelper()
    },
  },
  expose: ['publicMethod'],
}

// Test - same result, only publicMethod is accessible

Best Practices: Actually Using expose Right

Keep It Minimal

Expose only what you genuinely need. If you find yourself exposing 5+ methods, you probably have a design problem. Your component might be doing too much, or your component hierarchy might be wrong.

A good rule: if you can’t explain why something should be exposed, it shouldn’t be.

Prefer Props and Events

Before you reach for expose, ask yourself: can I do this with props and events?

Props flow downward. Events flow upward. This unidirectional data flow is Vue’s core design pattern, and it’s there for a reason—it makes your app easier to understand and debug.

Use expose only for imperative operations where the parent genuinely needs direct control:

  • Opening/closing modals or dialogs
  • Resetting form state
  • Controlling animations or playback
  • Focusing elements
  • Accessing third-party library instances

Everything else? Use props and events. Your future self will thank you.

Document It

If you’re building reusable components, make it clear what you’re exposing:

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

const isOpen = ref(false)

/**
 * Opens the modal
 * @example modalRef.value.openModal()
 */
function openModal() {
  isOpen.value = true
}

/**
 * Closes the modal
 * @example modalRef.value.closeModal()
 */
function closeModal() {
  isOpen.value = false
}

defineExpose({
  openModal,
  closeModal,
})
</script>

Good JSDoc comments make it clear what each method does and how to use it. This is especially important if other team members are using your components.

Real Examples: Where expose Shines

You want to trigger the modal from the parent without dealing with v-model complexity:

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

const confirmModalRef = ref()

async function handleDelete(item) {
  const confirmed = await confirmModalRef.value.show(
    `Delete ${item.name}?`
  )
  
  if (confirmed) {
    await deleteItem(item)
  }
}
</script>

<template>
  <div>
    <button @click="handleDelete(item)">Delete</button>
    <ConfirmModal ref="confirmModalRef" />
  </div>
</template>

The modal exposes a show() method that returns a promise. Parent says “show me this confirmation,” and waits for the answer. Clean, predictable, no prop drilling.

Form Component

Complex forms benefit from exposed reset and submit methods:

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

const formData = reactive({
  name: '',
  email: '',
  message: '',
})

const errors = ref({})

function reset() {
  formData.name = ''
  formData.email = ''
  formData.message = ''
  errors.value = {}
}

async function submit() {
  errors.value = {}
  
  if (!formData.name) {
    errors.value.name = 'Name is required'
    return false
  }
  
  // Submit logic
  return true
}

function validate() {
  return Object.keys(errors.value).length === 0
}

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

Parent can now programmatically reset the form, trigger validation, or submit without complex prop choreography.

Media Player Component

When you’re building a custom video player, expose the obvious imperative methods:

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

const videoRef = ref<HTMLVideoElement>()
const isPlaying = ref(false)

function play() {
  videoRef.value?.play()
  isPlaying.value = true
}

function pause() {
  videoRef.value?.pause()
  isPlaying.value = false
}

function setCurrentTime(time: number) {
  if (videoRef.value) {
    videoRef.value.currentTime = time
  }
}

function setVolume(volume: number) {
  if (videoRef.value) {
    videoRef.value.volume = Math.max(0, Math.min(1, volume))
  }
}

defineExpose({
  play,
  pause,
  setCurrentTime,
  setVolume,
})
</script>

Parent components get full control over playback without needing to access the raw video element.

When You Actually Need expose

Honestly? Most of the time you don’t.

I see a lot of developers reach for expose when they should be using props and events. It’s a tool for specific situations:

  • Building reusable UI libraries - Components that other teams depend on
  • Complex component hierarchies - Where prop drilling becomes a nightmare
  • Imperative APIs - When you genuinely need to trigger actions from the parent
  • Third-party library integration - When you’re wrapping external libraries and need to expose their methods

If you’re using expose constantly in your app, it might be a sign your component design needs rethinking.

The Bottom Line

expose is a small feature that solves a real problem: component privacy. Before it, there was no encapsulation. Now there is.

By default, everything is private. You explicitly choose what to share. This flips the mental model from “how do I hide things?” to “what do I intentionally want to expose?”

It makes your components safer to refactor, clearer to understand, and harder to accidentally break from the parent. And when you’re testing, you’re forced to test the actual public API, which leads to better tests.

For new projects, use Composition API with <script setup> and defineExpose. For legacy codebases, the Options API gives you the same capability through the expose property.

Remember: just because you can expose something doesn’t mean you should. Keep your API minimal, document it clearly, and prefer props and events when they fit.

That’s the Vue way—explicit, predictable, and maintainable.