Two-Way Binding
Lesson objectives
In the previous lessons, we've created a form modal with inputs, but these inputs don't currently capture or store any data. In this lesson, we will:
- Understand what two-way data binding is and why it's useful
- Implement two-way binding using the traditional
v-modelapproach - Refactor to use the modern
defineModel()macro (recommended approach) - Bind form data to capture user input for event name, details, date, and background color
What is two-way binding?
Two-way binding is a feature that allows data to flow in both directions:
- Down: From parent component to child component (via
props) - Up: From child component to parent component (via
events)
This is particularly useful for form inputs where you want the input value to be controlled by a parent component, but also allow the child component to update that value when the user types.
In Vue, a common way to achieve two-way binding is using v-model. However, when working with custom components, we need to implement two-way binding manually or use the defineModel() macro.
The current state
Currently, our EventForm component has form inputs, but they don't store any data:
<input
id="name"
type="text"
required
placeholder="Enter event name"
/>
When a user types in these inputs nothing happens, the data isn't stored anywhere. We need to bind these inputs to reactive data.
Traditional approach
The traditional way to implement two-way binding in a custom component involves:
- Accepting a prop from the parent (data flows down)
- Emitting an event when the value changes (data flows up)
Let's implement this step by step.
Step 1: Create form data in the child component
First, we'll create a reactive formData object in EventForm.vue to store the form values:
<script setup>
// 1. import ref from vue
import { ref } from "vue";
const props = defineProps({
show: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(["close"]);
// 2. Create form data object
const formData = ref({
name: "",
details: "",
date: "",
background: "linear-gradient(135deg, #FF416C, #FF4B2B)",
});
const backgroundOptions = [
"linear-gradient(135deg, #FF416C, #FF4B2B)",
"linear-gradient(135deg, #11998e, #38ef7d)",
"linear-gradient(135deg, #4E65FF, #92EFFD)",
"linear-gradient(135deg, #FC466B, #3F5EFB)",
"linear-gradient(135deg, #009FFF, #ec2F4B)",
"linear-gradient(135deg, #654ea3, #eaafc8)",
"linear-gradient(135deg, #667eea, #764ba2)",
"linear-gradient(135deg, #f093fb, #f5576c)",
];
</script>
Step 2: Bind inputs with v-model
Now we can bind the form inputs to our formData using v-model:
<template>
<div v-if="show" class="modal-overlay" @click="emit('close')">
<div class="modal" @click.stop>
<div class="modal-header">
<h2>Add New Event</h2>
<button class="close-button" @click="emit('close')">×</button>
</div>
<form class="modal-form">
<div class="form-group">
<label for="name">Event Name *</label>
<!-- add v-model -->
<input
id="name"
v-model="formData.name"
type="text"
required
placeholder="Enter event name"
/>
</div>
<div class="form-group">
<label for="details">Details</label>
<!-- add v-model -->
<textarea
id="details"
v-model="formData.details"
placeholder="Enter event details"
rows="3"
></textarea>
</div>
<div class="form-group">
<label for="date">Date *</label>
<!-- add v-model -->
<input id="date" v-model="formData.date" type="date" required />
</div>
<div class="form-group">
<label>Background Color</label>
<div class="color-options">
<!-- bind formData background, and @click -->
<div
v-for="(bg, index) in backgroundOptions"
:key="index"
class="color-option"
:class="{ active: formData.background === bg }"
:style="{ background: bg }"
@click="formData.background = bg"
></div>
</div>
</div>
<div class="form-actions">
<button type="button" class="cancel-button" @click="emit('close')">
Cancel
</button>
<button type="submit" class="submit-button">Add Event</button>
</div>
</form>
</div>
</div>
</template>
Key changes:
v-model="formData.name"- Binds the input toformData.namev-model="formData.details"- Binds the textarea toformData.detailsv-model="formData.date"- Binds the date input toformData.date:class="{ active: formData.background === bg }"- Highlights the selected background color@click="formData.background = bg"- Updates the background when a color is clicked
Try typing in the form inputs, the data is now being captured in the
formDataobject! You can see this by adding{{ formData }}temporarily to see the values update as you type.
Step 3: Two-way binding with parent component
If we want the parent component (App.vue) to control the form data, we need to:
- Accept a prop from the parent (data flows down)
- Watch the prop to update local state
- Emit an event when the value changes (data flows up)
Here's how we would implement this, starting with the EventForm component:
<script setup>
// 1. import vue watch function
import { ref, watch } from "vue";
const props = defineProps({
show: {
type: Boolean,
required: true,
},
// 2. accept form data as a prop (will pass prop next)
modelValue: {
type: Object,
default: () => ({
name: "",
details: "",
date: "",
background: "linear-gradient(135deg, #FF416C, #FF4B2B)",
}),
},
});
// 3. declare event
const emit = defineEmits(["close", "update:modelValue"]);
// 4. create local copy of the event
const formData = ref({ ...props.modelValue });
// 5. watch for changes in formData and emit to parent
watch(
formData,
(newData) => {
emit("update:modelValue", newData);
},
{ deep: true }
);
const backgroundOptions = [
"linear-gradient(135deg, #FF416C, #FF4B2B)",
"linear-gradient(135deg, #11998e, #38ef7d)",
"linear-gradient(135deg, #4E65FF, #92EFFD)",
"linear-gradient(135deg, #FC466B, #3F5EFB)",
"linear-gradient(135deg, #009FFF, #ec2F4B)",
"linear-gradient(135deg, #654ea3, #eaafc8)",
"linear-gradient(135deg, #667eea, #764ba2)",
"linear-gradient(135deg, #f093fb, #f5576c)",
];
</script>
Key changes in this approach:
import { ref, watch } from "vue"- Imports thewatchfunction needed to observe prop and data changesmodelValueprop - Accepts form data from the parent component (Vue convention forv-modelbinding)"update:modelValue"emit - Declares the event that will notify the parent when form data changesformDataref - Creates a local reactive copy of the prop data using the spread operator- watch() - Watches
formDataand emitsupdate:modelValueto the parent when local data changes (data flows up)deep: trueensures nested property changes are detected
Then in the parent component (App.vue), you would use it like this:
<EventForm
:show="showModal"
v-model="formData"
@close="closeModal"
/>
{{ formData }}is now updated in theEventFormcomponent and passed to theApp.vue.
This approach works, but it's verbose and requires watchers. Vue 3.4+ provides a better solution.
Modern approach using defineModel
The defineModel() macro (introduced in Vue 3.4+) simplifies two-way binding significantly. It automatically:
- Creates a prop
- Creates an emit event
- Handles the synchronization
Step 1: Using defineModel
Update EventForm.vue to use defineModel():
<script setup>
const props = defineProps({
show: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(["close"]);
// Use defineModel for two-way binding
const formData = defineModel({
type: Object,
default: () => ({
name: "",
details: "",
date: "",
background: "linear-gradient(135deg, #FF416C, #FF4B2B)",
}),
});
const backgroundOptions = [
"linear-gradient(135deg, #FF416C, #FF4B2B)",
"linear-gradient(135deg, #11998e, #38ef7d)",
"linear-gradient(135deg, #4E65FF, #92EFFD)",
"linear-gradient(135deg, #FC466B, #3F5EFB)",
"linear-gradient(135deg, #009FFF, #ec2F4B)",
"linear-gradient(135deg, #654ea3, #eaafc8)",
"linear-gradient(135deg, #667eea, #764ba2)",
"linear-gradient(135deg, #f093fb, #f5576c)",
];
</script>
That's it! Using defineModel() automatically:
- Creates a
modelValueprop - Creates an
update:modelValueemit - Returns a
ref()that you can use directly withv-model
Leaving a much simpler component structure.
Step 2: Using v-model in the parent
In App.vue, you can now use v-model directly:
<template>
<div class="events-container">
<!-- ... other content ... -->
<EventForm
:show="showModal"
v-model="formData"
@close="closeModal"
/>
<!-- formData is directly updated and accessible -->
{{ formData }}
</div>
</template>
Note: Since
defineModel()returns aref(), you can use it directly withv-modelin the template. The changes will automatically sync with the parent component!
Benefits of defineModel
The defineModel() approach is:
- Simpler: Less boilerplate code
- Cleaner: No need for manual watchers
- Type-safe: Better TypeScript support
- Standard: Follows Vue 3.4+ best practices
Summary
Two-way binding allows data to flow both ways between parent and child components:
- Traditional approach: Use
v-bindto pass data down andv-onto emit events up, with watchers to sync state - Modern approach: Use
defineModel()macro which automatically handles prop creation, event emission, and synchronization - Form inputs: Bind inputs using
v-modelto the reactive data object - Parent usage: Use
v-modelon the component in the parent template
In the next lesson, we'll use this bound form data to actually submit and add new events to our event list!