Two-Way Binding

vue.js logo

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-model approach
  • 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:

  1. Accepting a prop from the parent (data flows down)
  2. 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')">&times;</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 to formData.name
  • v-model="formData.details" - Binds the textarea to formData.details
  • v-model="formData.date" - Binds the date input to formData.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 formData object! 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:

  1. Accept a prop from the parent (data flows down)
  2. Watch the prop to update local state
  3. 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 the watch function needed to observe prop and data changes
  • modelValue prop - Accepts form data from the parent component (Vue convention for v-model binding)
  • "update:modelValue" emit - Declares the event that will notify the parent when form data changes
  • formData ref - Creates a local reactive copy of the prop data using the spread operator
  • watch() - Watches formData and emits update:modelValue to the parent when local data changes (data flows up)
    • deep: true ensures 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 the EventForm component and passed to the App.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 modelValue prop
  • Creates an update:modelValue emit
  • Returns a ref() that you can use directly with v-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 a ref(), you can use it directly with v-model in 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-bind to pass data down and v-on to 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-model to the reactive data object
  • Parent usage: Use v-model on 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!