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.
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 typesObject
,Function
, andArray
, the default value must be a function that returns the initial value. -
required
is a boolean value indicating whether the prop is mandatory. Ifrequired
istrue
, 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).
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.
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
,
required
:
true
}
}
}
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
>
,
required
:
true
}
}
}
When you use PizzaComponent
with the wrong data format, TypeScript will detect and highlight the error appropriately.
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.
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.
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.
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.
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).
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, amain
content section, and afooter
section with a default close button. These three sections should be customizable using slots.
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"
>
<
h2
>
{{ title }}
<
/
h2
>
<
button
>
X
<
/
button
>
<
/
slot
>
<
/
header
>
<
main
>
<
slot
name
=
"m-main"
/
>
<
/
main
>
<
footer
>
<
slot
name
=
"m-footer"
>
<
button
>
Close
<
/
button
>
<
/
slot
>
<
/
footer
>
<
/
dialog
>
<
/
template
>
In the preceding code, we use three slot sections to allow the user to customize:
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"
]
,
methods
:
{
close
(
)
{
this
.
$emit
(
"closeDialog"
,
false
)
;
}
,
}
,
}
)
<
/
script
>
emits
with one event,closeDialog
close
method that emits thecloseDialog
event with the new value ofopen
asfalse
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
>
<
/
slot
>
<
/
header
>
<
main
>
<
slot
name
=
"m-main"
/
>
<
/
main
>
<
footer
>
<
slot
name
=
"m-footer"
>
<
button
@
click
=
"close"
>
Close
<
/
button
>
<
/
slot
>
<
/
footer
>
<
/
dialog
>
<
/
template
>
@click
event handler for the “X” button on the header@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
to
=
"#modal"
:disabled
=
"!open"
>
<
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
>
<Teleport>
componentto
prop with the target location with id selectormodal
disabled
prop with the condition when component’sopen
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
>
<
script
type
=
"module"
src
=
"/src/main.ts"
>
<
/
script
>
<
/
body
>
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).
Figure 4-11 shows how it looks on screen:
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).
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 theref
attribute:<
dialog
:open
=
"open"
ref
=
"dialog"
>
<!--...-->
</
dialog
>
-
Trigger
$refs.dialog.showModal()
or$refs.dialog.close()
on thedialog
element whenever theopen
prop changes respectively withwatch
: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.
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:
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.