Back to home

Deep Dive into Vue 3 v-model and Dialog Component Encapsulation

167 min read

Understanding v-model in Vue 3

The Essence of v-model

In Vue 3, v-model is essentially syntactic sugar that expands into two parts:

  • A modelValue prop
  • An update:modelValue event
<!-- Using v-model -->
<ChildComponent v-model="searchText" />

<!-- Equivalent to -->
<ChildComponent 
  :modelValue="searchText"
  @update:modelValue="newValue => searchText = newValue"
/>

Custom v-model Names

Vue 3 allows customizing v-model names for better clarity and multiple bindings:

<!-- Parent component -->
<ChildComponent v-model:visible="isVisible" />

<!-- Equivalent to -->
<ChildComponent 
  :visible="isVisible"
  @update:visible="newValue => isVisible = newValue"
/>

Multiple v-models

Vue 3 supports multiple v-models on a single component:

<UserDialog
  v-model:visible="isVisible"
  v-model:username="username"
  v-model:age="age"
/>

Implementing a Reusable Dialog Component

Complete Dialog Component Implementation

<template>
  <el-dialog
    v-model="dialogVisible"
    title="Add Category"
    width="400px"
    destroy-on-close
    center
    @close="closeDialog"
  >
    <el-form :model="form" :rules="rules" ref="formRef">
      <el-form-item label="Category Name" :label-width="formLabelWidth" prop="categoryName">
        <el-input v-model="form.categoryName" placeholder="Please enter category name" />
      </el-form-item>
    </el-form>
    <template #footer>
      <div class="dialog-footer">
        <el-button @click="closeDialog">Cancel</el-button>
        <el-button type="primary" @click="confirmAddCategory">Confirm</el-button>
      </div>
    </template>
  </el-dialog>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import type { FormInstance } from 'element-plus'

const props = defineProps({
  visible: {
    type: Boolean,
    required: true
  },
  formLabelWidth: {
    type: String,
    default: '80px'
  }
})

const emits = defineEmits(['update:visible', 'addCategory'])

const dialogVisible = computed({
  get() {
    return props.visible
  },
  set(value) {
    emits('update:visible', value)
  }
})

const formRef = ref<FormInstance>()
const form = ref({
  categoryName: ''
})

const rules = {
  categoryName: [
    { required: true, message: 'Please enter category name', trigger: 'blur' },
    { min: 2, max: 50, message: 'Length should be 2 to 50 characters', trigger: 'blur' }
  ]
}

const closeDialog = () => {
  dialogVisible.value = false
  form.value.categoryName = ''
  formRef.value?.resetFields()
}

const confirmAddCategory = () => {
  if (!formRef.value) return
  
  formRef.value.validate((valid: boolean) => {
    if (valid) {
      emits('addCategory', form.value.categoryName.trim())
      closeDialog()
    }
  })
}
</script>

Key Concepts Explained

Two-way Binding with Computed Properties

// Basic version
const dialogVisible = computed({
  get() {
    return props.visible
  },
  set(value) {
    emits('update:visible', value)
  }
})

// Complete version with TypeScript
const dialogVisible = computed<boolean>({
  get() {
    return props.visible
  },
  set(value: boolean) {
    emits('update:visible', value)
  }
})

TypeScript Integration

// Props type definition
interface Props {
  visible: boolean
  modelValue?: string
}

// Emits type definition
interface Emits {
  (e: 'update:visible', value: boolean): void
  (e: 'update:modelValue', value: string): void
  (e: 'close'): void
}

// Implementation
const props = withDefaults(defineProps<Props>(), {
  modelValue: ''
})

const emits = defineEmits<Emits>()

Best Practices

Default Props Configuration

const props = withDefaults(defineProps<Props>(), {
  visible: false,
  modelValue: '',
  width: '400px'
})

State Reset Handling

const resetState = () => {
  // Reset form
  formRef.value?.resetFields()
  
  // Reset local state
  form.value = {
    categoryName: ''
  }
  
  // Notify parent
  emits('update:modelValue', '')
}

const handleClose = () => {
  resetState()
  emits('close')
}

Component Methods Exposure

const open = () => {
  dialogVisible.value = true
}

const close = () => {
  dialogVisible.value = false
}

defineExpose({
  open,
  close
})

Usage Example

<template>
  <div>
    <el-button @click="openDialog">Open Dialog</el-button>
    
    <CategoryDialog
      v-model:visible="dialogVisible"
      @addCategory="handleAddCategory"
    />
  </div>
</template>

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

const dialogVisible = ref(false)

const openDialog = () => {
  dialogVisible.value = true
}

const handleAddCategory = (category: string) => {
  console.log('New category:', category)
  dialogVisible.value = false
}
</script>

Tips and Common Pitfalls

  1. Always use computed properties for v-model implementations to ensure proper reactivity

  2. Reset state properly when closing the dialog to prevent stale data

  3. Type your emits and props for better development experience and error catching

  4. Use meaningful v-model names when implementing multiple v-models

  5. Consider component lifecycle when handling state resets and cleanup

  6. Implement proper validation before emitting events to the parent component

Conclusion

Vue 3's v-model implementation provides a flexible and powerful way to handle two-way data binding in components. When combined with TypeScript and proper component design patterns, it enables the creation of robust and reusable components like dialogs. Remember to follow the best practices outlined above to ensure your components are maintainable and type-safe.