Introduction

Vue 3 introduced several composition API features that change how we build components, and among these powerful additions lies a lesser-known gem: defineExpose. This compiler macro provides a clean and more effective way to expose specific methods and properties from child components to their parents, offering developers greater control over component interfaces.

 

What is defineExpose()?

defineExpose is a compile-time macro available in Vue 3's <script setup> syntax that allows you to explicitly define which properties and methods should be accessible to parent components through template refs. Unlike the Options API, where all methods and data are automatically exposed, the Composition API  <script setup> keeps everything private by default; that's where defineExpose becomes invaluable.

The problem defineExpose solves?

In traditional Vue 2 or options API components, parent components could access any method or property of child components through refs. However, with the composition API, this automatic exposure is disabled for better encapsulation. Sometimes you need to break this encapsulation intentionally, and that's exactly what defineExpose enables. For understanding the options API way, see the example below.

Options API example:

Child.vue

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

<script>
export default {
  data() {
    return { count: 0 }
  },
  methods: {
    increment() {
      this.count++
    }
  }
}
</script>

Parent.vue

<template>
  <Child ref="child" />
  <button @click="callChild">Increment From Parent</button>
</template>

<script>
import Child from './Child.vue'

export default {
  components: { Child },
  methods: {
    callChild() {
      // can directly access any data or method
      this.$refs.child.increment()
      console.log(this.$refs.child.count)
    }
  }
}
</script>

In this, the parent can call increment () or even read count without the child exposing it.

Basic Implementation of defineExpose()

Here's a simple example demonstrating how to use 

Child component (childComponent.vue):

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

const count = ref(0)
const message = ref('Hello from child!')

const increment = () => {
  count.value++
}

const reset = () => {
  count.value = 0
}

const updateMessage = (newMessage) => {
  message.value = newMessage
}

// Expose specific methods and properties
defineExpose({
  count,
  increment,
  reset,
  updateMessage
})
</script>

<template>
  <div>
    <p>Count: {{ count }}</p>
    <p>{{ message }}</p>
    <button @click="increment">Increment</button>
    <button @click="reset">Reset</button>
  </div>
</template>

Parent component (ParentComponent.vue):

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

const childRef = ref()

const handleParentIncrement = () => {
  childRef.value.increment()
}

const handleReset = () => {
  childRef.value.reset()
}

const changeChildMessage = () => {
  childRef.value.updateMessage('Updated from parent!')
}
</script>

<template>
  <div>
    <h2>Parent Component</h2>
    <button @click="handleParentIncrement">Increment from Parent</button>
    <button @click="handleReset">Reset from Parent</button>
    <button @click="changeChildMessage">Change Message</button>
    
    <ChildComponent ref="childRef" />
  </div>
</template>

 

Practical Use Cases

Form Validation

One of the most common use cases for defineExpose is creating reusable form components that can be validated from the parent components:

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

const props = defineProps(['modelValue', 'rules'])
const emit = defineEmits(['update:modelValue'])

const inputValue = computed({
  get: () => props.modelValue,
  set: (value) => emit('update:modelValue', value)
})

const errors = ref([])

const validate = () => {
  errors.value = []
  if (props.rules) {
    props.rules.forEach(rule => {
      const result = rule(inputValue.value)
      if (result !== true) {
        errors.value.push(result)
      }
    })
  }
  return errors.value.length === 0
}

const clearErrors = () => {
  errors.value = []
}

defineExpose({
  validate,
  clearErrors,
  hasErrors: computed(() => errors.value.length > 0)
})
</script>

 

Another excellent use case is modal components, where the parent needs to control the modal's visibility.

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

const isVisible = ref(false)

const show = () => {
  isVisible.value = true
}

const hide = () => {
  isVisible.value = false
}

const toggle = () => {
  isVisible.value = !isVisible.value
}

defineExpose({
  show,
  hide,
  toggle,
  isVisible: readonly(isVisible)
})
</script>

 

Best Practices

  1. Be selective:
    Only expose what's necessary. The principle of least privilege applies here - expose only the methods and properties that parent components genuinely need to access.
  2. Use Descriptive Names:
    Choose clear, descriptive names for exposed methods that clearly indicate their purpose and expected behavior.
  3. Consider readonly for state:
    When exposing state, consider using readonly() to prevent parent components from directly mutating the state
    <script setup>
    import { ref, readonly } from 'vue'
    
    const internalState = ref('some value')
    
    defineExpose({
      state: readonly(internalState),
      updateState: (newValue) => {
        internalState.value = newValue
      }
    })
    </script>

Alternative Approaches

While defineExpose is powerful, consider whether it's the right solution. Sometimes, proper event emission or props passing might be more appropriate.

  • Events: For communicating state changes upward
  • Props: For passing data downward
  • Provide/Inject: For deep component tree communication
  • State management: for complex application state

TypeScript Support

defineExpose works seamlessly with TypeScript; you can define interfaces for better type safety:

<script setup lang="ts">
interface ExposedAPI {
  validate: () => boolean
  reset: () => void
  value: string
}

const validate = (): boolean => {
  // validation logic
  return true
}

const reset = (): void => {
  // reset logic
}

const value = ref('')

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

 

Conclusion

defineExpose is a valuable addition to the Vue 3 toolkit that strikes a balance between component encapsulation and necessary parent-child communication. Remember that with great power comes great responsibility – use defineExpose thoughtfully to maintain clean component architecture while solving real communication challenges in your Vue 3 applications.

The key is understanding when to use it versus other communication patterns, ensuring your components remain maintainable and your application architecture stays clean and predictable.