<p>A</p>
<p>B</p>
<span>C</span>
Text
Fragment
<p>D</p>
"A"
</div>
<p>
<span>
<p>
The component’s HTML
Text
Text
Text
"B"
"C"
"D"
i = 0 (in fragment)
i = 1 (in fragment)
i = 2 (in fragment)
i = 1 (in div)
i = 2 (in div)
i = 3 (in div)
Figure 9.12
Adding a node to the component’s view with an offset
Listing 9.10
Patching the children using the offset (patch-dom.js)
function patchChildren(oldVdom, newVdom, hostComponent) {
const oldChildren = extractChildren(oldVdom)
const newChildren = extractChildren(newVdom)
Adds the host
const parentEl = oldVdom.el
component as an argument
to the patchChildren() function
const diffSeq = arraysDiffSequence(
oldChildren,
newChildren,
areNodesEqual
)
for (const operation of diffSeq) {
Gets the host component’s
const { from, index, item } = operation
offset, if there is one; offset
is zero otherwise
const offset = hostComponent?.offset ?? 0
switch (operation.op) {
case ARRAY_DIFF_OP.ADD: {
232
CHAPTER 9
Stateful components
mountDOM(item, parentEl, index + offset, hostComponent)
break
}
When a node is added, takes
account of the host
case ARRAY_DIFF_OP.REMOVE: {
component’s offset
destroyDOM(item)
break
}
When a node is moved, uses
the offset to find the correct
case ARRAY_DIFF_OP.MOVE: {
position in the DOM
const el = oldChildren[from].el
const elAtTargetIndex = parentEl.childNodes[index + offset]
parentEl.insertBefore(el, elAtTargetIndex)
patchDOM(
oldChildren[from],
newChildren[index],
parentEl,
hostComponent
Passes the host
)
component back to the
patchDOM() function
break
}
case ARRAY_DIFF_OP.NOOP: {
patchDOM(
oldChildren[from],
newChildren[index],
Passes the host
parentEl,
component to the
patchChildren() function
hostComponent
)
break
}
}
}
}
With this nuance out of the way, we can focus on the component’s methods.
Exercise 9.3
Can you explain why the problem of the component’s offset doesn’t arise when the component’s view is a single root element?
Find the solution .
Summary
A stateful component can be modeled as a class that can be instantiated.
Each instance of a component has its own state and lifecycle, and it manages its own DOM subtree.
To allow users to create component classes with custom states, render functions, and other methods, you can use a factory function.
Summary
233
The state of a component is created at instantiation time, and you can update it by calling the updateState() method.
When the updateState() method is called, the component patches its view.
The render() function and event handlers of a component need to be explicitly bound to the component instance so that this refers to the component instance inside them.
Component methods
This chapter covers
Implementing component methods to handle
events
Binding a method’s context to the component
Passing the host component reference to mount
and patch functions
What happens when you’re handling an event in a component that requires a couple of lines of code? One-liners, like this one, look fine inside the virtual Document Object Model (DOM) definition:
render() {
const { count } = this.state
Handles the click
return hFragment([
event by decrementing
h(
the count
'button',
{ on: { click: () => this.updateState({ count: count - 1 }) }}
['Decrement']
),
h('span', {}, [count]),
h(
234
235
'button',
{ on: { click: () => this.updateState({ count: count + 1 }) }}
['Increment']
)
Handles the click
])
event by incrementing
}
the count
But what happens when you want to do more than you can fit in a single line? Well, you could fit the code inside the virtual DOM (vdom) definition, but then the code will be harder to read. Look at the following example list, in which clicking a button loads more items from the server:
render() {
const { items, offset, isLoading } = this.state
return hFragment([
h(
'ul',
{},
items.map((item) => h('li', {}, [item])),
),
isLoading
? h('p', {}, ['Loading...'])
: h(
'button',
{
The event handler spans
multiple lines, making the
on: {
code harder to read.
click: async () => {
const { items, offset } = this.state
this.updateState({ isLoading: true })
const newItems = await fetchItems(offset)
this.updateState({
items: [...items, ...newItems],
isLoading: false,
offset: offset + newItems.length,
})
},
},
},
['Load more'],
),
])
}
This code doesn’t look as nice as the preceding example. Instead, we want to move the event-handler code to a method of the component and reference the method as the handler for the event. This approach results in much cleaner code: render() {
const { items, offset, isLoading } = this.state
236
CHAPTER 10
Component methods
return hFragment([
h(
'ul',
{},
items.map((item) => h('li', {}, [item])),
),
isLoading
? h('p', {}, ['Loading...'])
: h(
'button',
Now the event handler
is a reference to the
{
loadMore() method.
on: { click: this.loadMore },
},
['Load more'],
),
])
}
So a loadMore() method inside the component would handle the event. You need a way to add custom methods to the component. How do you go about it? You can pass the defineComponent() function together with the render() and state() functions, which are other arbitrary methods. In the preceding example, you’d define the component as follows:
const InfiniteList = defineComponent(
state() { ... },
The loadMore() method
is defined as part of the
render() { ... },
component.
async loadMore() {
const { items, offset } = this.state
this.updateState({ isLoading: true })
const newItems = await fetchItems(offset)
this.updateState({
items: [...items, ...newItems],
isLoading: false,
offset: offset + newItems.length,
})
},
)
The objective of this chapter is to modify the defineComponent() function so that it can receive custom methods to handle events. The changes are straightforward, but the methods won’t be bound to the component’s context correctly when they’re used as event handlers. As you’ll see soon, you need to explicitly bind the event handlers to the component’s context. This topic will take up most of the chapter.
NOTE
You can find all the listings in this chapter in the listings/ch10 directory of the book’s repository (The code you write in this chapter is for the framework’s version v3.0, which you’ll publish in chapter 12.
10.1
Component methods
237
Therefore, the code in this chapter can be checked out from the ch12 label ( $ git switch --detach ch12.
Code catch-up
In chapter 9, you wrote the defineComponent() function, which receives the render() and state() functions as arguments and returns a Component class. You implemented the following methods in the Component class:
render()—Renders the component’s virtual DOM
mount()—Mounts the component’s view into the DOM
unmount()—Unmounts the component from the DOM
#patch()—Patches the component’s view in the DOM
updateState()—Updates the component’s state and patches the view in the DOM
You also modified the patchChildren() function to receive one more argument: the host component (the component that owns the children). You needed this component to calculate the offset of the children so that the operations that require moving nodes in the DOM could be performed correctly. For the patchChildren() function to receive the host component, you also had to modify the patchDOM() function, adding the hostComponent argument to it.
10.1
Component methods
We need to update our analogy from chapter 9, in which defineComponent() was a component factory. This factory, in addition to the render() and state() functions, can receive other methods that will be added to the component’s prototype (figure 10.1).
Component
state = state()
render()
render()
state()
mount()
foo()
unmount()
bar()
...
foo()
defineComponent()
bar()
...
Other user-defined methods
included in the component class
Figure 10.1
Using defineComponent() to define a component with custom methods
238
CHAPTER 10
Component methods
To add custom methods to the component, you can use JavaScript’s prototypal inheritance ). Classes in JavaScript are syntactic sugar for prototypes, and extending a prototype to include a new method is as simple as
class Component {}
Component.prototype.loadMore = function () { ... }
But you want to prevent the user from redefining a method that already exists in the component, such as updateState() or mount(). Overriding these methods would break the component. First, you want to use the hasOwnProperty() method to check whether the method already exists in the component’s prototype. Unfortunately, this method isn’t safe to use (a malicious user could use it to do some harm), and ESLint will complain about it. Fortunately for us, the same ESLint rule that complains about hasOwnProperty() also provides a suggestion for a safe implementation of it.
NOTE
If you want to understand better why using an object’s hasOwnProperty() method is unsafe, see You may also want to learn more about JavaScript’s prototypal inheritance. If so, I recommend that you take some time to read .
Inside the utils/objects.js file, add the hasOwnProperty() function as shown in the following listing.
Listing 10.1
A safe implementation of hasOwnProperty() (utils/objects.js) export function hasOwnProperty(obj, prop) {
return Object.prototype.hasOwnProperty.call(obj, prop)
}
Now go back to the component.js file. Write the code shown in bold in the following listing to add custom methods to the component.
Listing 10.2
Adding custom methods to the component (component.js)
import { destroyDOM } from './destroy-dom'
import { DOM_TYPES, extractChildren } from './h'
import { mountDOM } from './mount-dom'
Destructures the rest
import { patchDOM } from './patch-dom'
of the methods passed
import { hasOwnProperty } from './utils/objects'
to defineComponent()
export function defineComponent({ render, state, ...methods }) {
class Component {
// --snip-- //
Iterates over the
}
method names
Ensures that the
component doesn’t
for (const methodName in methods) {
already have a method
with the same name
if (hasOwnProperty(Component, methodName)) {
throw new Error(
`Method "${methodName}()" already exists in the component.`
10.1
Component methods
239
)
}
Adds the
method to the
Component.prototype[methodName] = methods[methodName]
component’s
}
prototype
return Component
}
This code is great, but the InfiniteList component from the preceding example won’t work now:
const InfiniteList = defineComponent(
state() { ... },
render() {
const { items, offset, isLoading } = this.state
return hFragment([
h(
'ul',
{},
items.map((item) => h('li', {}, [item])),
),
isLoading
? h('p', {}, ['Loading...'])
: h(
'button',
{
on: { click: this.loadMore }
},
['Load more'],
),
])
},
async loadMore() { ... },
)
We get an error because this is bound to the <button> element that’s emitting the click event, not to the component. Hence, this.loadMore() is undefined because buttons in the DOM don’t include a loadMore() method; if they did, you wouldn’t see any error, but your loadMore() method would never be called. The culprit is the line in bold:
h(
'button',
{
// Fails, because 'this' is bound to the <button> element
on: { click: this.loadMore },
},
['Load more'],
)
240
CHAPTER 10
Component methods
When you add an event handler by using the addEventHandler() method, the this keyword of the function is bound to the element that emitted the event—in the preceding example, the <button> element. You don’t want the this keyword to be bound to the element; you want it to be bound to the component so that the methods inside it can be resolved correctly, as shown in figure 10.2.
Items
We don’t want the method’s context
bound to the button element.
Component
render()
loadMore()
Load more
...
Binding
!
'click' : loadMore
Binding
The method’s context should be
bound to the component instance.
Figure 10.2
Binding of the loadMore() method’s this keyword
You can get around this problem quickly by wrapping the event handler in an arrow function:
h(
'button',
{
// Works by wrapping the event handler in an arrow function
on: { click: () => this.loadMore() },
},
['Load more'],
)
NOTE
Arrow functions can’t have their own this keyword, so the this keyword inside the arrow function is inherited from the containing function.
Arrow functions are lexically scoped, which means that the this keyword is bound to the value of this in the enclosing (function or global) scope.
In our case, this function is the render() function, and the this keyword is bound to the component, which is why you had to do render.call(this) inside the component’s render method. Arrow functions use lexical scoping (), so the addEventListener() method can’t bind the this keyword to the element that emitted the event.
10.2
Binding event handlers to the component
241
We don’t want to force the user to wrap the event handler in an arrow function, however. We want them to be able to reference a method from the component and have it work. For that purpose, we need to explicitly bind the event handler to the component so that the this keyword is bound to the component.
10.2
Binding event handlers to the component
Open the events.js file. You need to make some modifications to the addEventListener() function. You want to pass the host component reference to the function, which can be null in case the event handler isn’t bound to a component. If the hostComponent argument isn’t null, you want to bind the event handler to the component by using the .apply() method. This method is similar to .call(), but you can pass it an array of arguments instead of passing arguments one by one. When the hostComponent is null, you want to call the event handler as is:
hostComponent
? handler.apply(hostComponent)
: handler()
Keep in mind that an event handler can receive arguments, so you need to pass those arguments to the event handler when you call it. You can wrap the code from the preceding code snippet in a new handler function that passes its arguments to the original handler function:
function boundHandler() {
hostComponent
? handler.apply(hostComponent, arguments)
: handler(...arguments)
}
NOTE
If you’re not familiar with the arguments object, you can read more about it In a nutshell, arguments is an arraylike object that contains the arguments passed to a function. Bear in mind that inside an arrow function, the arguments object isn’t available.
Now that the plan is clear, modify your code inside the events.js file as shown in the following listing.
Listing 10.3
Event handlers bound to the component (events.js)
export function addEventListeners(
listeners = {},
Adds the host
component argument
el,
to the function
hostComponent = null
) {
const addedListeners = {}
Object.entries(listeners).forEach(([eventName, handler]) => {
const listener = addEventListener(
242
CHAPTER 10
Component methods
eventName,
handler,
Passes the host component
to the addEventListener()
el,
function
hostComponent
)
addedListeners[eventName] = listener
})
return addedListeners
}
export function addEventListener(
eventName,
handler,
Adds the host
component argument
el,
to the function
hostComponent = null
) {
el.addEventListener(eventName, handler)
return handler
function boundHandler() {
If a host component
exists, binds it to the
hostComponent
event handler context...
? handler.apply(hostComponent, arguments)
: handler(...arguments)
...otherwise, calls
}
the event handler
el.addEventListener(eventName, boundHandler)
Adds the bound
event listener to
return boundHandler
the element
}
Now that your event handlers are properly bound to the component—in case there is one—you need to modify the mountDOM() and patchDOM() functions so that they pass the host component to the addEventListener() function.
10.3
Mounting the DOM with a host component
Open the mount-dom.js file to modify the mountDOM() function. Add the code shown in bold in the following listing so that the host component can be passed as an argument.
Listing 10.4
Passing the host component to the mountDOM() function (mount-dom.js) export function mountDOM(
vdom,
parentEl,
Adds the host component
as an argument to the
index,
function
hostComponent = null
) {
switch (vdom.type) {
case DOM_TYPES.TEXT: {
createTextNode(vdom, parentEl, index)
break
}
10.3
Mounting the DOM with a host component
243
case DOM_TYPES.ELEMENT: {
createElementNode(vdom, parentEl, index, hostComponent)
break
}
Passes the host component to the
createElementNode() function
case DOM_TYPES.FRAGMENT: {
createFragmentNodes(vdom, parentEl, index, hostComponent)
break
}
Passes the host component to the
createFragmentNodes() function
default: {
throw new Error(`Can't mount DOM of type: ${vdom.type}`)
}
}
}
You passed the hostElement reference down to the createElementNode() and createFragmentNodes() functions. Now modify them to accept that argument. First, add the hostComponent argument to the createElementNode() function, as shown in the following listing. From there, you can pass the hostComponent argument to the addProps() function and back to the mountDOM() function.
Listing 10.5
Passing the host component (mount-dom.js)
function createElementNode(vdom, parentEl, index, hostComponent) {
const { tag, props, children } = vdom
Adds the host component as an
argument to the function
const element = document.createElement(tag)
addProps(element, props, vdom, hostComponent)
Passes the host
vdom.el = element
component to the
addProps() function
children.forEach((child) =>
mountDOM(child, element, null, hostComponent)
)
Passes a null index and
insert(element, parentEl, index)
the host component to the
}
mountDOM() function
Now modify the addProps() function so that the passed-in hostComponent argument is passed to the addEventListeners() function, as shown in the following listing. This change is the most important one in this section because it’s where you pass the host component to the addEventListener() function you modified earlier.
Listing 10.6
Passing the host component (mount-dom.js)
Adds the host component
function addProps(el, props, vdom, hostComponent) {
as an argument to the
const { on: events, ...attrs } = props
function
vdom.listeners = addEventListeners(events, el, hostComponent) setAttributes(el, attrs)
Passes the host component to the
}
addEventListeners() function
244
CHAPTER 10
Component methods
Last, modify the createFragmentNodes() function so that the hostComponent argument is passed back to the mountDOM() function, as shown in the following listing.
Listing 10.7
Passing the host component (mount-dom.js)
function createFragmentNodes(
vdom,
parentEl,
Adds the host component
as an argument to the
index,
function
hostComponent
) {
const { children } = vdom
vdom.el = parentEl
children.forEach((child) =>
mountDOM(
child,
parentEl,
Passes the host
component to the
index ? index + i : null,
mountDOM() function
hostComponent
)
)
}
Great! Let’s end the chapter by modifying the patchDOM() function so that the host component is passed and can be used to patch event handlers.
10.4
Patching the DOM with a host component
If you recall, you modified the patchDOM() function in chapter 9 so that it passes the host component to the patchChildren() function. You made this change so you could pass it to the patchChildren() function, taking the component’s offset into account.
Open the patch-dom.js file again, and this time, pass the hostComponent reference to the mountDOM() function (the case when the DOM is re-created) and to the patchElement() function, as shown in the following listing.
Listing 10.8
Passing the host component (patch-dom.js)
export function patchDOM(
oldVdom,
newVdom,
parentEl,
hostComponent = null
) {
if (!areNodesEqual(oldVdom, newVdom)) {
const index = findIndexInParent(parentEl, oldVdom.el)
destroyDOM(oldVdom)
mountDOM(newVdom, parentEl, index, hostComponent)
Passes the host
component to
return newVdom
the mountDOM()
}
function
10.4
Patching the DOM with a host component
245
newVdom.el = oldVdom.el
switch (newVdom.type) {
case DOM_TYPES.TEXT: {
patchText(oldVdom, newVdom)
return newVdom
}
Passes the host
component to the
case DOM_TYPES.ELEMENT: {
patchElement()
patchElement(oldVdom, newVdom, hostComponent)
function
break
}
}
patchChildren(oldVdom, newVdom, hostComponent)
return newVdom
}
Let’s modify the patchElement() function so that it receives the hostComponent argument, as shown in the following listing. You also want to pass it down to the patchEvents() function, which you’ll modify next.
Listing 10.9
Passing the host component (patch-dom.js)
function patchElement(oldVdom, newVdom, hostComponent) {
Adds the host
const el = oldVdom.el
component as an
const {
argument to the
class: oldClass,
function
style: oldStyle,
on: oldEvents,
...oldAttrs
} = oldVdom.props
const {
class: newClass,
style: newStyle,
on: newEvents,
...newAttrs
} = newVdom.props
const { listeners: oldListeners } = oldVdom
patchAttrs(el, oldAttrs, newAttrs)
patchClasses(el, oldClass, newClass)
patchStyles(el, oldStyle, newStyle)
newVdom.listeners = patchEvents(
el,
oldListeners,
oldEvents,
Passes the host
component to the
newEvents,
patchEvents() function
hostComponent
)
}
246
CHAPTER 10
Component methods
Now add the hostComponent argument to the patchEvents() function, as shown in the following listing. When an event handler is added or updated, the addEventListener() is called, so remember to pass the hostComponent argument to it.
Listing 10.10
Passing the host component (patch-dom.js)
function patchEvents(
el,
oldListeners = {},
oldEvents = {},
Adds the host
component as an
newEvents = {},
argument to the function
hostComponent
) {
const { removed, added, updated } = objectsDiff(oldEvents, newEvents) for (const eventName of removed.concat(updated)) {
el.removeEventListener(eventName, oldListeners[eventName])
}
const addedListeners = {}
for (const eventName of added.concat(updated)) {
const listener = addEventListener(
eventName,
newEvents[eventName],
Passes the host component
to the addEventListener()
el,
function
hostComponent
)
addedListeners[eventName] = listener
}
return addedListeners
}
Finally, go back to the component.js file. Modify the mount() function so that it passes the hostComponent argument to the mountDOM() function, as shown in the following listing.
Listing 10.11
Component’s mount() passing this reference (component.js) export function defineComponent({ render, state, ...methods }) {
class Component {
// --snip-- //
mount(hostEl, index = null) {
if (this.#isMounted) {
throw new Error('Component is already mounted')
}
Passes the component
reference to the
this.#vdom = this.render()
mountDOM() function
mountDOM(this.#vdom, hostEl, index, this)
10.4
Patching the DOM with a host component
247
this.#hostEl = hostEl
this.#isMounted = true
}
// --snip-- //
}
// --snip-- //
return Component
}
You’re done! Before we close the chapter, let’s review what should result when the defineComponent() function returns a Component class. Then I’ll give you a challenge exercise to test your knowledge.
Your component.js file should look like the following listing. Make sure that you got all the details right before moving on to chapter 11. Can you explain what each property and method of the Component class does?
Listing 10.12
Result of the defineComponent() function (component.js)
import { destroyDOM } from './destroy-dom'
import { DOM_TYPES, extractChildren } from './h'
import { mountDOM } from './mount-dom'
import { patchDOM } from './patch-dom'
import { hasOwnProperty } from './utils/objects'
export function defineComponent({ render, state, ...methods }) {
class Component {
#isMounted = false
#vdom = null
#hostEl = null
constructor(props = {}) {
this.props = props
this.state = state ? state(props) : {}
}
get elements() {
if (this.#vdom == null) {
return []
}
if (this.#vdom.type === DOM_TYPES.FRAGMENT) {
return extractChildren(this.#vdom).map((child) => child.el)
}
return [this.#vdom.el]
}
get firstElement() {
return this.elements[0]
}
248
CHAPTER 10
Component methods
get offset() {
if (this.#vdom.type === DOM_TYPES.FRAGMENT) {
return Array.from(this.#hostEl.children).indexOf(this.firstElement)
}
return 0
}
updateState(state) {
this.state = { ...this.state, ...state }
this.#patch()
}
render() {
return render.call(this)
}
mount(hostEl, index = null) {
if (this.#isMounted) {
throw new Error('Component is already mounted')
}
this.#vdom = this.render()
mountDOM(this.#vdom, hostEl, index, this)
this.#hostEl = hostEl
this.#isMounted = true
}
unmount() {
if (!this.#isMounted) {
throw new Error('Component is not mounted')
}
destroyDOM(this.#vdom)
this.#vdom = null
this.#hostEl = null
this.#isMounted = false
}
#patch() {
if (!this.#isMounted) {
throw new Error('Component is not mounted')
}
const vdom = this.render()
this.#vdom = patchDOM(this.#vdom, vdom, this.#hostEl, this)
}
}
for (const methodName in methods) {
if (hasOwnProperty(Component, methodName)) {
throw new Error(
`Method "${methodName}()" already exists in the component.`
Summary
249
)
}
Component.prototype[methodName] = methods[methodName]
}
return Component
}
Now it’s time for the challenge exercise!
Exercise 10.1: Challenge
A free API returns random cocktails. Try it in your browser to see what it returns: GET https://www.thecocktaildb.com/api/json/v1/1/random.php
For this challenge exercise, I want you to write the code for a component that fetches a random cocktail and displays it. When the component is mounted, it should show HTML similar to the following:
<h1>Random cocktail</h1>
<button>Get a cocktail</button>
When the button is clicked, the component should fetch a random cocktail and display it. You should display a Loading . . . message while the cocktail is being fetched and maybe add an artificial timeout of 1 or 2 seconds for the message to have time to be displayed. When the cocktail is fetched, display its name (strDrink field), image (strDrinkThumb field), and preparation instructions (strInstructions field). Include a button to load another cocktail:
<h1>Cocktail name</h1>
<p>Preparation instructions</p>
<img src="cocktail-image-url" />
<button>Get another cocktail</button>
Copy and paste the code for your component (and the framework code that’s needed for it to work) into the console of your browser, and mount it inside a website of your choice.
Find the solution at .
Summary
Methods are convenient for handling events in components. When the handling logic has more than a couple of lines, it’s cleaner to define a method for it.
Methods can be used as event handlers in the on object of the h() function.
They can be referenced as this.methodName in the on object.
Functions added as event handlers have to be explicitly bound to the component instance so that this inside the function refers to the component.
Subcomponents:
Communication
via props and events
This chapter covers
Adding a new virtual DOM type to represent
components
Implementing subcomponents
Passing data from a parent component to its
children using props
Communicating among components by using
events
What’s the value of a component if it can’t include other components? The Component class that you implemented in chapters 9 and 10 can’t return other components from its render() method; it can return only fragments, elements, or text nodes.
But, as you can imagine, building a complex application by using a single component that renders the entire view isn’t very practical.
In this chapter, you learn how to add subcomponents (components inside other components) to your framework and how to communicate among them by using props and events. A parent component can pass data to its children by using props, and children can communicate with their parents by emitting events, as illustrated in figure 11.1.
250
251
Component
Events move up the
component hierarchy.
state:
- foo: 3
Props are data that flow
- bar: 5
down the view hierarchy.
Event
payload
<div>
The props of a component
come from its parent component.
Component
Component
props:
props:
- foo: 3
- bar: 5
events:
- click
Figure 11.1
Props flow down; events flow up.
Let’s briefly review the “Anatomy of a stateful component” diagram from chapter 9 and the annotated properties and methods you’ll implement in this chapter (figure 11.2).
In this chapter, you’ll add the following private properties to the Component class:
parentComponent—A reference to the component that contains the current component. This property is null for the top-level component.
dispatcher—An instance of the Dispatcher class used to emit events and subscribe handlers to them.
subscriptions—An array of subscriptions of event handlers to events emitted by the component. These subscriptions need to be unsubscribed when the component is unmounted.
You’ll also implement two important methods:
updateProps()—This method is called when the component receives new props; it updates the component’s props property and triggers a re-render. I’ll show you an optional optimization that you can implement to avoid re-rendering the component if the new props are the same as the current ones.
emit()—This method is used to emit events from the component.
When you finish this chapter, you’ll have a fully functional component prototype that handles its own state and lifecycle and that can include other components. That work is an enormous step forward in the development of your framework, so let’s get started!
252
CHAPTER 11
Subcomponents: Communication via props and events
Anatomy of a stateful component
Component
Private
isMounted
The component that contains
vdom
this component (null if root)
hostEl
parentComponent
Dispatcher used to emit events
dispatcher
to the parent component
subscriptions
patch()
List of subscriptions of the
parent component to the events
Public
props
state
get elements
get firstElement
get offset
Updates the props and
triggers a render cycle
updateProps(props)
updateState(state)
async onMounted()
async onUnmounted()
Emits an event for the parent
component to handle
emit(eventName, payload)
render()
mount(hostEl, index)
unmount()
<<custom methods>>
Figure 11.2
Anatomy of a stateful component
NOTE
You can find all the listings in this chapter in the listings/ch11 directory of the book’s repository ). The code you’ll write in this chapter is for the framework’s version v3.0, which you’ll publish in chapter 12. Therefore, the code in this chapter can be checked out from the ch12
label ( $ git switch --detach ch12.
Code catch-up
In chapter 10, you modified the defineComponent() function to include custom methods in the component’s prototype. You had to modify the addEventListeners() function so that if the element to which the listener is to be attached has a host component, the listener is bound to it. This change required you to modify the mountDOM(),
11.1
Adding components as a new virtual DOM type
253
createElementNode(), and createFragmentNodes() functions. You also modified the patchDOM(), patchElement(), and patchEvents() functions in all cases to add the host component argument that needs to be passed around so that the addEventListeners() function can access it.
11.1
Adding components as a new virtual DOM type
The reason why your component—as implemented in the preceding two chapters—
can’t return other components is that you currently have no way to include components in the virtual DOM tree. Ideally, you’d want to use the h() function to create component nodes, like this:
const FooComponent = defineComponent({ ... })
const BarComponent = defineComponent({ ... })
const ParentComponent = defineComponent({
render() {
return hFragment([
h(FooComponent),
h(BarComponent),
])
}
})
The render() method of the ParentComponent returns a fragment with two component nodes: FooComponent and BarComponent. The virtual DOM tree could look something like the following example:
{
type: 'fragment',
children: [
{ type: 'component', tag: FooComponent, props: {}, children: [] },
{ type: 'component', tag: BarComponent, props: {}, children: [] },
]
}
Notice that the type property of the component nodes is 'component' and that the tag property contains the component prototype instead of a string with the HTML tag name. But the h() function doesn’t know how to mount component nodes—at least not yet, so let’s see how to do it.
To add components inside a virtual DOM tree, you need to add a new virtual DOM
type to the DOM_TYPES constant to represent those components. Then you need to teach the h() function how to differentiate between element nodes and component nodes. One way is to check whether the tag argument is a string, in which case you know that the node must be an element node. In JavaScript, the typeof operator returns 'function' for classes, as you can check yourself in the console:
254
CHAPTER 11
Subcomponents: Communication via props and events
class Component {}
typeof Component // 'function'
You could use this operator to check whether the tag argument is a component, but I chose to check whether the tag argument is a string. Both approaches work the same way.
Open the h.js file, and add the COMPONENT constant to the DOM_TYPES object. Then add the condition to check whether the tag argument is a string, as the code shown in bold in the following listing.
Listing 11.1
Adding components as a new virtual DOM type (h.js)
export const DOM_TYPES = {
TEXT: 'text',
ELEMENT: 'element',
FRAGMENT: 'fragment',
COMPONENT: 'component',
}
export function h(tag, props = {}, children = []) {
const type =
typeof tag === 'string' ? DOM_TYPES.ELEMENT : DOM_TYPES.COMPONENT
return {
tag,
props,
type,
children: mapTextNodes(withoutNulls(children)),
type: DOM_TYPES.ELEMENT,
}
}
Now that virtual node trees can include component nodes, you want to update the Component class’s elements getter to return the elements of a component node.
11.1.1 Updating the elements getter
You’ll recall that in chapter 9, we discussed the elements getter, which retrieves the top-level HTML elements of the virtual node tree representing the component’s view.
You used this getter to calculate the offset property of the component. This offset was crucial for accurately updating the component’s DOM when the top-level node was a fragment.
You need to modify the case when the top-level node is a fragment to account for the subcomponents that might be inside the fragment. In this case, you call the elements getter recursively, as illustrated in figure 11.3. Look at the figure from bottom to top. Each component in the hierarchy assembles an array of its view’s first-level elements, and if it finds a component, it calls the elements getter—hence, the recur-sion. As you can see, the topmost component has a fragment at the root of its view, so it looks inside it to find the first-level elements. First, it finds a component that has two
11.1
Adding components as a new virtual DOM type
255
Component
[ <h1> <p> <div> <span> ]
elements
First
Second
component
component
Fragment
[ <h1> <p> ]
[ <span> ]
Component
<div>
Component
elements
elements
Fragment
Text
<span>
"C"
<h1>
<p>
Text
"D"
...
...
Figure 11.3
Calling the elements getter recursively to get the parent component’s elements elements in its first level (inside the fragment): an <h1> and a <p> element. Then it finds a <div> element, which is the second element in the fragment. Finally, it finds another component, which has a single <p> element in its first level.
The component’s elements getter returns an array, not a single element, so you want to use the flatMap() method to flatten the array of arrays. In the component.js file, make the changes shown in bold in the following listing.
Listing 11.2
Extracting the elements of a component (component.js)
export function defineComponent({ render, state, ...methods }) {
class Component {
// --snip-- //
get elements() {
if (this.#vdom == null) {
return []
Flat-maps
}
the arrays
if (this.#vdom.type === DOM_TYPES.FRAGMENT) {
return extractChildren(this.#vdom).map((child) => child.el)
return extractChildren(this.#vdom).flatMap((child) => {
if (child.type === DOM_TYPES.COMPONENT) {
Checks whether
return child.component.elements
the node is a
}
component
return [child.el]
Otherwise, returns
Calls the elements
})
the node’s element
getter recursively
}
inside an array
256
CHAPTER 11
Subcomponents: Communication via props and events
return [this.#vdom.el]
}
// --snip-- //
}
// --snip-- //
return Component
}
With this small but necessary change out of the way, let’s focus on mounting, destroying, and patching component nodes.
11.1.2 Mounting component virtual nodes
To mount component nodes, first you need to include the DOM_TYPES.COMPONENT case in the mountDOM() function’s switch statement. The boldface code in the following listing shows how. Make this change in the mount-dom.js file.
Listing 11.3
Mounting component virtual nodes (mount-dom.js)
export function mountDOM(vdom, parentEl, index, hostComponent = null) {
switch (vdom.type) {
case DOM_TYPES.TEXT: {
createTextNode(vdom, parentEl, index)
break
}
case DOM_TYPES.ELEMENT: {
createElementNode(vdom, parentEl, index, hostComponent)
break
}
case DOM_TYPES.FRAGMENT: {
createFragmentNodes(vdom, parentEl, hostComponent)
break
}
Checks whether
the node is a
component
case DOM_TYPES.COMPONENT: {
createComponentNode(vdom, parentEl, index, hostComponent) break
}
Mounts the
component node
default: {
throw new Error(`Can't mount DOM of type: ${vdom.type}`)
}
}
}
The job of mounting the component is delegated to a function called createComponentNode(), which you need to implement next. Let’s see what this function needs to do.
11.1
Adding components as a new virtual DOM type
257
First, you want to extract the component’s instance from the virtual node’s tag key and instantiate it, using code similar to the following:
const vnode = {
type: 'component',
tag: FooComponent,
props: {},
}
const Component = vnode.tag
const component = new Component()
In chapter 9, you learned that you can instantiate a component class by passing it a props object. These props objects contain data received from the parent component.
The virtual node you defined also includes a props object. Initially, you used this object to pass attributes and event handlers to element nodes; you can use it to pass props and event handlers to component nodes as well.
Let’s explore an example that demonstrates how to achieve this goal. Suppose that you’ve defined a component called Greetings that receives a name prop and renders a paragraph with a greeting message:
const Greetings = defineComponent({
render() {
const { name } = this.props
return h('p', {}, [`Hello, ${name}!`])
}
})
To instantiate this component including the name prop, you can do something like the following:
const vnode = {
type: 'component',
tag: Greetings,
props: { name: 'Jenny' },
}
const Component = vnode.tag
const component = new Component(vnode.props)
Next, you want to mount the component. To do so, call the component instance’s mount() method, which renders its view via the render() method and passes the resulting virtual DOM tree to the mountDOM() function. Whenever another component is found in the virtual DOM tree, the process is repeated. Figure 11.4 illustrates this cycle.
Keep in mind one very important thing when you mount component nodes: you want to keep the instance of the component in the virtual node. Components have state—state that changes as the user interacts with its view. You want to keep this state
258
CHAPTER 11
Subcomponents: Communication via props and events
Component
1. Mount component.
3. Call mountDOM().
mount()
function mountDOM() {
.
...
2. Render.
render()
component.mount()
<div>
...
}
mountDOM()
<h1>
Component
6. Call
mount()
4. Mount component.
render()
Text
"A"
<p>
...
5. Render.
Figure 11.4
Mounting a component virtual node
“alive” throughout the application’s lifecycle. If the component is instantiated every time the DOM is patched, the state is lost. This internal state is the key difference between your new stateful components and the previous version, which were pure functions without internal state. By saving the component instance in the virtual node, you can access it inside the patchDOM() function and, thus, don’t need to instantiate it again. Figure 11.5 shows how the component instance is saved in the virtual node.
Component
new Component(...)
C
<div>
The component instance is
el
saved into the virtual node.
<h1>
<p>
el
el
Text
Text
el
el
"A"
"B"
Figure 11.5
A reference to the component instance is saved in the
virtual node.
11.1
Adding components as a new virtual DOM type
259
Following the preceding example, after you instantiate and mount the component, you save the instance in the virtual node inside a property called component, as follows: const Component = vnode.tag
const component = new Component(vnode.props)
component.mount(parentEl)
vdom.component = component
Don’t forget to keep a reference to the component’s DOM element in the virtual node’s el property (figure 11.6).
new Component(...)
Component
C
el
<div>
el
Figure 11.6
A reference to the
The component DOM element
component’s DOM element is saved
...
is referenced in the node.
in the virtual node.
But wait! Again, you’ll recall from chapter 9 that if a component’s view is a fragment, it doesn’t have one single HTML element as its root, but an array of elements. What do you do in this case?
The use case for the el reference is the reconciliation algorithm. In the case of a component, the el reference’s sole use is to serve as a reference node to insert elements before the component. To move a node to a different position, use the insertBefore() method, which requires a reference node after the location where the node will be inserted. To insert an element before a component, you should make sure that the el property points to the component’s first element (figure 11.7).
[ <h1> <p> <div> <span> ]
Component
C
elements
el
The component’s first DOM element
is referenced in the node.
Fragment
...
Figure 11.7
The el property points to the component’s first element.
By keeping the el reference of a component node pointing to the first element, you can move DOM nodes before the component, as shown in figure 11.8. In this case, the
260
CHAPTER 11
Subcomponents: Communication via props and events
view of the component is made of a fragment containing an <h1> and a <p> element.
A <div> outside the component is moved before the component, for which purpose the component’s first element—the <h1> element—is used as the reference node.
The parent node
The moving node
The component’s
<body>
first node
Component
body.insertBefore( <div> , <h1> )
<h1>...</h1>
<p>...</p>
<div>...</div>
Component
<h1>...</h1>
</body>
<p>...</p>
<body>
<div>...</div>
Component
The <div> is correctly placed before
<h1>...</h1>
the component’s first element (<h1>).
<p>...</p>
</body>
Figure 11.8
Moving a component to a different position in the DOM tree
You’re ready to implement the createComponentNode() function. Inside the mount-dom.js file, add the code in the following listing.
Listing 11.4
Mounting a component node (mount-dom.js)
function createComponentNode(vdom, parentEl, index, hostComponent) {
const Component = vdom.tag
Extracts the
const props = vdom.props
component’s class
const component = new Component(props)
Extracts the props
component.mount(parentEl, index)
vdom.component = component
vdom.el = component.firstElement
Instantiates the component
}
passing it the props
Saves the component’s first DOM
element in the virtual node
Mounts the component at
the given parent and index
Saves the component
instance in the virtual node
NOTE
You passed the hostComponent as the fourth argument to the create-
ComponentNode() function, but you haven’t used it yet; you’ll use it in section
11.1
Adding components as a new virtual DOM type
261
11.2 to implement the event listeners. I thought it would be convenient to have the function in place before you get to that point.
Hooray! Now you can mount component nodes, which means that you can have subcomponents inside your components. These child components can be rendered only once—when the component is mounted (because your patching algorithm doesn’t handle components at the moment)—but this is a good start. In section 11.1.3, you find out how to destroy component nodes.
Exercise 11.1
Write a component called List that renders a list of items inside a <ul> element.
These items are passed to the component as a prop called items, which is an array of strings. Each item should be rendered using another component called ListItem, which renders a <li> element with the item’s text.
The result of mounting the component as follows
const items = ['foo', 'bar', 'baz']
const list = new List({ items })
list.mount(document.body)
should be the following HTML:
<ul>
<li>foo</li>
<li>bar</li>
<li>baz</li>
</ul>
Look at the answer in the following link if you get stuck. You must understand how to define subcomponents as you move forward in this chapter.
Find the solution at
11.1.3 Destroying component virtual nodes
Once again, destroying is always easier than creating. Destroying a component is as simple as calling its unmount() method. You implemented the unmount() method in chapter 9, and, if you recall, that method calls the destroyDOM() by passing it the component’s virtual DOM tree. If the component has subcomponents, the destroyDOM() method is called recursively (figure 11.9).
Because you saved the component instance in the virtual node, now you can access it inside the destroyDOM() function, which is a straightforward addition to the destroyDOM() method. Open the destroy-dom.js file, and add the code shown in bold in the following listing.
262
CHAPTER 11
Subcomponents: Communication via props and events
Component
1. Unmount
function destroyDOM() {
component.
2. Call
.
destroyDOM()
unmount()
...
.
component.unmount()
<div>
...
}
destroyDOM()
<h1>
Component
4. Call
3. Unmount component.
unmount()
Text
"A"
<p>
...
Figure 11.9
Destroying a component’s DOM tree
Listing 11.5
Destroying the component (destroy-dom.js)
export function destroyDOM(vdom) {
const { type } = vdom
switch (type) {
case DOM_TYPES.TEXT: {
removeTextNode(vdom)
break
}
case DOM_TYPES.ELEMENT: {
removeElementNode(vdom)
break
}
case DOM_TYPES.FRAGMENT: {
removeFragmentNodes(vdom)
break
}
Calls the node’s
component instance
case DOM_TYPES.COMPONENT: {
unmount() method
vdom.component.unmount()
break
}
default: {
throw new Error(`Can't destroy DOM of type: ${type}`)
}
}
11.1
Adding components as a new virtual DOM type
263
delete vdom.el
}
11.1.4 Patching component virtual nodes
Now let’s look at how you can patch component nodes. When the state of a component changes or when it receives new props, this component patches its view and instructs its subcomponents to do the same. The view of a component can change only if its state changes or its props change. So when the state changes or the props of a component change, the component patches its view and passes the new props to its subcomponents. The subcomponents state doesn’t change, but their props might. Figure 11.10 illustrates how a component whose state changes notifies its subcomponents to patch their views.
Component
Component
The state of the parent
state:
state:
component changes.
- foo: 3
- foo: 8
- bar: 5
- bar: 7
The new state flows
<div>
<div>
down the view.
Component
Component
Component
Component
props:
props:
props:
props:
- foo: 3
- bar: 5
- foo: 8
- bar: 7
The new state is received as props
in the child components.
Figure 11.10
The state of a component changes, and its subcomponents receive new props.
Then, to patch the subcomponents inside the virtual DOM tree of a component, you need to extract the new props passed to them and call their updateProps() method, which internally calls the patch() method of the component. Let’s implement the updateProps() method in the Component class to update the props of the component and re-render it. Add the boldface code in the following listing to the component.js file.
Listing 11.6
Updating the props of a component (component.js)
export function defineComponent({ render, state, ...methods }) {
class Component {
// --snip-- //
264
CHAPTER 11
Subcomponents: Communication via props and events
updateProps(props) {
this.props = { ...this.props, ...props }
Merges the new
this.#patch()
props with the
}
old ones
updateState(state) {
Re-renders the
this.state = { ...this.state, ...state }
component
this.#patch()
}
// --snip-- //
}
// --snip-- //
}
Now add the code to patch component nodes (shown in bold in the following listing) to the patch-dom.js file. You add a case inside the switch statement that checks what type of node you’re patching.
Listing 11.7
Adding the component case to the patchDOM() function (patch-dom.js) export function patchDOM(
oldVdom,
newVdom,
parentEl,
hostComponent = null,
) {
if (!areNodesEqual(oldVdom, newVdom)) {
const index = findIndexInParent(parentEl, oldVdom.el)
destroyDOM(oldVdom)
mountDOM(newVdom, parentEl, index, hostComponent)
return newVdom
}
newVdom.el = oldVdom.el
switch (newVdom.type) {
case DOM_TYPES.TEXT: {
patchText(oldVdom, newVdom)
return newVdom
}
case DOM_TYPES.ELEMENT: {
patchElement(oldVdom, newVdom, hostComponent)
break
}
Calls the method
that patches the
case DOM_TYPES.COMPONENT: {
component
patchComponent(oldVdom, newVdom)
break
}
}
11.1
Adding components as a new virtual DOM type
265
patchChildren(oldVdom, newVdom, hostComponent)
return newVdom
}
// TODO: implement patchComponent()
Next, you implement the patchComponent() function. This function is in charge of extracting the new props from the virtual node and calling the component’s updateProps() method. The function should also save the component instance in the new virtual node so that it can be accessed later. Because components can change their top-level element between renders, you want to save the component’s first DOM
element in the node’s el property. Write the code in the following listing inside the patch-dom.js file.
Listing 11.8
Patching a component node (patch-dom.js)
Extracts the component instance
function patchComponent(oldVdom, newVdom) {
from the old virtual node
const { component } = oldVdom
const { props } = newVdom
Extracts the new props
from the new virtual node
component.updateProps(props)
Updates the component’s props
newVdom.component = component
newVdom.el = component.firstElement
Saves the component instance
}
in the new virtual node
Saves the component’s first DOM
element in the new virtual node
And just like that, you can use child components inside your components and patch them when the state of the parent component changes. If you’re interested in optimizing the patching of components, avoiding unnecessary re-renders, you can read section 11.1.5, which is optional. Otherwise, jump to section 11.2, which discusses how child components can communicate with their parent components via events.
11.1.5 A rendering optimization (optional)
You may have realized that you have a good opportunity here to optimize the patching of components. Can you guess what it is? Exactly: you can check whether the props of the component have changed and re-render only if they have. By comparing the old and new prop objects, you can avoid patching the component—and all its subcomponents—if its props haven’t changed, as shown in figure 11.11.
This approach may eliminate the need to check entire (and potentially long) branches of the virtual DOM tree. But this benefit comes at a cost: you need to compare the old and new props objects, which can be expensive if the objects are large.
As it turns out, comparing objects in JavaScript isn’t as straightforward as comparing primitive values. The === operator returns true only if both objects are the same object (reference equality). When the render() function of a component is called, it
266
CHAPTER 11
Subcomponents: Communication via props and events
Component
The state of the parent
component changes.
state:
- foo: 8
- bar: 5
The new state flows
down the view.
<div>
The new state is received as
props in the child component.
The props of this component
don’t change.
Component
Component
props:
props:
- foo: 8
- bar: 5
The component view doesn’t
The component’s view
need to be re-rendered.
needs to be re-rendered.
<div>
<div>
...
...
Figure 11.11
The state of a component changes, but not all the props of its child components change.
produces a fresh virtual DOM object, which means that the old and new props objects will never be the same object.
This situation isn’t a big deal because the community has satisfied this need by creating libraries that handle deep-object comparison. One such library is fast-deep-equal (which is small and does exactly what we need.
This optimization is optional, so feel free to skip this section if you’re not interested in it. First, you need to add the fast-deep-equal library to your runtime package as follows (making sure that you’re in the packages/runtime folder): npm install fast-deep-equal
Then you need to import the library into the component.js file and use it to compare the old and new props objects inside the component’s updateProps() method: import equal from 'fast-deep-equal'
// --snip-- //
export function defineComponent({ render, state, ...methods }) {
class Component {
// --snip-- //
updateProps(props) {
Creates a new props
object by merging the
#this.props = { ...this.props, ...props }
old and new props
const newProps = { ...this.props, ...props }
11.1
Adding components as a new virtual DOM type
267
if (equal(this.props, newProps)) {
Compares the old and new
return
props objects; returns if
}
they’re equal
this.props = newProps
Updates the
this.#patch()
component’s props
}
// --snip-- //
}
}
With this simple change, your components will be patched only if their props have changed. But to bundle the code in the fast-deep-equal library, you need to install two extra rollup plugins:
@rollup/plugin-commonjs (—Transforms the fast-deep-equal library code from CommonJS to ES syntax, allowing Rollup to bundle the library code.
@rollup/plugin-node-resolve (Resolves the fast-deep-equal library, which is a third-party module, and includes it in the bundle.
By default, Rollup resolves only modules that are part of your project. This plugin allows you to resolve third-party modules and include them in the bundle.
Install the libraries in the packages/runtime folder as dev dependencies: $ npm install --save-dev @rollup/plugin-commonjs @rollup/plugin-node-resolve Now you need to edit the rollup configuration file, packages/runtime/rollup.config.js, to add the plugins to the plugins array:
import commonjs from '@rollup/plugin-commonjs'
import { nodeResolve } from '@rollup/plugin-node-resolve'
import cleanup from 'rollup-plugin-cleanup'
import filesize from 'rollup-plugin-filesize'
export default {
input: 'src/index.js',
plugins: [commonjs(), nodeResolve(), cleanup()],
output: [
{
file: 'dist/fe-fwk.js',
format: 'esm',
plugins: [filesize()],
},
],
}
Run the npm run build script to make sure that your configuration is correct and the output JavaScript file is generated correctly. If you open the bundled JavaScript file for
268
CHAPTER 11
Subcomponents: Communication via props and events
your framework, inside the dist folder, you should see the fast-deep-equal library code included in the bundle. Look for a function called fastDeepEqual(): var fastDeepEqual = function equal(a, b) {
if (a === b) return true;
if (a && b && typeof a == 'object' && typeof b == 'object') {
if (a.constructor !== b.constructor) return false;
// --snip-- //
}
// --snip-- //
}
With this small change, you’ve added a useful optimization to your framework.
11.2
Events
A component can pass information to its child components via props, which is how a parent component communicates with its children. But how can a child component communicate with its parent? It communicates via events that the parent component can listen to, as shown in figure 11.12.
Component
Events move up the
The parent component
component hierarchy.
can handle the event.
handle( )
Event
payload
<div>
Component
Component
events:
- click
Figure 11.12
A child component communicates with its parent via events.
The parent component defines the event-handler function and registers it to listen to events emitted by the child component. The events emitted by a child component aren’t DOM events; they’re custom events identified by a name and containing a payload. The child component that receives the event-handler function from the parent component needs to save that function in a registry, where it can be called when the event is emitted. (If this process reminds you a lot of your Dispatcher class, you’re headed in the right direction.)
11.2
Events
269
Events vs. callbacks
If you’ve used React, you’re probably used to passing callbacks from parent to child components:
function MyButton({ onClick }) {
return <button onClick={onClick}>Click me</button>
}
Then the parent component defines a function that will be called when the button is clicked and passes it to the child component:
function App() {
function handleClick() {
console.log('Ooh la la!')
}
return <MyButton onClick={handleClick} />
}
In other frameworks, such as Vue, you emit an event from the child component instead and never receive the callback function from the parent component:
<template>
<button @click="$emit('click')">Click me</button>
</template>
The $emit() method, which is similar to what you’ll implement in this section, emits a custom (non-DOM) event. Then the parent component listens to that event:
<script setup>
import MyButton from './MyButton.vue'
function handleClick() {
console.log('Ooh la la!')
}
</script>
<template>
<MyButton @click="handleClick" />
</template>
Although these two approaches seem to be different, you’ll see as you implement this feature that they’ve very similar. The difference is mostly syntactical, related to how a framework decides to expose the feature to the developer.
In the case of React, using JSX (which is a more convenient way of defining the virtual DOM tree for your view), you operate at the virtual DOM level. Thus, you must explicitly pass the event-handler function from the parent to the child component.
270
CHAPTER 11
Subcomponents: Communication via props and events
(continued)
In the case of Vue, you work with a template. It feels natural to add event handlers inside the template of a component to handle the events that its child components emit. But under the hood, Vue compiles your template into a render function that operates at the virtual DOM level, where the event handler is passed to the child component as a prop—the same thing that happens in React.
A parent component listening to an event from a child component fits well with the way you’ve implemented the virtual DOM in your framework. If you recall from chapter 3, to add an event listener function to an element node, you include it inside the on object of the props parameter of the h() function:
h(
'button',
{ on: { click: () => console.log('Ooh la la!') } },
['Click me']
)
You can make your components emit events. These events won’t be instances of the Event interface (), which represents events arising from the user’s interaction with the DOM; they’ll be simple JavaScript objects with the desired payload. The key idea is that you can add event listeners to your components by using exactly the same syntax that you use to add event listeners to DOM elements. Here’s an example:
h(
MyFancyButton,
{ on: { btnClicked: () => console.log('Much fancy, too cool, wow!') } },
['Click me']
)
Next, you’ll see how to implement this feature in your Component prototype.
11.2.1 Saving the event handlers inside the component
You want to save those event listeners passed by the parent component inside the component that emits the event (the subcomponent or child component). Suppose that you have a MyFancyButton component emitting a custom event called btnClicked, and its parent component renders it like so:
h(
MyFancyButton,
{
on: {
btnClicked: () => { ... }
}
},
11.2
Events
271
['Click me']
)
You want to save the passed-in btnClicked event handler inside the MyFancyButton component. The component can accept that event-handler object as a parameter in its constructor and save it in a private property called #eventHandlers.
Then, when the child component (MyFancyButton) emits an event, it can look up the event handler in the #eventHandlers object and call it. Do you recall the Dispatcher class that you implemented in chapter 5? It’s the perfect fit for this task—registering handlers for events and calling them when the event is emitted. You used Dispatcher to dispatch and handle commands, but you can use it to dispatch and handle events as well. (Remember that the difference between commands and events is mostly in the nomenclature: events represent something that happened in the past, whereas commands represent something that should happen in the future.) You need to take one important subtlety into account, though: when a parent component declares an event handler attached to a child component, it expects the event handler to be called with the parent component as the this value. A common use case is when the event handler modifies the state of the parent component: const ParentComponent = defineComponent({
state() {
return { counter: 0 }
},
render() {
const { counter } = this.state
return hFragment([
h('p', {}, [The count is: ${counter}]),
h(
MyFancyButton,
{
on: {
btnClicked: () => {
this.updateState({ counter: counter + 1 })
Inside the handler,
},
this should be
},
ParentComponent.
},
['Increment']
)
])
}
})
You want to explicitly bind the this value of the event handler to the parent component, not the child component executing the event handler (figure 11.13). This point is very important, so keep it in mind: the this value of the event handler should be the parent component, not the child component calling the event handler.
272
CHAPTER 11
Subcomponents: Communication via props and events
Component
We don’t want the method’s context
Items
bound to the button element.
render()
handle( )
Component
Load more
render()
Binding !
The event handler is
events:
defined by the parent
- btnClicked
'btnClicked' : handle
component.
Binding
The method’s context should be
'this' shouldn’t be bound
bound to the parent component.
to the child component.
Figure 11.13
The this value of the event handler should be the parent component.
Let’s add the #eventHandlers property to the Component prototype. We’ll also pass the reference to the parent component to the constructor so that we can use it to explicitly bind the event handler’s context. Inside the component.js file, add the code shown in bold in the following listing.
Listing 11.9
Passing event handlers to a component (component.js)
export function defineComponent({ render, state, ...methods }) {
class Component {
#isMounted = false
#vdom = null
Declares the eventHandlers
#hostEl = null
private property
#eventHandlers = null
#parentComponent = null
Declares the parentComponent
private property
constructor(
props = {},
eventHandlers = {},
parentComponent = null,
) {
this.props = props
Saves the passed-in
this.state = state ? state(props) : {}
event handlers
this.#eventHandlers = eventHandlers
this.#parentComponent = parentComponent
Saves the passed-in
}
parent component
reference
// --snip-- //
}
// --snip-- //
return Component
}
11.2
Events
273
Before you implement the wiring of the event handlers, you want to go back to the mount-dom.js and patch-dom.js files, and pass the event handlers and the parent component reference to the component constructor.
11.2.2 Extracting the props and events for a component
As you probably remember from destructuring the props and events for an element node (refer to the mount-dom.js file inside the addProps() function), separating the props from the events is as simple as this:
const { on: events, ...props } = vdom.props
You could simply add that same line to the createComponentNode() function (in the mount-dom.js file) and the patchComponent() function (in the patch-dom.js file) functions. But you’ll add some extra logic to the props and events extraction in chapter 12 when you learn about keyed lists. (You want to remove the key prop, but let’s not get ahead of ourselves.) To avoid duplicating the same logic in two places, I prefer to create a separate file inside the utils/ directory, call it props.js, and write a function that extracts the props and events for a component. In that utils/props.js file, add the extractPropsAndEvents() function shown in the following listing.
Listing 11.10
Extracting the props and events for a component (utils/props.js)
export function extractPropsAndEvents(vdom) {
const { on: events = {}, ...props } = vdom.props
return { props, events }
}
Now open the mount-dom.js file, import the extractPropsAndEvents() function, and use it inside the createComponentNode() function to extract the props and events.
Then you want to pass the props, event handlers, and parent component to the component constructor. The parent component is the fourth hostComponent parameter passed to the createComponentNode() function. You added this parameter in listing 11.4 but didn’t use it then. Implement these changes as shown in bold in the following listing.
Listing 11.11
Extracting the props and events (mount-dom.js)
import { extractPropsAndEvents } from './utils/props'
// --snip-- //
function createComponentNode(vdom, parentEl, index, hostComponent) {
const Component = vdom.tag
const props = vdom.props
Extracts the
const component = new Component(props)
props and events
const { props, events } = extractPropsAndEvents(vdom)
274
CHAPTER 11
Subcomponents: Communication via props and events
const component = new Component(props, events, hostComponent) component.mount(parentEl, index)
Passes the props, event handlers,
vdom.component = component
and parent component to the
vdom.el = component.firstElement
component constructor
}
Similarly, open the patch-dom.js file, import the extractPropsAndEvents() function, and use it inside the patchComponent() function, as shown in the following listing. In this case, you pass only the props; we’re expecting that the event handlers won’t change between patches.
Listing 11.12
Extracting the props and events (patch-dom.js)
import { extractPropsAndEvents } from './utils/props'
// --snip--
function patchComponent(oldVdom, newVdom) {