Chapter 4. Interactions Between Components

In Chapter 3, we deep-dived into composing a component with lifecycle hooks, computed properties, watchers, methods, and other features. We also learned about the power of slots and how to receive external data from other components using props.

Based on that foundation, this chapter guides you on how to build the interactions between components using custom events and provide/inject patterns. It also introduces Teleport API, which allows you to move elements around the DOM tree while keeping their order of appearance inside a Vue component.

Nested Components and Data Flow in Vue

Vue components can nest other Vue components inside them. This feature is handy in allowing users to organize their code into smaller, manageable, and reusable pieces in a complex UI project. We call nested elements child components and the component containing them their parent component.

Data flow in a Vue application is unidirectional by default, which means that the parent component can pass data to its child component but not the other way around. The parent can pass data to the child component using props (discussed briefly in “Exploring the Options API”), and the child component can emit events back to the parent component using custom events emits. Figure 4-1 demonstrates the data flow between components.

A diagram shows the one-way data flow between components
Figure 4-1. One-way data flow in Vue components

Passing Functions as Props

Unlike other frameworks, Vue does not allow you to pass a function as a prop to the child component. Instead, you can bind the function as a custom event emitter (see “Communication Between Components with Custom Events).

Using Props to Pass Data to Child Components

In the form of an object or array, the props field of a Vue component contains all the available data properties that the component can receive from its parent. Each property of props is a prop of the target component. To start receiving data from the parent, you need to declare the props field in the component’s options object, as shown in Example 4-1.

Example 4-1. Defining props in a component
export default {
  name: 'ChildComponent',
  props: {
    name: String
  }
}

In Example 4-1, the ChildComponent component accepts a name prop of type String. The parent component then can pass data to the child component using this name prop, as shown in Example 4-2.

Example 4-2. Passing static data as props to a child component
<template>
  <ChildComponent name="Red Sweater" />
</template>
<script lang="ts">
import ChildComponent from './ChildComponent.vue'
export default {
  name: 'ParentComponent',
  components: {
    ChildComponent
  },
}
</script>

The ChildComponent receives a static “Red Sweater” as a name value in the previous example. If you want to pass and bind a dynamic data variable to name, such as the first element in the children list, you can use the v-bind attribute, denoted by :, as shown in Example 4-3.

Example 4-3. Passing dynamic variables as props to a child component
<template>
  <ChildComponent :name="children[0]" />
</template>
<script lang="ts">
import ChildComponent from './ChildComponent.vue'
export default {
  //...
  data() {
    return {
      children: ['Red Sweater', 'Blue T-Shirt', 'Green Hat']
    }
  }
}
</script>

The output for the previous code is the same as passing a static string, Red Sweater, to the name prop.

Note

If the name prop is not of type String, you still need to use the v-bind attribute (or :) to pass static data to the child component, such as :name="true" for Boolean, or :name="["hello", "world"]" for Array type.

In Example 4-3, whenever the value of children[0] changes, Vue will also update the name prop in the ChildComponent, and the child component will re-render its content if needed.

If you have more than one prop in the child component, you can follow the same approach and pass each data to the relevant prop. For instance, to pass name and price of a product to the ProductComp component, you can perform this (Example 4-4).

Example 4-4. Passing multiple props to a child component
/** components/ProductList.vue */
<template>
  <ProductComp :name="product.name" :price="product.price" />
</template>
<script lang="ts">
import ProductComp from './ProductComp.vue'
export default {
  name: 'ProductList',
  components: {
    ProductComp
  },
  data() {
    return {
      product: {
        name: 'Red Sweater',
        price: 19.99
      }
    }
  }
}
</script>

And we can define the ProductComp component as in Example 4-5.

Example 4-5. Defining multiple props in ProductComp
<template>
  <div>
    <p>Product: {{ name }}</p>
    <p>Price: {{ price }}</p>
  </div>
</template>
<script lang="ts">
export default {
  name: 'ProductComp',
  props: {
    name: String,
    price: Number
  }
}
</script>

The output will be as follows:

Product: Red Sweater
Price: 19.99

Alternatively, you can use v-bind (not :) to pass the entire object user and have its properties bound to the relevant child component’s props:

<template>
  <ProductComp v-bind="product" />
</template>

Note that only the child component will receive the relevant declared props. Hence, if you have another field, product.description, in the parent component, it will not be available for access in the child component.

Note

Another approach to declare your component’s props is to use an array of strings, each representing the name of the prop it accepts, such as props: ["name", "price"]. This approach is practical when you want to prototype a component quickly. However, I strongly recommend you use the object form of props and declare all your props with types, as a good practice for code readability and bug prevention.

We have learned how to declare props with types, but how do we validate the data passed to the child’s props when needed? How can we set a fallback value for a prop when no value is passed? Let’s find out next.

Declaring Prop Types with Validation and Default Values

Back in Example 4-1, we declared the name prop as a String type. Vue will warn if the parent component passes a non-string value to the name prop during run-time. However, to be able to enjoy the benefit of Vue’s type validation, we should use the full declaration syntax:

{
  type: String | Number | Boolean | Array | Object | Date | Function | Symbol,
  default?: any,
  required?: boolean,
  validator?: (value: any) => boolean
}

In which:

  • type is the type of prop. It can be a constructor function (or custom class) or one of the built-in types.

  • default is the prop’s default value if no value is passed. For types Object, Function, and Array, the default value must be a function that returns the initial value.

  • required is a boolean value indicating whether the prop is mandatory. If required is true, the parent component must pass a value to the prop. By default, all props are optional.

  • validator is a function that validates the value passed to the prop, mainly for development debugging.

We can declare the name prop to be more specific, including a default value, as shown in Example 4-6.

Example 4-6. Defining prop as a string with a default value
export default {
  name: 'ChildComponent',
  props: {
    name: {
      type: String,
      default: 'Child component'
    }
  }
}

If the parent component does not pass a value, the child component will fall back to the default value “Child component” for the name prop.

We can also set name as a mandatory prop for the child component and add a validator for its received data, as shown in Example 4-7.

Example 4-7. Defining name as required with a prop validator
export default {
  name: 'ChildComponent',
  props: {
    name: {
      type: String,
      required: true,
      validator: value => value !== "Child component"
    }
  }
}

In this scenario, if the parent component does not pass a value to the name prop, or the given value matches Child component, Vue will throw a warning in development mode (Figure 4-2).

Screenshot of console warning for failed name prop validation
Figure 4-2. Console warning in development for failed prop validation
Note

For the default field, the Function type is a function that returns the initial value of the prop. You can’t use it to pass data back to the parent component or to trigger data changes on the parent level.

In addition to the built-in types and validation provided by Vue, you can combine a JavaScript Class or a function constructor and TypeScript to create your custom prop type. I’ll cover them in the next section.

Declaring Props with Custom Type Checking

Using primitive types like Array, String, or Object suits the essential use case. However, as your application grows, primitive types can be too generic to keep your component’s type safe. Take a PizzaComponent with the following template code:

<template>
  <header>Title: {{ pizza.title }}</header>
  <div class="pizza--details-wrapper">
    <img :src="pizza.image" :alt="pizza.title" width="300" />
    <p>Description: {{ pizza.description }}</p>
    <div class="pizza--inventory">
      <div class="pizza--inventory-stock">Quantity: {{pizza.quantity}}</div>
      <div class="pizza--inventory-price">Price: {{pizza.price}}</div>
    </div>
  </div>
</template>

This component accepts a mandatory pizza prop, which is an Object containing some details about the pizza:

export default {
  name: 'PizzaComponent',
  props: {
    pizza: {
      type: Object,
      required: true
    }
  }
}

Straightforward enough. However, by declaring pizza as an Object type, we assume the parent will always pass the suitable object with the appropriate fields (title, image, description, quantity, and price) required for a pizza to render.

This assumption can lead to a problem. Since pizza accepts data of type Object, any component that uses PizzaComponent can pass any object data to the prop pizza without the actual fields needed for a pizza, as in Example 4-8.

Example 4-8. Using Pizza component with wrong data
<template>
  <div>
    <h2>Bad usage of Pizza component</h2>
    <pizza-component :pizza="{ name: 'Pinia', description: 'Hawaiian pizza' }" />
  </div>
</template>

The preceding code results in a broken UI render of PizzaComponent, where only a description is available, and the rest of the fields are empty (with a broken image), as shown in Figure 4-3.

Screenshot of a pizza without title, price, quantity and image rendered
Figure 4-3. Broken UI with no image link and missing fields for a pizza

TypeScript won’t be able to detect the data type mismatch here either, as it performs the type checking according to the declared type of pizza: the generic Object. Another potential problem is that passing pizza in the wrong nest properties format can cause the app to crash. Therefore, to avoid such accidents, we use custom type declarations.

We can define the Pizza class and declare the prop pizza of type Pizza as shown in Example 4-9.

Example 4-9. Declaring a Pizza custom type
class Pizza {
  title: string;
  description: string;
  image: string;
  quantity: number;
  price: number;

  constructor(
    title: string,
    description: string,
    image: string,
    quantity: number,
    price: number
  ) {
    this.title = title
    this.description = description
    this.image = image
    this.quantity = quantity
    this.price = price
  }
}

export default {
  name: 'PizzaComponent',
  props: {
    pizza: {
      type: Pizza, 1
      required: true
    }
  }
}
1

Declare the type of pizza props as Pizza directly

Alternatively, you can use TypeScript’s interface or type to define your custom type instead of Class. However, in such scenarios, you must use type PropType from the vue package, with the following syntax, to map the declared type to the target prop:

type: Object as PropType<Your-Custom-Type>

Let’s rewrite the Pizza class as an interface instead (Example 4-10).

Example 4-10. Declaring a Pizza custom type using the TypeScript interface API
import type { PropType } from 'vue'

interface Pizza {
  title: string;
  description: string;
  image: string;
  quantity: number;
  price: number;
}

export default {
  name: 'PizzaComponent',
  props: {
    pizza: {
      type: Object as PropType<Pizza>, 1
      required: true
    }
  }
}
1

Declare the type of pizza props as Pizza interface with PropType help.

When you use PizzaComponent with the wrong data format, TypeScript will detect and highlight the error appropriately.

Note

Vue performs type validation during run-time, while TypeScript performs type checking during compile-time. Hence, it is a good practice to use both Vue’s type checking and TypeScript’s type checking to ensure your code is bug-free.

Declaring Props Using defineProps() and withDefaults()

As we learned in “setup”, starting with Vue 3.x, Vue offers <script setup> syntax for declaring a functional component without the classic Options API. Within this <script setup> block, you can use defineProps() to declare props, as shown in Example 4-11.

Example 4-11. Props declaration with defineProps() and <script setup>
<script setup>
import { defineProps } from 'vue'

const props = defineProps({
  name: {
    type: String,
    default: "Hello from the child component."
  }
})
</script>

Thanks to TypeScript, we can also declare the accepted type for defineProps() per component with type validation on compile-time, as shown in Example 4-12.

Example 4-12. Props declaration with defineProps() and TypeScript type
<script setup >
import { defineProps } from 'vue'

type ChildProps = {
  name?: string
}

const props = defineProps<ChildProps>()
</script>

In this case, to declare the default value of the message prop, we need to wrap the defineProps() call with withDefaults(), as in Example 4-13.

Example 4-13. Props declaration with defineProps() and withDefaults()
import { defineProps, withDefaults } from 'vue'

type ChildProps = {
  name?: string
}

const props = withDefaults(defineProps<ChildProps>(), {
  name: 'Hello from the child component.'
})

Using defineProps() with TypeScript Type Checking

We can’t combine run-time and compile-time type checking when using defineProps(). I recommend using defineProps() in the approach in Example 4-11, for better readability and a combination of both Vue and TypeScript type checking.

We have learned how to declare props for passing raw data in a Vue component, with type checking and validation. Next, we will explore how to pass functions as custom event emitters to a child component.

Communication Between Components with Custom Events

Vue treats data passed to a child component via props as read-only and raw data. One-way data flow ensures that the parent component is the only one that can update the data prop. We often want to update a specific data prop and sync it with the parent component. To do so, we use the emits field in the component’s options to declare custom events.

Take a to-do list, or ToDoList component, for instance. This ToDoList will use ToDoItem as its child component to render a list of tasks with the code in Example 4-14.

Example 4-14. ToDoList component
<template>
  <ul style="list-style: none;">
    <li v-for="task in tasks" :key="task.id">
      <ToDoItem :task="task" />
    </li>
  </ul>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import ToDoItem from './ToDoItem.vue'
import type { Task } from './ToDoItem'

export default defineComponent({
  name: 'ToDoList',
  components: {
    ToDoItem
  },
  data() {
    return {
      tasks: [
        { id: 1, title: 'Learn Vue', completed: false },
        { id: 2, title: 'Learn TypeScript', completed: false },
        { id: 3, title: 'Learn Vite', completed: false },
      ] as Task[]
    }
  }
})
</script>

And ToDoItem is a component that receives a task prop and renders an input as a checkbox for the user to mark the task as completed or not. This input element receives task.completed as its initial value for the checked attribute. Let’s look at Example 4-15.

Example 4-15. ToDoItem component
<template>
  <div>
    <input
      type="checkbox"
      :checked="task.completed"
    />
    <span>{{ task.title }}</span>
  </div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue'

export interface Task {
  id: number;
  title: string;
  completed: boolean;
}

export default defineComponent({
  name: 'ToDoItem',
  props: {
    task: {
      type: Object as PropType<Task>,
      required: true,
    }
  },
})
</script>

When a user toggles this input checkbox, we want to emit an event called task-completed-toggle to inform about the task.completed value of the specific task to the parent component. We can do so by first declaring the event in the emits field of the component’s options (Example 4-16).

Example 4-16. ToDoItem component with emits
/** ToDoItem.vue */
export default defineComponent({
  //...
  emits: ['task-completed-toggle']
})

Then, we create a new method onTaskCompleted to emit the task-completed-toggle event with the new value of task.completed from the checkbox and the task.id as the event’s payload (Example 4-17).

Example 4-17. ToDoItem component with a method to emit task-completed-toggle event
/** ToDoItem.vue */
export default defineComponent({
  //...
  methods: {
    onTaskCompleted(event: Event) {
      this.$emit("task-completed-toggle", {
        ...this.task,
        completed: (event.target as HTMLInputElement)?.checked,
      });
    },
  }
})
Note

We use defineComponent to wrap around the component’s options and create a TypeScript-friendly component. Using define Component is not required for simple components, but you need to use it to access other data properties of this inside components’ methods, hooks, or computed properties. Otherwise, TypeScript will throw an error.

Then we bind the onTaskCompleted method to the input element’s change event, as shown in Example 4-18.

Example 4-18. ToDoItem component’s updated template
<div>
  <input
    type="checkbox"
    :checked="task.completed"
    @change="onTaskCompleted"
  />
  <span>{{ task.title }}</span>
</div>

Now in the parent component <ToDoList> of ToDoItem, we can bind the task-completed-toggle event to a method using @ notation, with the template in Example 4-19.

Example 4-19. ToDoList component’s updated template
<template>
  <ul style="list-style: none;">
    <li v-for="task in tasks" :key="task.id">
      <ToDoItem
        :task="task"
        @task-completed-toggle="onTaskCompleted"
      />
    </li>
  </ul>
</template>

The onTaskCompleted method in the parent component <ToDoList> will receive the payload of the task-completed-toggle event, and update the task.completed value of the specific task in the tasks array, as in Example 4-20.

Example 4-20. ToDoList component’s script with a method to handle task-completed-toggle event
//...

export default {
  //...
  methods: {
    onTaskCompleted(payload: { id: number; completed: boolean }) {
      const index = this.tasks.findIndex(t => t.id === payload.id)

      if (index < 0) return

      this.tasks[index].completed = payload.completed
    }
  }
}

These code blocks will render the page shown in Figure 4-4.

Screenshot of a to-do list with three tasks, each with a checkbox and task's title
Figure 4-4. ToDoList component with three items

Vue will update the related data in ToDoList and accordingly render the relevant ToDoItem component instance. You can toggle the checkbox to mark a to-do item as completed. Figure 4-5 shows we can detect the component’s event using the Vue Devtools.

A screenshot of the Vue Devtools showing the event emitted by the +ToDoItem+ component
Figure 4-5. Mark a to-do item as completed and debug the event emitted using Vue Devtools

Defining Custom Events Using defineEmits()

Similar to “Declaring Props Using defineProps() and withDefaults()”, within a <script setup> code block, you can use defineEmits() to define custom events. The defineEmits() function accepts the same input parameter type as emits accepts:

const emits = defineEmits(['component-event'])

It then returns a function instance that we can use to invoke a specific event from the component:

emits('component-event', [...arguments])

Thus we can write the script section of ToDoItem as in Example 4-21.

Example 4-21. ToDoItem component with the custom event using defineEmits()
<script lang="ts" setup>
//...
const props = defineProps({
  task: {
    type: Object as PropType<Task>,
    required: true,
  }
});

const emits = defineEmits(['task-completed-toggle'])

const onTaskCompleted = (event: Event) => {
  emits("task-completed-toggle", {
    id: props.task.id,
    completed: (event.target as HTMLInputElement)?.checked,
  });
}
</script>

Note here we don’t need to use defineComponent since there is no this instance available within the <script setup> code block.

For better type checking, you can use type-only declaration for the task-completed-toggle event instead of a single string. Let’s improve the emits declaration in Example 4-21 to use type EmitEvents as shown in Example 4-22.

Example 4-22. Custom event using defineEmits() and type-only declaration
// Declare the emit type
type EmitEvents = {
  (e: 'task-completed-toggle', task: Task): void;
}

const emits = defineEmits<EmitEvents>()

This approach helps ensure you bind the correct method to the declared event. As seen for the task-complete-toggle event, any event declaration should follow the same pattern:

(e: 'component-event', [...arguments]): void

In the previous syntax, e is the event’s name, and arguments are all the inputs passed to the event emitter. In the case of the task-completed-toggle event, its emitter’s argument is task of type Task.

emits is a powerful feature that allows you to enable two-way communication between a parent and a child component without breaking the data flow mechanism of Vue. However, props and emits are only beneficial when you want direct data communication.

You must use a different approach to pass data from a component to its grandchild or descendant. In the next section, we will see how to use the provide and inject APIs to pass data from a parent component to its child or grandchild component.

Communicate Between Components with provide/inject Pattern

To establish data communication between an ancestor component and its descendants, the provide/inject API is a reasonable option. The provide field passes data from the ancestor, while inject ensures that Vue injects the provided data into any target descendant.

Using provide to Pass Data

The component’s option field provide accepts two formats: a data object or a function.

provide can be an object containing data to inject, with each property representing a (key, value) data type. In the following example, ProductList provides a data value, selectedIds, with the value [1] to all its descendants (Example 4-23).

Example 4-23. Passing selectedIds using provide in ProductList component
export default {
  name: 'ProductList',
  //...
  provide: {
    selectedIds: [1]
  },
}

Another format type for provide is a function that returns an object containing the data available to inject for descendants. A benefit of this format type is we can access the this instance and map dynamic data or a component method to the relevant fields of the return object. From Example 4-23, we can rewrite the provide field as a function as shown in Example 4-24.

Example 4-24. Passing selectedIds using provide in ProductList component as a function
export default {
//...
  provide() {
    return {
      selectedIds: [1]
    }
  },
//...
}
</script>
Note

Unlike props, you can pass a function and have the target descendant trigger it using the provide field. Doing so enables sending data back up to the parent component. However, Vue considers this approach an anti-pattern, and you should use it cautiously.

At this point, our ProductList passes some data values to its descendant using provide. Next, we must inject the provided values to operate within a descendant.

Using inject to Receive Data

Like props, the inject field can accept an array of strings, each representing the provided data key (inject: [selectedId]) or an object.

When using inject as an object field, each of its properties is an object, with the key presenting the local data key used within the component and the following properties:

{
  from?: string;
  default: any
}

Here, from is optional if the property key is the same as the provided key from the ancestor. Take Example 4-23 with the selectedIds as the data provided by ProductList to its descendants, for instance. We can compute a ProductComp that receives the provided data, selectedIds, from ProductList and rename it to current SelectedIds to use locally, as shown in Example 4-25.

Example 4-25. Injecting provided data in ProductComp
<script lang='ts'>
export default {
  //...
  inject: {
    currentSelectedIds: {
      from: 'selectedIds',
      default: []
    },
  },
}
</script>

In this code, Vue will take the value of injected selectedIds and assign it to a local data field, currentSelectedIds, or use its default value [] if there is no injected value.

Within the Components section of the Vue tab in the browser’s Developer Tools, when selecting the ProductComp from the component tree (the left-side panel), you can debug the indication of the renaming for the injected data (the right-side panel), as shown in Figure 4-6.

A screenshot shows the Component tab of the Vue tab in the browser's Develop tools with information about a component's provided and injected data.
Figure 4-6. Debug the provided and injected data using Vue Devtools
Note

The equivalent hooks in Composition API for provide/inject are provide() and inject(), respectively.

Now we understand how to use provide and inject to pass data between components efficiently without props drilling. Let’s explore how we can render a specific content section of an element to another location in the DOM with the <Teleport> component.

Teleport API

Due to styling constraints, we often need to implement a component that contains elements that Vue should render in a different location in the actual DOM for full visual effect. In such cases, we usually need to “teleport” those elements to the desired place by developing a complex solution, resulting in lousy performance impact, time consumption, etc. To solve this “teleport” challenge, Vue offers the <Teleport> component.

The <Teleport> component accepts a prop to, which indicates the target container, whether an element’s query selector or the desired HTML element. Suppose we have a House component that will have a section of Sky and clouds that needs the Vue engine to teleport it to a designated #sky DOM element, as in Example 4-26.

Example 4-26. House component with Teleport
<template>
  <div>
    This is a house
  </div>
  <Teleport to="#sky">
    <div>Sky and clouds</div>
  </Teleport>
</template>

In our App.vue, we add a section element with the target id sky above the House component, as in Example 4-27.

Example 4-27. Template of App.vue with House component
<template>
  <section id="sky" />
  <section class="wrapper">
      <House />
  </section>
</template>

Figure 4-7 shows the code outputs.

Screenshot displaying two texts in the reverse order
Figure 4-7. Actual display order when using the Teleport component

When you inspect the DOM tree using the Elements tab of the browser’s Developer Tools, “Sky and clouds” appears as nested within <section id="sky"> instead (Figure 4-8).

Screenshot displaying the DOM tree
Figure 4-8. Actual DOM tree when using the Teleport component

You can also temporarily disable moving the content inside a <Teleport> component instance with its Boolean prop disabled. This component is handy when you want to keep the DOM tree structure, and Vue should move only the desired content to the target location when needed. An everyday use case for Teleport is a modal, which we will implement next.

Wrapping Both Sections Under a Parent

The destination component for teleporting must exist in the DOM before mounting <Teleport>. In Example 4-27, if you wrap both instances of section under a main element, the <Teleport> component will not work as expected. See “Rendering Problem Using Teleport” for more details.

Implementing a Modal with Teleport and the <dialog> Element

A modal is a dialog window that appears on top of a screen and blocks the user’s interaction with the main page. The user must interact with the modal to dismiss it and then returns to the main page.

A modal is very handy in displaying essential notifications that require the user’s full attention and should appear only once.

Let’s design a basic modal. Similar to a dialog, a modal should contain the following elements (Figure 4-9):

  • A backdrop that covers the entire screen where the modal appears on top and blocks the user’s interactions with the current page.

  • A modal window that contains the modal’s content, including a header with a title and a close button, a main content section, and a footer section with a default close button. These three sections should be customizable using slots.

Screenshot displaying the design of a basic modal.
Figure 4-9. Design of a basic modal

Based on the preceding design, we implement a Modal component template using the <dialog> HTML element in Example 4-28.

Example 4-28. Modal component
<template>
  <dialog :open="open">
    <header>
      <slot name="m-header"> 1
        <h2>{{ title }}</h2>
        <button>X</button>
      </slot>
    </header>
    <main>
      <slot name="m-main" /> 2
    </main>
    <footer>
      <slot name="m-footer"> 3
        <button>Close</button>
      </slot>
    </footer>
  </dialog>
</template>

In the preceding code, we use three slot sections to allow the user to customize:

1

The modal’s header (m-header)

2

The main content (m-main)

3

The modal’s footer (m-footer)

We also bind the <dialog> element’s open attribute to a local data prop open for controlling the modal’s visibility (visible/hidden). In addition, we render the title prop as the modal’s default title. Now, let’s implement the Modal component’s options, which receive two props: open and title as in Example 4-29.

Example 4-29. Adding props to Modal component
<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
  name: 'Modal',
  props: {
    open: {
      type: Boolean,
      default: false,
    },
    title: {
      type: String,
      default: 'Dialog',
    },
  },
})
</script>

When a user clicks on the modal’s close button or the “X” button on the header, it should close itself. Since we control the visibility of the modal using the open prop, we need to emit a closeDialog event with the new value of open from the Modal component to the parent. Let’s declare emits and a close method that emits the target event as in Example 4-30.

Example 4-30. Declaring the event closeDialog for Modal to emit
<script lang="ts">
/** Modal.vue */
import { defineComponent } from 'vue'

export default defineComponent({
  name: 'Modal',
  //...
  emits: ["closeDialog"], 1
  methods: {
    close() { 2
      this.$emit("closeDialog", false);
    },
  },
})
</script>
1

emits with one event, closeDialog

2

close method that emits the closeDialog event with the new value of open as false

Then we bind it to the relevant action elements in the <dialog> element using @ notation, as shown in Example 4-31.

Example 4-31. Binding event listener on click events
<template>
  <dialog :open="open" >
    <header>
      <slot name="m-header" >
        <h2>{{ title }}</h2>
        <button @click="close" >X</button> 1
      </slot>
    </header>
    <main>
      <slot name="m-main" />
    </main>
    <footer>
      <slot name="m-footer" >
        <button @click="close" >Close</button> 2
      </slot>
    </footer>
  </dialog>
</template>
1

@click event handler for the “X” button on the header

2

@click event handler for the default close button on the footer

Next, we need to wrap the dialog element with a <Teleport> component to move it outside the parent component’s DOM tree. We also pass the to prop to the <Teleport> component to specify the target location: an HTML element with an id, modal. Finally, we bind the disabled prop to the component’s open value to ensure Vue moves only the modal component content to the desired location when visible (Example 4-32).

Example 4-32. Using <Teleport> component
<template>
  <teleport 1
    to="#modal" 2
    :disabled="!open" 3
  >
    <dialog ref="dialog" :open="open" >
      <header>
      <slot name="m-header">
        <h2>{{ title }}</h2>
        <button @click="close" >X</button>
      </slot>
      </header>
      <main>
        <slot name="m-main" />
      </main>
      <footer>
        <slot name="m-footer">
          <button @click="close" >Close</button>
        </slot>
      </footer>
    </dialog>
  </teleport>
</template>
1

<Teleport> component

2

to prop with the target location with id selector modal

3

disabled prop with the condition when component’s open value is falsy

Now let’s try out our Modal component in a WithModalComponent by adding the following code in Example 4-33 to the WithModalComponent.

Example 4-33. Using modal component in WithModalComponent
<template>
  <h2>With Modal component</h2>
  <button @click="openModal = true">Open modal</button>
  <Modal :open="openModal" title="Hello World" @closeDialog="toggleModal"/>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import Modal from "./Modal.vue";

export default defineComponent({
  name: "WithModalComponent",
  components: {
    Modal,
  },
  data() {
    return {
      openModal: false,
    };
  },
  methods: {
    toggleModal(newValue: boolean) {
      this.openModal = newValue;
    },
  },
});
</script>

Finally, add a <div> element with id modal to the body element in the index.html file:

<body>
  <div id="app"></div>
  <div id="modal"></div> 1
  <script type="module" src="/src/main.ts"></script>
</body>
1

div element with id modal

By doing so, Vue renders the Modal component’s content to this div with id modal whenever the open prop is set to true (Figure 4-10).

Modal component rendered to the +div+ with id +modal+ when visible
Figure 4-10. Modal component rendered to the div with id modal when visible

Figure 4-11 shows how it looks on screen:

Output of the +WithModalComponent+ when modal is visible
Figure 4-11. Output of the WithModalComponent when modal is visible

And when the open prop is false, the div with id modal is empty (Figure 4-12), and the modal is invisible on screen (Figure 4-13).

Modal component not rendered to the +div+ with id +modal+ when hidden
Figure 4-12. Modal component not rendered to the div with id modal when hidden
Modal component not visible when hidden
Figure 4-13. Modal component not visible when hidden

At this point, you have a working modal component. However, the visual appearance of the modal isn’t exactly as good as we wanted; there should be a dark overlay over the main page content when the modal is visible. Let’s fix this issue using CSS stylings for ::backdrop selector in the <style> section of the modal element:

<style scoped>
  dialog::backdrop {
    background-color: rgba(0, 0, 0, 0.5);
  }
</style>

However, this won’t change the appearance of the modal’s backdrop. This behavior is because the browser applies the ::backdrop CSS selector rules to the dialog only when we open the dialog using dialog.showModal() method, and not by changing the open attribute. To fix this issue, we need to perform the following modifications in our Modal component:

  • Add a direct reference to the <dialog> element by assigning a “dialog” value to the ref attribute:

    <dialog :open="open" ref="dialog">
      <!--...-->
    </dialog>
  • Trigger $refs.dialog.showModal() or $refs.dialog.close() on the dialog element whenever the open prop changes respectively with watch:

    watch: {
      open(newValue) {
        const element = this.$refs.dialog as HTMLDialogElement;
        if (newValue) {
          element.showModal();
        } else {
          element.close();
        }
      },
    },
  • Remove the original binding for the open attribute of the <dialog> element:

    <dialog ref="dialog">
      <!--...-->
    </dialog>
  • Remove the use of the disabled attribute in the <teleport> component:

    <teleport to="#modal">
      <!--...-->
    </teleport>

When opening the modal using the built-in showModal() method, the browser will add a ::backdrop pseudo-element to the actual <dialog> element in the DOM, and dynamically moving the element content to the target location will disable this functionality, leaving the modal without the desired backdrop.

We also reposition the modal to the center of the page and on top of other elements by adding the following CSS rules to the dialog selector:

dialog {
  position: fixed;
  z-index: 999;
  inset-block-start: 30%;
  inset-inline-start: 50%;
  width: 300px;
  margin-inline-start: -150px;
}

The output will be as shown in Figure 4-14 when the modal is visible.

Modal component with backdrop and stylings
Figure 4-14. Modal component with backdrop and stylings

We have learned how to implement a reusable Modal component using Teleport and explored different use cases with each of the built-in <dialog> element features. We also learned how to use the ::backdrop CSS selector to style the modal’s backdrop.

As you have noticed, we set the target location div for the modal to be a direct child of body, outside of the Vue app entry element <div id="app">. What happens if we want to move the modal’s target div to within the entry component App.vue of the Vue application? Let’s find out in the next section.

Rendering Problem Using Teleport

To understand the problem with using Teleport to render the modal inside a child component of the App.vue component, let’s first move the <div id="modal"></div> from index.html to App.vue, after the WithModalComponent instance:

<template>
  <section class="wrapper">
    <WithModalComponent />
  </section>
  <div id="modal"></div>
</template>

After running your application, you can see that the browser doesn’t render the modal despite how often you click on the Open modal button. And the console shows the following error:

Error message when rendering modal inside App.vue
Figure 4-15. Console error message when rendering modal inside App.vue

Due to the Vue rendering order mechanism, the parent waits for the children to render before rendering itself. The children render in the order of appearance in the parent’s template section. In this scenario, the WithModalComponent renders first. Thus Vue renders the <dialog> element and starts moving the component’s content to the target location before rendering the ParentComponent. However, since the ParentComponent is still waiting for WithModalComponent to finish its rendering, the <div id="modal"> element doesn’t yet exist on the DOM. As a result, Vue can’t locate the target location and perform the right move, and it can’t render the <dialog> element inside the <div id="modal"> element, hence the error.

A workaround to bypass this limitation is to put the target element <div id="modal"> to appear before WithModalComponent:

<template>
  <div id="modal"></div>
  <section class="wrapper">
    <WithModalComponent />
  </section>
</template>

This solution ensures the target div is available before Vue renders the Modal element and moves the content. Another approach is to use the disabled attribute to postpone the content moving process for Modal during rendering until the user clicks on the Open modal button. Both options have pros and cons, and you should choose the one that best suits your needs.

The most common solution is to insert the target element as a direct child of the body element and isolate it from the Vue rendering context.

A significant benefit of using <Teleport> is achieving the maximum visual display effect (such as fullscreen mode, modal, sidebar, etc.) while maintaining the code hierarchy structure, component isolation, and readability.

Summary

This chapter explored the concept of different approaches in components’ communication using the built-in Vue features such as props, emits, and provide/inject. We learned how to use these features to pass data and events between components while keeping Vue’s data flow mechanism intact. We also learned how to use Teleport API to render an element outside the parent component’s DOM tree while keeping its appearance order in the parent component’s <template>. <Teleport> is beneficial for building components that require displaying with alignment to the main page element, such as popups, dialogs, modals, etc.

In the next chapter, we will explore more on Composition API and how to use it to compose Vue components together.

Get Learning Vue now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.