const { component } = oldVdom
const { props } = newVdom
const { props } = extractPropsAndEvents(newVdom)
component.updateProps(props)
newVdom.component = component
newVdom.el = component.firstElement
}
With this little detour out of the way, you can go back to the component.js file and implement the logic to wire the event handlers.
NOTE
After the child component is mounted, we don’t allow the parent component to change the event handlers or register new ones. This design decision is deliberate because it simplifies the implementation of the component patching logic. A few good use cases for event handlers to change between renders might exist, but they’re not very common. You can try to implement this logic as an exercise.
11.2.3 Wiring the event handlers
When the component is mounted, you want to iterate over the #eventHandlers object and register the event handlers in the component. How can you register event handlers in a component? You use the Dispatcher class you implemented in chapter 5.
Each component instance has its own dispatcher where it can register event handlers and emit the events.
You want to save the functions that the subscribe() method returns so you can call them later to unsubscribe the event handlers when the component is unmounted.
(If you recall, the subscribe() method returns a function that, when called, unsubscribes the event handler.)
11.2
Events
275
Inside the Component class, write a private method called #wireEventHandler() that takes the event name and the event handler as parameters and subscribes the event handler to the component’s dispatcher. Don’t forget to bind the event handler’s context to the parent component instance—the component that’s defining the event handler. Then, for convenience, a second method called #wireEventHandlers() (in plural) can do the work of iterating over the #eventHandlers object and call the
#wireEventHandler() method for each event handler, saving the unsubscribe functions in a #subscriptions private property.
In your component.js file, add the code shown in bold in the following listing.
Don’t forget to import the Dispatcher class from the dispatcher.js file.
Listing 11.13
Wiring the event handlers from the parent component (component.js) import equal from 'fast-deep-equal'
import { destroyDOM } from './destroy-dom'
import { Dispatcher } from './dispatcher'
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
#eventHandlers = null
Creates a new
#parentComponent = null
dispatcher
#dispatcher = new Dispatcher()
#subscriptions = []
Creates an array for
the unsubscribe
// --snip-- //
functions
#wireEventHandlers() {
this.#subscriptions = Object.entries(this.#eventHandlers).map(
([eventName, handler]) =>
this.#wireEventHandler(eventName, handler)
Iterates over the
)
event handler’s object
}
#wireEventHandler(eventName, handler) {
return this.#dispatcher.subscribe(eventName, (payload) => {
if (this.#parentComponent) {
handler.call(this.#parentComponent, payload)
If there is a parent
} else {
component, binds
handler(payload)
If no parent
the event handler’s
}
component
context to it and
})
exists, calls the
calls it
}
event handler
with no context
// --snip-- //
}
276
CHAPTER 11
Subcomponents: Communication via props and events
// --snip-- //
return Component
}
Now you can call the #wireEventHandlers() method from the mount() method. You also want to unsubscribe the event handlers when the component is unmounted. Add the changes shown in bold in the following listing.
Listing 11.14
Wiring the event handlers (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')
}
this.#vdom = this.render()
Wires the event
handlers when the
mountDOM(this.#vdom, hostEl, index, this)
component is mounted
this.#wireEventHandlers()
this.#isMounted = true
this.#hostEl = hostEl
}
unmount() {
if (!this.#isMounted) {
Unsubscribes the
throw new Error('Component is not mounted')
event handlers when
}
the component is
unmounted
destroyDOM(this.#vdom)
this.#subscriptions.forEach((unsubscribe) => unsubscribe())
this.#vdom = null
this.#isMounted = false
this.#hostEl = null
this.#subscriptions = []
Clears the
}
subscriptions
array
// --snip-- //
}
// --snip-- //
return Component
}
Now you can subscribe event handlers passed from the parent component in a child component, but you want a way of emitting these events from your component. As you
11.2
Events
277
can guess, that’s as easy as calling the dispatcher’s emit() method. Section 11.2.4
shows how.
Exercise 11.2
Can you explain why you need to bind the event handler’s context to the parent component instance when you subscribe the event handler?
Find the solution at .
11.2.4 Emitting events
You can emit events from the component by calling the emit() method on the component’s dispatcher:
const MyFancyButton = defineComponent({
render() {
return h(
'button',
{
on: { click: () => this.#dispatcher.dispatch('click') }
},
['Click me!']
)
}
})
But that code is a bit verbose. You want the users of your framework to use an API that’s as simple as possible. Something like the following would be ideal: const MyFancyButton = defineComponent({
render() {
return h(
'button',
{
on: { click: () => this.emit('click') }
},
['Click me!']
)
}
})
You can achieve that goal easily by adding an emit() method to the Component class.
Add the code shown in bold in the following listing to your component.js file.
Listing 11.15
Emitting events from the component (component.js)
export function defineComponent({ render, state, ...methods }) {
class Component {
// --snip-- //
278
CHAPTER 11
Subcomponents: Communication via props and events
emit(eventName, payload) {
this.#dispatcher.dispatch(eventName, payload)
}
}
// --snip-- //
return Component
}
With this last change, your Component class is complete. In chapter 12, you’ll refactor the createApp() function to return an application instance made of stateful components. Then you’ll look into an interesting problem: what happens when stateful components move around the DOM? Does the reconciliation algorithm know how to handle it? After solving that problem, you’ll publish the new version of your framework.
Exercise 11.3
Write the code for a component called SearchField that renders an input field and emits a search event when the user types in the field. You want to debounce the event by 500 milliseconds. (Assume that you have a debounce() function that takes a function and a delay as parameters and returns a debounced version of the function.) Find the solution .
Summary
A parent component can pass data to a child component by passing it as props.
A child component can pass data to a parent component by emitting events to which the parent component can subscribe.
The child component saves the registered event handlers in a #eventHandlers object and subscribes them to its dispatcher when the component is mounted.
When the state of a component changes or its props change, the component re-renders its view. In this process, when a subcomponent appears in the view tree, the parent component passes the subcomponent’s props to it and tells it to re-render its view.
Keyed lists
This chapter covers
Working with keyed lists of components
Working with keyed lists of elements
Updating the application instance
Publishing the new version of the framework
Components have internal state. This state is hidden from the outside world—not reflected in the virtual DOM tree that represents the view—and thus can’t be used to compare two component virtual DOM nodes. But to patch the view correctly, you must make sure that when you have a list of components, the reconciliation algorithm can distinguish among them.
Let’s look at an example. Suppose that you have a Counter component with a count state property, which can be initialized with a different value for each component instance:
const Counter = defineComponent({
state(props) {
return { count: props.initialCount }
},
279
280
CHAPTER 12
Keyed lists
render() {
return h('p', {}, [`Count: ${this.state.count}`])
},
})
Then you render a list of three Counter components, each of which has a different value for the count state:
h('div', {}, [
h(Counter, { initialCount: 0 }),
h(Counter, { initialCount: 1 }),
h(Counter, { initialCount: 2 }),
])
Note that the value of each count state property isn’t reflected in the virtual DOM
tree. That information is part of the component’s state, stored inside the Counter component instances. The rendered HTML would look like the following:
<div>
<p>Count: 0</p>
<p>Count: 1</p>
<p>Count: 2</p>
</div>
What happens if the Counter component in the middle of the list is removed? The new virtual DOM tree would look like this:
h('div', {}, [
h(Counter, { initialCount: 0 }),
h(Counter, { initialCount: 2 }),
])
But the reconciliation algorithm you wrote can’t know which Counter component was removed, as illustrated in figure 12.1. The reconciliation algorithm doesn’t know how to distinguish among the components at all; all components look the same to it.
By comparing the two virtual DOM trees, the arraysDiffSequence() function would wrongly conclude that the last Counter component was removed. The resulting HTML would (incorrectly) be
<div>
<p>Count: 0</p>
<p>Count: 1</p>
</div>
when it should have been
<div>
<p>Count: 0</p>
<p>Count: 2</p>
</div>
281
Old vdom
New vdom
<div>
<div>
Component
Component
Component
Component
Component
state:
state:
state:
state:
state:
- count: 0
- count: 1
- count: 2
- count: 0
- count: 2
<p>
<p>
<p>
<p>
<p>
Text
Text
Text
Text
Text
"Count: 0"
"Count: 1"
"Count: 2"
"Count: 0"
"Count: 2"
The reconciliation algorithm can’t access the components
state and thus can’t know what component was removed.
All components look equal to the reconciliation algorithm.
Figure 12.1
All components look the same to the reconciliation algorithm.
You might think that the solution is as straightforward as making the areNodesEqual() compare the internal state and props of the components to determine whether they’re equal; a component is fully characterized by its internal data.
Unfortunately, this solution isn’t possible. Before reading the next paragraph, try to think why.
This solution isn’t possible because the areNodesEqual() function receives one node from the old virtual DOM tree, which contains a reference to the component instance, and another node from the new virtual DOM tree, which doesn’t. Without access to the instantiated component in the new virtual DOM, you can’t access its internal state and props, as shown in figure 12.2.
Your areNodesEqual() function would receive the following old and new nodes: const oldNode = {
type: 'component',
tag: Counter,
props: {},
component: <instance of Counter>,
}
const newNode = {
type: 'component',
tag: Counter,
props: {},
}
282
CHAPTER 12
Keyed lists
Old vdom
New vdom
<div>
<div>
Component
Component
Component
Component
Component
C
C
C
el
el
el
Mounted components have a reference to the component
Components that haven’t been mounted
instance and the mounted DOM element.
or patched don’t have a reference to
an existing instance.
Figure 12.2
The new virtual DOM tree doesn’t contain a reference to the component instance.
It’s obvious that you can’t access the count property of the component instance from the newNode object; thus, you can’t use it to compare whether the two nodes are equal.
Clearly, you need another solution.
The goal of this chapter is to explore the solution to this problem. As you might have guessed from the title of the chapter, that solution is the key attribute that the developer can provide to each component. The chapter also explores some poor practices that you should avoid when using the key attribute. Finally, you’ll publish a new version of the framework, version 3.0, that supports stateful components.
NOTE
You can find all the listings in this chapter in the listings/ch12/
directory of the book’s repository (). The code in this chapter can be checked out from the ch12 label
$ git switch --detach ch12.
Code catch-up
In chapter 11, you added the component as a new type of virtual DOM node and extended the h() function to support it. You modified the mountDOM() function to include a case for the component type, handled by the createComponentNode() function. You also modified the destroyDOM() function to include a case for the component, in this case simply calling the component’s unmount() method.
Then you worked on adapting the patchDOM() function to support the component type. For that purpose, you had to include a new method in the Component class: updateProps(). This method is called inside the patchComponent() function, which you wrote to handle the component case in the patchDOM() function.
You implemented a mechanism for components to handle events emitted by their child components, which involved adding two new private properties to the Component
12.1
The key attribute
283
class: #eventHandlers and #parentComponent. Then you wrote a function called extractPropsAndEvents() to extract the props and events from the props object of a virtual DOM node and used it inside the createComponentNode() and patchComponent() functions. A component subscribes to the events by using the #wireEventHandlers() private method, which you call inside mount(). You managed these subscriptions by using an instance of the Dispatcher class, saved inside the component as the #dispatcher private property.
Last, you wrote a new method, emit(), used to emit events from a component to its parent component.
12.1
The key attribute
If your framework has no way of comparing two component nodes in virtual DOM
trees to determine whether they are the same, how can you solve the reconciliation problem? In this case, you let the developer provide a unique identifier for each component: a key attribute. Following the Counter example from the introduction, the developer would provide a key attribute to each component as follows: h('div', {}, [
h(Counter, { key: 'counter-0', initialCount: 0 }),
h(Counter, { key: 'counter-1', initialCount: 1 }),
h(Counter, { key: 'counter-2' , initialCount: 2 }),
])
So when the Counter component in the middle of the list is removed, the new virtual DOM tree would look like this:
h('div', {}, [
h(Counter, { key: 'counter-0', initialCount: 0 }),
h(Counter, { key: 'counter-2', initialCount: 2 }),
])
Now the reconciliation algorithm is able to compare the key attribute of the Counter components to determine which one was removed. The only drawback is that you rely on the developer to provide a unique key attribute for each component. When each item in the list that produces the components doesn’t have a unique identifier, developers resort to using the index of the item in the list as the key attribute. I’ve taken this approach myself, but this approach is a bad idea, as you’ll see in section 12.3.1. Your framework won’t be resilient in these cases; all the responsibility is on the developer.
Let’s start by including the component case in the areNodesEqual() function.
Your framework currently doesn’t know how to compare component nodes (as of now, it deems all component pairs to be different), so this step is a good place to start.
You want this function to know at least when two components are instances of the
284
CHAPTER 12
Keyed lists
same component prototype. When the function is passed two component nodes that are instances of a different prototype, it should return false; if they are instances of the same prototype, it should return true, for now.
12.1.1 Component nodes equality
How can you compare two component virtual DOM nodes to determine whether they’re equal? First, you need to check whether their type properties are equal to DOM_TYPES.COMPONENT; then you want to know whether they are the same component prototype. In JavaScript, a prototype (or class definition) is, unsurprisingly, always equal to itself, so you can compare the tag properties of the two nodes. You can test this fact yourself in the browser console:
class Component {}
Component === Component // true
Open the nodes-equal.js file, and add the code shown in bold in the following listing to include the component case in the areNodesEqual() function.
Listing 12.1
Comparing two component nodes for equality (nodes-equal.js)
export function areNodesEqual(nodeOne, nodeTwo) {
if (nodeOne.type !== nodeTwo.type) {
return false
}
if (nodeOne.type === DOM_TYPES.ELEMENT) {
const { tag: tagOne } = nodeOne
const { tag: tagTwo } = nodeTwo
Checks whether the
return tagOne === tagTwo
type is component
}
Extracts the first node’s
if (nodeOne.type === DOM_TYPES.COMPONENT) {
component prototype
const { tag: componentOne } = nodeOne
const { tag: componentTwo } = nodeTwo
Extracts the second
node’s component
return componentOne === componentTwo
prototype
}
Compares the two
return true
component prototypes
}
This section is a great start. Next, let’s support the key attribute in the areNodesEqual() function.
12.1
The key attribute
285
12.1.2 Using the key attribute
You want developers to include a key attribute in a component node when it’s part of a dynamic list of components in which the nodes might be reordered, added, or removed. As we saw in the initial example, the key attribute is passed as a prop to the component, so you need to extract it from the props object.
You want to extract the key attribute from both nodes’ props objects and compare them. If the key values aren’t provided, they’ll be undefined, so you can still use the
=== operator to compare them. As you know, despite all JavaScript equality wilderness, undefined is equal to itself. (Yay!) Add the code shown in bold in the following listing.
Listing 12.2
Using the key attribute to compare component nodes (nodes-equal.js) export function areNodesEqual(nodeOne, nodeTwo) {
if (nodeOne.type !== nodeTwo.type) {
return false
}
if (nodeOne.type === DOM_TYPES.ELEMENT) {
const { tag: tagOne } = nodeOne
const { tag: tagTwo } = nodeTwo
return tagOne === tagTwo
}
if (nodeOne.type === DOM_TYPES.COMPONENT) {
const {
Extracts the first
tag: componentOne,
node’s key prop
props: { key: keyOne },
} = nodeOne
const {
tag: componentTwo,
Extracts the second
node’s key prop
props: { key: keyTwo },
} = nodeTwo
Compares
the two key
props
return componentOne === componentTwo && keyOne === keyTwo
}
return true
}
With that small change, your framework supports the key attribute to compare component nodes. How easy was that? But wait—you need to make one more important change. When the developer provides a key attribute to a component, you don’t want to pass it to the component as a prop. You want to remove it from the props object before passing the props to the component; otherwise, the component will receive a key prop that it doesn’t know what to do with.
286
CHAPTER 12
Keyed lists
12.1.3 Removing the key attribute from the props object
In chapter 11, you implemented a function called extractPropsAndEvents(); its job was to separate the props from the events in the props object of a virtual DOM node. I told you that although this function was a single line of code, it made sense to extract it into a separate function because you’d need to modify it later.
“Later” is now. Open the utils/props.js file, and add the code shown in bold in the following listing to remove the key attribute from the props object before passing it to the component.
Listing 12.3
Removing the key from the props object (utils/props.js) export function extractPropsAndEvents(vdom) {
const { on: events = {}, ...props } = vdom.props
delete props.key
Removes the key
attribute from the
return { props, events }
props object
}
You may wonder whether removing the key attribute from the props object in the virtual DOM tree will affect the reconciliation algorithm because the areNodesEquals() function requires this attribute to compare component nodes. That observation is valid . But you aren’t modifying the original props object in the vdom node; you’re creating a new one when you use the spread operator:
// props is a shallow copy of vdom.props
const { on: events = {}, ...props } = vdom.props
The component instance gets a props object without the key attribute, but the props object in the virtual DOM tree still has it. You can convince yourself by running a quick experiment in the browser console.
12.2
Extending the solution to element nodes
The reconciliation algorithm you wrote in chapters 7 and 8 is powerful enough to handle any kind of change in a list of elements, but it might do much more work than necessary in some cases. Figure 12.3 shows an example in where two <p> elements swap positions inside the same <section> element.
In a list of elements, if all elements have the same tag, the reconciliation algorithm—the arraysDiffSequence() function, to be more precise—will never know that an element has been moved (figure 12.4). Recall that we deem two elements to be equal if they have the same tag, regardless of their contents.
12.2
Extending the solution to element nodes
287
Old vdom
New vdom
<section>
The right branch moves
<section>
to the left.
<p>
<p>
<p>
<p>
Text
Text
<div>
Text
<div>
Text
<p>
<p>
<p>
<p>
Text
Text
Text
Text
Figure 12.3
Two elements swapping positions
Old vdom
New vdom
<section>
<section>
arraysDiffSequence()
[
<p>
<p>
{ op: 'noop' },
<p>
<p>
{ op: 'noop' },
]
...
...
...
...
The swap of the two paragraph
elements can t be detected.
’
Figure 12.4
The reconciliation algorithm can’t detect the swap of the two paragraph nodes.
Big portions of the DOM tree might be re-created unnecessarily because the reconciliation algorithm is incapable of detecting that an element has been moved. In the preceding example, all the elements in the <p> elements will be re-created even though they haven’t changed—simply moved together with their parent element. To see your reconciliation algorithm do unnecessary extra work, complete exercise 12.1.
288
CHAPTER 12
Keyed lists
Exercise 12.1
Let’s look at the problem of unnecessarily re-creating entire branches of the DOM
tree in action. You want to copy/paste the code from your framework into the browser’s console. You can use your framework’s previous version from unpkg.com or copy/
paste it from the bundled file inside your dist/ folder.
When you have all of your framework’s code available on the console, copy/paste the following virtual DOM tree:
const before = h('ul', {}, [
h('li', {}, ['Item 1']),
h('li', {}, ['Item 2']),
h('li', {}, [
h('div', {}, [
h('div', {}, [
h('div', {}, [
h('p', {}, ['Item 3']),
]),
]),
]),
]),
])
This <ul> list has three <li> elements, the last one containing a nested tree of elements. Mount this view into the body of the current document:
mountDOM(before, document.body)
You should see the list rendered in the browser. Now paste the following virtual DOM
tree, which is the same as the preceding one, but with the last item moved to the middle of the list (highlighted in bold):
const after = h('ul', {}, [
h('li', {}, ['Item 1']),
h('li', {}, [
h('div', {}, [
h('div', {}, [
h('div', {}, [
h('p', {}, ['Item 3']),
]),
]),
]),
]),
h('li', {}, ['Item 2']),
])
Your challenge is to set a breakpoint inside the patchDOM() function to inspect the diffSeq calculated inside the patchChildren() subfunction. You want to check what set of operations the reconciliation algorithm is going to perform to transform the before tree into the after tree. When the code you want to debug is code that you
12.2
Extending the solution to element nodes
289
pasted into the browser’s console, setting a breakpoint is a bit tricky. You may want to do a quick search in your browser of choice to find out how. Then execute the patchDOM() function to hit the breakpoint:
patchDOM(before, after, document.body)
Explore the flow of execution of the patchDOM() function, and try to answer the following questions:
Did the reconciliation algorithm figure out that it could simply move the last
<li> element to the middle of the list?
Did it detect any movement of elements inside the <ul>?
Was the second <li> element re-created from scratch?
Find the solution at
Why not extend the key attribute to element nodes? With a bit of help from the developer, we can guide the reconciliation algorithm to help it detect when an element has been moved. Figure 12.5 shows the same example as before, but this time, the developer provided a key attribute to each element node, and the reconciliation algorithm detects that the element has been moved.
Old vdom
New vdom
<section>
<section>
arraysDiffSequence()
[
<p>
<p>
<p>
<p>
{ op: 'move' },
key: one
key: two
{ op: 'noop' },
key: two
key: one
]
...
...
...
...
The swap of the two paragraph
elements is correctly detected.
Figure 12.5
Two elements swapping positions using the key attribute
Let’s modify the areNodesEqual() function to support the key attribute in element nodes. Open the nodes-equal.js file, and add the code shown in bold in the following listing.
Listing 12.4
Using the key attribute to compare element nodes (nodes-equal.js) export function areNodesEqual(nodeOne, nodeTwo) {
if (nodeOne.type !== nodeTwo.type) {
return false
}
290
CHAPTER 12
Keyed lists
if (nodeOne.type === DOM_TYPES.ELEMENT) {
const {
tag: tagOne,
Extracts the first
props: { key: keyOne },
node’s key prop
} = nodeOne
const {
tag: tagTwo,
Extracts the second
props: { key: keyTwo },
node’s key prop
} = nodeTwo
return tagOne === tagTwo && keyOne === keyTwo
Compares the
}
two key props
if (nodeOne.type === DOM_TYPES.COMPONENT) {
const {
tag: componentOne,
props: { key: keyOne },
} = nodeOne
const {
tag: componentTwo,
props: { key: keyTwo },
} = nodeTwo
return componentOne === componentTwo && keyOne === keyTwo
}
return true
}
With this simple change, you can use the key attribute to guide the reconciliation algorithm when an element has been moved. Now you need to modify the mountDOM() function to use the extractPropsAndEvents() function, which is in charge of removing the key attribute from the props object before passing it to the element. If you didn’t do this, your addProps() function would attempt to add a key attribute to the DOM node, which you don’t want to do. The key attribute doesn’t exist for DOM elements.
NOTE
If the key attribute existed in the DOM standard, you’d want to name the prop differently to prevent collisions. Some frameworks prepend their internal props with a dollar sign ($key) or two dollar signs ($$key). You could also look at Python for inspiration and use dunders (double underscores), as in __key__. For simplicity, we’ll use key as the prop name. The only drawback to this approach is that you won’t be able to call key a prop in your components because key is reserved for the framework.
Open the mount-dom.js file, and modify the code in the createElementNode() function as shown in the following listing. You want to remove the part where you destructure the props object and pass it to the addProps() function. The addProps() function will be in charge of extracting the props and events from the vdom node.
12.3
Using the key attribute
291
Listing 12.5
Modifying the createElementNode() function (mount-dom.js)
function createElementNode(vdom, parentEl, index, hostComponent) {
const { tag, props, children } = vdom
const { tag, children } = vdom
Removes the props
destructuring
const element = document.createElement(tag)
addProps(element, props, vdom, hostComponent)
addProps(element, vdom, hostComponent)
Don’t pass the props to
vdom.el = element
the addProps() function.
children.forEach((child) =>
mountDOM(child, element, null, hostComponent)
)
insert(element, parentEl, index)
}
Now, inside the addProps() function, you need to extract the props and events from the vdom node. For this purpose, you’ll use the extractPropsAndEvents() function, which you modified to remove the key attribute from the props object. Modify the addProps() function as shown in the following listing.
Listing 12.6
Modifying the addProps() function (mount-dom.js)
Removes the props
function addProps(el, props, vdom, hostComponent) {
argument
function addProps(el, vdom, hostComponent) {
const { on: events, ...attrs } = props
const { props: attrs, events } = extractPropsAndEvents(vdom)
vdom.listeners = addEventListeners(events, el, hostComponent)
setAttributes(el, attrs)
}
Extracts the props and
events from the vdom node
That’s it! You can use the key attribute to guide the reconciliation algorithm when an element has been moved. Before you publish the new version of your framework, let’s take a little detour to talk about some good practices for using the key attribute.
12.3
Using the key attribute
The key attribute is a powerful tool that helps the reconciliation algorithm detect when an element has been moved, but it’s also a tool that can easily be misused. In this section, we’ll look at two common mistakes in using the key attribute.
12.3.1 Mistake 1: Using the index as key
A typical example of misuse occurs when the developer uses the index of the element in the list as the value of the key attribute. Why is this approach a bad idea?
There’s no guarantee that the same elements or components will always be in the same positions in the list, so they won’t always have the same index—a problem
292
CHAPTER 12
Keyed lists
referred to as unpredictable reordering. The key attribute doesn’t work unless a node keeps the same key value consistently.
This problem affects only lists of components, not lists of elements. The reconciliation algorithm, as you’ve already seen, may need to do extra work by re-creating entire view trees, but it always gets the final result right. With components, this isn’t the case, because components have their own state, invisible to the virtual DOM. So components can’t be re-created; the live instances need to be moved around in the DOM. This situation is why the key attribute is so important for components.
Suppose that you have a list with three <MyCounter> components. You decide to use their positions in the list as the value of the key attribute:
<div>
<MyCounter key="0" />
<MyCounter key="1" />
<MyCounter key="2" />
</div>
Now suppose that the middle component (the one with the key attribute set to 1) is removed from the list:
<div>
<MyCounter key="0" />
<MyCounter key="1" />
<MyCounter key="2" />
</div>
The component with the key attribute set to 2 will be moved to the position of the component with the key attribute set to 1. Thus, its index changes from 2 to 1:
<div>
<MyCounter key="0" />
<MyCounter key="1" /> <!-- Wrong! was key="2" -->
</div>
By looking at the two virtual DOM trees, the reconciliation algorithm incorrectly assumes that the component with the key="2" was removed from the bottom of the list when in reality, the middle component was removed.
Lists of components or elements are typically generated inside a loop, and the index of the element in the list is the index of the loop. Thus, the index of the element might change as things are added, removed, or moved in the list. The developer must make sure that the key attribute is set to a value that doesn’t change, even if the index of the element in the list changes.
NOTE
The key attribute attached to a component or element must be consistent across renders. The same element or component must have the same key value throughout the whole life cycle of the node.
12.3
Using the key attribute
293
To see the problem of using indices as keys in action, I recommend that you work on exercise 12.2.
Exercise 12.2
Create a component called MyCounter that consists on a button with a counter. The button increments its counter when it’s clicked. Next to this button, create another button with the label "Remove" that emits a 'remove' event when clicked.
Create a second component (you can call it App) that has a count of counters as its state. Set its initial state to 3:
const App = defineComponent({
state() {
return { counters: 3 }
},
render() { ... }
})
Make the App component render a list of MyCounter components, one for each counter in the state. For their key, use the index of the counter in the list. When the 'remove'
event is emitted, update the state to remove one counter. You can follow this example: const App = defineComponent({
state() { ... },
render() {
const { counters } = this.state
return h(
'div',
{},
Array(counters)
.fill()
.map((_, index) => {
return h(MyCounter, {
key: index,
on: {
remove: () => {
this.updateState({ counters: counters - 1 })
},
},
})
})
)
}
})
Mount the application into the DOM. Next, change the counter in the middle of the list to have a count of 2 and the last counter to have a count of 3. Click the "Remove"
button of the counter in the middle (the second one, with a count of 2). Is the correct counter removed? (You’d expect to have two counters left, with counts 1 and 3.) Find the solution at .
294
CHAPTER 12
Keyed lists
12.3.2 Mistake 2: Using the same key for different elements
Keys must be unique across the same list. Different lists can have elements with the same key but not the same list. What happens when the same key is assigned to two elements or components in the same list? The reconciliation algorithm can’t determine unequivocally which element was moved and might wrongly assume that a node moved when it didn’t.
Suppose that you mistakenly assign the same key attribute to the two last components in the list, as follows:
<div>
<MyCounter key="abc" />
<MyCounter key="def" />
<MyCounter key="def" /> <!-- Wrong! Same key as the previous one -->
</div>
Now, if I remove one of the two last components from the list, would you be able to tell me which one I removed?
<div>
<MyCounter key="abc" />
<MyCounter key="def" />
</div>
You can’t. Neither can the reconciliation algorithm.
NOTE
The key attribute attached to a component or element must be unique across the same list.
Try exercise 12.3 to see this problem in action.
Exercise 12.3
Use the same MyCounter component from the preceding exercise. This time, modify the App render function to render all the counters with the same key (using
'abc' as the key):
render() {
const { counters } = this.state
return hFragment([
h('h1', {}, ['Repeated key problem']),
h('p', {}, [
'Set a different count in each counter, then remove the middle one.',
]),
h(
'div',
{},
12.4
The application instance
295
Array(counters)
.fill()
.map(() => {
return h(MyCounter, {
key: 'abc',
on: {
remove: () => {
this.updateState({ counters: counters - 1 })
},
},
})
})
),
])
}
Repeat the same process as in the preceding exercise: set the count of the second counter to 2 and the count of the third counter to 3; then remove the middle counter.
Does the correct counter get removed? Can you explain why or why not?
Find the solution at
12.4
The application instance
Let’s move on to modifying the createApp() function to use the new stateful component system. The application instance doesn’t need to handle the entire application’s state; each component handles its own now. Thus, there’s no need to pass the state object and reducers to the createApp() function anymore (figure 12.6). You need to pass it only the root component—the component that contains the whole application’s view split in subcomponents (figure 12.7).
State
View
Reducers
reduce( , ) =
createApp({ state, view, reducers })
Figure 12.6
The current framework’s application instance requires the global state, reducers, and the root component.
296
CHAPTER 12
Keyed lists
The root component, the one at the top of
the hierarchy, is passed to createApp().
Component
State
View
Component
Component
State
View
State
View
createApp(RootComponent)
Figure 12.7
The new version of the framework’s application instance requires only the root component.
This root component might accept props, so you want to pass the props to the createApp() function as well:
function createApp(RootComponent, props = {}) {
// --snip-- //
}
Open the app.js file, and start by removing all the code you wrote; things are going to change a lot. The idea is still the same, though. You want to return an object with two methods: mount() and unmount(). This time, the code is much simpler because each component in the view hierarchy handles its own state. In the app.js file, add the code in the following listing.
Listing 12.7
The application instance (app.js)
import { mountDOM } from './mount-dom'
Passes the root
import { destroyDOM } from './destroy-dom'
component and
import { h } from './h'
props to the
createApp() function
export function createApp(RootComponent, props = {}) {
let parentEl = null
let isMounted = false
let vdom = null
function reset() {
A function to reset the
parentEl = null
internal properties of
isMounted = false
the application
vdom = null
}
12.5
Publishing the framework
297
return {
An application can’t be mounted
mount(_parentEl) {
if it’s already mounted.
if (isMounted) {
throw new Error('The application is already mounted')
}
Saves a reference to the
parent DOM element
parentEl = _parentEl
vdom = h(RootComponent, props)
Creates the virtual DOM node
mountDOM(vdom, parentEl)
for the root component
isMounted = true
Mounts the component
},
in the parent element
unmount() {
if (!isMounted) {
An application
throw new Error('The application is not mounted')
can’t be
}
unmounted if it’s
not mounted yet.
destroyDOM(vdom)
Destroys the DOM tree
reset()
},
}
Resets the internal properties
of the application
}
This code is much simpler, right? To get the framework ready for publication, you want to export the defineComponent() function from your framework’s public API, which is as simple as exporting it from the main barrel file, src/index.js. Open that file, and add the line shown in bold in the following listing.
Listing 12.8
The framework’s updated API (index.js)
export { createApp } from './app.js'
export { defineComponent } from './component.js'
export { DOM_TYPES, h, hFragment, hString } from './h.js'
Now that the framework’s API is ready, let’s publish your new version of the framework to NPM.
12.5
Publishing the framework
To publish version 3.0 of your framework, you need to update the version number in the package.json file. Open this file (remember that it’s inside the packages/runtime folder, not the top-level one), and change the version number to 3.0.0:
{
"name": "your-fwk-name",
"version": "2.0.0",
"version": "3.0.0",
...
}
298
CHAPTER 12
Keyed lists
Now you can publish the new version of the framework to NPM. Make sure that your terminal is in the packages/runtime folder, and run the following command: $ npm publish
You—and the rest of the world—can install and use the version of your framework that includes stateful components. You’ll use this new version in exercise 12.4 to refactor the TODOs application.
Exercise 12.4—Challenge
Using the new version of your framework, refactor the TODOs application to use stateful components.
Find the solution .
Summary
In a list of components, a unique key must be provided to each component so that the reconciliation algorithm can differentiate among them.
In a list of elements, the key isn’t mandatory, but using it improves performance. Without a key attribute, the reconciliation algorithm will do more work than necessary.
The key must be unique and invariant in the list of elements or components.
Never use the index of the element of component as the key because when an element is removed, added, or moved, the index of the rest of the elements will change.
The component
lifecycle hooks
and the scheduler
This chapter covers
Understanding a lifecycle hook
Executing code when a component is mounted
Executing code when a component is unmounted
Publishing version 4 of the framework
It’s not uncommon for a component to execute some code when it’s mounted into or unmounted from the Document Object Model (DOM). The classic example is fetching data from a server right after the component is mounted. In that case, the component is already part of the DOM, so it can display a loading indicator while the data is being fetched. When the data is ready, the component updates its view by removing the loading indicator and rendering the newly fetched data.
Your existing framework runs code only when a user-originated event happens, such as clicking a button. (Technically, it could also run code as part of a setTimeout() or setInterval() callback, but let’s omit this possibility for the sake of simplicity in the explanation.) It can’t fetch data when a component first shows up, which is a limitation because developers can’t make code run before the user interacts with the page.
299
300
CHAPTER 13
The component lifecycle hooks and the scheduler
A lifecycle hook is a user-defined function that is executed when a component goes through a lifecycle event. Lifecycle events include the creation, mounting, updating, and unmounting of a component. In this chapter, you’ll explore how lifecycle hooks fix the aforementioned limitation and how to implement them in your framework.
Lifecycle hooks are often asynchronous, such as when the user uses them to fetch data from a server after a component is mounted. Your mounting process has been synchronous so far, but as soon as you run asynchronous code as part of it, an interesting problem arises. If you await the component’s hook that executes after the component is mounted, you block the first render of the application: while a component is waiting for the hook to finish, the remaining components have to wait in line to be rendered. That situation isn’t what you want; it yields a bad user experience and the sensation that the application is slow (when in reality it’s waiting to finish mounting a component before it moves to the next), as depicted in figure 13.1.
The component is waiting
The rest of the app is waiting
Mounted
for the data before finishing
for the component to finish
mounting.
Component
mounting.
!
Waiting to be mounted
API request
Component
Component
Mounting...
!
Waiting for response
Component
Component
Figure 13.1
While a component is fetching data from a server, the rest of the application needs to wait to be mounted.
To avoid blocking the first render of the application, you shouldn’t use await for the mounted hook to finish. In section 13.3, you’ll learn about the scheduler, a mechanism that allows the execution of code to be delayed until the current render cycle is finished. With the scheduler, you can schedule asynchronous hooks to run after the application is fully mounted, thus not blocking the first render. In this chapter, you’ll publish version 4 of your framework so that you can put lifecycle hooks to use.
NOTE
You can find all the listings in this chapter in the listings/ch13/ directory of the book’s repository The code in this chapter can be checked out from the ch14 label ($ git switch
--detach ch14.
13.1
The component lifecycle
301
Code catch-up
In chapter 12, you updated the areNodesEqual() function to include the case of two component nodes. This function checks whether the component types are the same and their key props—if they have one—are equal. You made a quick modification to the extractPropsAndEvents() function to remove the key property from the props object so that it doesn’t reach the component and isn’t mistaken for a prop.
You also included the key prop comparison for the case of two element nodes inside the areNodesEqual() function. This change required you to modify the createElementNode() and addProps() functions to use the extractPropsAndEvents() function so that the key prop is removed from the props object before it reaches the element creation and is mistaken for an HTML attribute.
Last, you rewrote the createApp() function and exported it from the runtime package by exporting it from the index.js file.
13.1
The component lifecycle
The lifecycle of a component is the sequence of events that it goes through from the moment it’s created until it gets destroyed. Table 13.1 describes the lifecycle of a component in your framework.
Table 13.1
The lifecycle of a component
Lifecycle event
Code
Explanation
Creation
new Component(…)
When the mountDOM() function is passed a compo-
nent, it instantiates it.
Mounting
component.mount(…)
Then the mountDOM() function calls the component’s
mount() method. This event is when the component
is mounted into the DOM and its view first appears
onscreen.
Updating
#patch() { … }
The component remains onscreen until it’s removed
from the DOM, but it can render itself multiple times
when the internal state changes or the external props
change. This task is handled by the component’s pri-
vate #patch() method.
Unmounting
component.unmount()
Finally, when the component is removed from the DOM,
the destroyDOM() function calls the component’s
unmount() method. This event is when the compo-
nent is unmounted from the DOM and its view disap-
pears from the screen.
Figure 13.2 shows the lifecycle of a component, which includes the lifecycle events discussed in table 13.1. Starting from the left, the component starts in the none state; it’s not instantiated yet. Then, after the component is created, it goes to the created state,
302
CHAPTER 13
The component lifecycle hooks and the scheduler
where you have an instance of your component class, but it’s not mounted into the DOM yet. Next is the mount lifecycle event, after which the component is in the mounted state. From being mounted, it can take two paths: it can be updated or unmounted. If it’s updated, it goes back to the mounted state, and the cycle repeats. If it’s unmounted, it goes to the destroyed state; the instance of the component still exists but is detached from the DOM.
None
create
Created
mount
Mounted
unmount
Destroyed
Update
Update
Legend
props
state
State
Lifecycle event
update
Figure 13.2
The lifecycle of a component
Now that you’re familiar with how components go through lifecycle events, let’s dive into finding out why lifecycle hooks are useful. This feature lets developers who work with your framework run their own code at these specific events. This way, they can customize how their components behave at each stage, tailoring the behavior to suit their application’s needs.
Different frameworks have different lifecycle hooks, but creation, mounting, and unmounting are the three most common, used in almost all (if not all) frameworks.
Table 13.2 compares the lifecycle hooks of a few popular frameworks.
Table 13.2
Lifecycle hooks in popular frameworks
Framework
Hooks
Comments
Svelte (v4)
onMount()—Executed when the component is
Svelte defines four main
mounted into the DOM
lifecycle hooks
beforeUpdate()—Executed before the compo-
(
nent is updated
). Svelte also defines
afterUpdate()—Executed after the component
a special lifecycle hook
called
is updated
tick() that returns
a promise that resolves
onDestroy()—Executed when the component is
when the “pending
unmounted from the DOM
changes have been
applied.”
13.1
The component lifecycle
303
Table 13.2
Lifecycle hooks in popular frameworks (continued)
Framework
Hooks
Comments
Vue (v3)
onBeforeMount()—Executed before the compo-
Vue defines as many
nent is mounted into the DOM
as 12 lifecycle hooks
onMounted()—Executed when the component is
but
mounted into the DOM
these are the most com-
onBeforeUpdate()—Executed before the com-
mon ones.
ponent’s DOM is updated
onUpdated()—Executed after the component’s
DOM is updated
onBeforeUnmount()—Executed before the com-
ponent is unmounted from the DOM
onUnmounted()—Executed when the component
is unmounted from the DOM
Angular (v16)
ngOnInit()—Executed when the component is
Angular defines eight
initialized
lifecycle hooks
ngOnChanges()—Executed when the compo-
but
nent’s input properties change
these are the most com-
ngAfterViewInit()—Executed after the com-
mon ones.
ponent’s view is initialized
ngOnDestroy()—Executed before the compo-
nent is destroyed
React (v18)
To execute code when the component is mounted, call
React is a bit different.
the useEffect() hook, passing an empty array as
It doesn’t define lifecycle
the second argument:
hooks, but it does
define side effects
useEffect(() => {
) that
// code to execute when the
are executed when the
// component is mounted
component is mounted,
}, [])
updated, or unmounted.
You can specify the dependencies of the hook by pass-
ing them in the array. In that case, the effect would run
when the component is mounted and every time one
of the dependencies changes. To execute code when
the component is unmounted, return a function from
the useEffect() hook that has an empty array as
the second argument:
useEffect(() => {
// code to execute when the
// component is mounted
return () => {
// code to execute when the
// component is unmounted
}
}, [])
304
CHAPTER 13
The component lifecycle hooks and the scheduler
Table 13.2
Lifecycle hooks in popular frameworks (continued)
Framework
Hooks
Comments
To execute code when the component is updated
(including after being mounted), don’t pass the depen-
dencies array as the second argument to the
useEffect() hook:
useEffect(() => {
// code to execute every time the
// component is updated
})
In this case, the effect would run when the component
is mounted and every time the component is
updated—that is, when the component’s state
changes or its props change.
You’ll implement two lifecycle hooks in your framework:
onMounted()—Allows the developer to run code just after the component is mounted into the DOM
onUnmounted()—Allows the developer to run code just after the component is unmounted from the DOM
These two hooks are depicted in figure 13.3, which is an updated version of figure 13.2.
Legend
State
None
create
Created
mount
Lifecycle event
Lifecycle hook
onMounted()
Called right after the
component mounts
Mounted
unmount
onUnmounted()
Destroyed
Update
Update
props
state
Called right after the component unmounts
update
Figure 13.3
The onMounted() and onUnmounted() lifecycle hooks
The process is so straightforward that you may want to take inspiration from other frameworks and implement more lifecycle hooks on your own. Developers most often
13.2
Implementing the mounted and unmounted lifecycle hooks
305
use the onMounted() and onUnmounted() lifecycle hooks, which makes them a great starting point.
No more talking. Let’s get to work!
13.2
Implementing the mounted and unmounted
lifecycle hooks
The first step in implementing the onMounted() and onUnmounted() lifecycle hooks is defining them in the Component class. You want to let developers define these lifecycle hooks when they create a new component by using the defineComponent() function: defineComponent({
onMounted() { ... },
onUnmounted() { ... },
render() { ... }
})
To do so, you need to accept those two new functions in the object that’s passed as an argument to the defineComponent() function. You want to give developers an empty function as the default value of the new functions so that they can omit those functions. Open the component.js file, and add the code shown in bold in the following listing.
Listing 13.1
Defining a component with lifecycle hooks (component.js)
const emptyFn = () => {}
export function defineComponent({
render,
state,
onMounted = emptyFn,
onUnmounted = emptyFn,
...methods
}) {
class Component {
// --snip-- //
}
}
You need to take two important things into account regarding the passed-in onMounted() and onUnmounted() functions:
Hooks asynchronicity
Hooks execution context
Sections 13.2.1 and 13.2.2 discuss these two concerns in order.
306
CHAPTER 13
The component lifecycle hooks and the scheduler
13.2.1 Hooks asynchronicity
The functions passed by the developer are quite possibly asynchronous and return a Promise. The typical use of the mounted hook is to fetch data from an API, so it’s very likely that the function will await for a Promise to be resolved:
async onMounted() {
const data = await fetch('https://api.example.com/data')
this.updateState({ data })
}
The hooks may be synchronous, but wrapping their result inside a Promise doesn’t hurt. This approach also has the benefit of allowing you to treat all hooks as though they were asynchronous:
Promise.resolve(onMounted())
13.2.2 Hooks execution context
The second concern is the execution context. As with state() and render(), the developer expects the context of the onMounted() and onUnmounted() hooks to be the component instance. Inside the hook functions, the developer should be able to access the component’s state and methods:
async onMounted() {
const data = await fetch('https://api.example.com/data')
this.updateState({ data })
}
You already know how to solve this problem. Explicitly bind the functions to the component instance:
Promise.resolve(onMounted.call(this))
You want to create two new methods in the Component class, called the same way as the lifecycle hooks, that wrap the hooks inside a promise and bind them to the component instance.
13.2.3 Dealing with asynchronicity and execution context
In the component.js file, add the code shown in bold in the following listing, just below the constructor.
Listing 13.2
Wrapping the hooks inside a Promise (component.js)
export function defineComponent({
render,
state,
onMounted = emptyFn,
onUnmounted = emptyFn,
13.3
The scheduler
307
...methods
}) {
class Component {
// --snip-- //
constructor(props = {}, eventHandlers = {}, parentComponent = null) {
// --snip-- //
}
onMounted() {
return Promise.resolve(onMounted.call(this))
}
onUnmounted() {
return Promise.resolve(onUnmounted.call(this))
}
// --snip-- //
}
// --snip-- //
}
Now that you have the onMounted() and onUnmounted() lifecycle hooks defined, you need to call them at the right time. But what is the right time?
13.3
The scheduler
Mounting the application is a synchronous and fast process (at least, it has been up to now), which is exactly what you want. The application’s first render should be as fast as possible. But lifecycle hooks may not be as fast; they might take some time to finish while they fetch data from a server, for example. What would happen if you await for the onMounted() hook to finish before moving to the next component inside the mountDOM() function?
async function mountDOM(...) {
switch (vdom.type) {
// --snip --//
case DOM_TYPES.COMPONENT: {
createComponentNode(vdom, parentEl, index, hostComponent)
await vdom.component.onMounted()
break
Waits for the
}
onMounted() hook to
finish before moving to
// --snip --//
the next component
}
}
This code would block the first render of the application because the mountDOM() function wouldn’t move to the next component until the onMounted() hook is finished—which might take some time. Let’s look at an example.
308
CHAPTER 13
The component lifecycle hooks and the scheduler
The application whose virtual DOM tree is depicted in figure 13.4 has three components: Header, Main, and Footer. The Main component has an onMounted() hook that fetches data from a server. When the application mounts, the mountDOM() function mounts first the Header component and then the Main component; next, it calls the onMounted() hook of the Main component, which blocks the rendering of the Footer component until the hook is finished. Section 13.3.1 presents a naive solution to this problem.
App
The Main component shows a
loading while it waits for the
server to return data.
<App>
Header
Main
Footer
<Header />
<Main> Loading... </Main>
Mounted
!
Waiting
</App>
await onMounted()
The Footer component isn’t rendered until
the Main component completes executing
the onMounted() function.
Figure 13.4
The Main component blocks the rendering of the Footer component.
13.3.1 A simple solution that doesn’t quite work
A simple solution is to not await for the onMounted() hook to finish: function mountDOM(...) {
switch (vdom.type) {
// --snip --//
case DOM_TYPES.COMPONENT: {
createComponentNode(vdom, parentEl, index, hostComponent)
vdom.component.onMounted()
break
}
// --snip --//
}
}
I’ll explain why this solution might be problematic. The code inside onMounted() would execute until the first await is encountered. At this point, a callback would be added to the microtask queue ). I’ll explain shortly. Then the mounting process would continue. The promise’s callback would be executed later,
13.3
The scheduler
309
when JavaScript’s execution call stack is empty—that is, after the mountDOM() function is finished. If the onMounted() hook of the Main component from the preceding example is defined as follows,
async onMounted() {
this.setState({ loading: true })
const data = await fetch('https://api.example.com/data')
this.setState({ data, loading: false })
}
the first line of the hook, which sets the loading state to true, would be executed immediately. Then the fetch() function would be called, returning a Promise whose callback—the rest of the onMounted() function’s body—would be added to the microtask queue. After that, the mounting process would continue.
Because using async/await
then() and catch(), the preceding code can be rewritten as follows: onMounted() {
this.setState({ loading: true })
fetch('https://api.example.com/data').then((data) => {
this.setState({ data, loading: false })
})
}
This example may help you understand why the first line is called synchronously, whereas the rest of the code—the callback passed to then()—is moved to the microtask queue. The callback is made to the rest of the onMounted() function’s body, which includes the final line that sets the loading state to false and the data received from the server. This approach might work well in most cases, but it leaves unhandled promises in your code, exposing your mounting process to two problems:
You have no control of the sequencing of the code in the mounting process. An await is a blocking operation that prevents some part of the code from executing until a promise is resolved. It ensures that the part of the code that requires the promise to be resolved doesn’t run before the promise is resolved. If you don’t handle the components’ onMounted() promises, the user might interact with the component while the onMounted() callback is waiting in the microtask queue. This interaction might cause a re-render of the partly mounted component. Then, when the onMounted() callback is finally executed, it likely renders the component again, which might cause a race condition. Ideally, a component isn’t rendered while it’s being mounted.
If an error occurs while the onMounted() hook is executing, the promise is rejected, but you have no way to handle the error. If the developer isn’t notified of the error, they might not even know that something went wrong. At least print the error to the console so that the developer can analyze it and fix the problem.
310
CHAPTER 13
The component lifecycle hooks and the scheduler
NOTE
The typescript-eslint program has a rule called “no-floating-promises”
that warns you when you have unhandled promises in your code. Similarly, a plugin for ESLint called “eslint-plugin-no-floating-promise” does the same.
Unhandled (or floating) promises expose your code to sequencing and error-handling problems that are complex to track down. As a general rule, avoid unhandled promises in your code.
Instead, you want to mount the entire application synchronously and then execute all the onMounted() hooks in the order in which they were encountered, ensuring that they complete and their errors are handled. Only then should you allow the framework to execute any other code. To accomplish this task, you need to learn how asynchronous code is executed in the browser, so bear with me. If you’re familiar with event loops, tasks, and microtask queues, you can skip the next section. In particular, though, you should know about the order in which the event loop processes tasks and microtasks.
13.3.2 Tasks, microtasks, and the event loop
When a website loads in a browser, the synchronous JavaScript code executes immediately. The application reaches a point at which there’s no JavaScript code left to execute, which is necessary for the website to respond to user interactions—the perks of single-threaded JavaScript. At this point, we say that the execution stack is empty. The JavaScript run time doesn’t have any code pending to execute.
DEFINITION
The execution stack (or call stack) is a stack data structure that stores the functions currently being executed by the JavaScript run time.
When a function is called, it’s added to the top of the stack, and when it returns, it’s popped from the top of the stack.
The rest of the JavaScript code that runs is triggered by user interactions, such as clicking a button or typing in an input field, or scheduled tasks, such as those scheduled by setTimeout() or setInterval(). If nothing is scheduled to run, or if the user doesn’t interact with the application, the JavaScript run time’s execution stack is empty.
When the execution stack is empty the event loop reads from two queues (the task and microtask queues) and executes the callbacks that are in them. Examples of tasks are the callbacks of the event listeners. When an event is triggered, the callback (the function registered as its event handler) is added to the task queue. Microtasks are mainly the callbacks of promises.
DEFINITION
An event loop (is a loop that runs continuously in the browser’s JavaScript run time and decides what code to execute next. The event loop is responsible for reading from the task and microtask queues and pushing their callbacks to the execution stack.

13.3
The scheduler
311
Why does the browser need two queues instead of one? What’s relevant for now is that the event loop handles the tasks and microtasks differently. Understanding these differences is the key to understanding how to schedule asynchronous hooks to run after the application is fully mounted. Let’s open the hood of the event loop to see how it reads from the task and microtask queues and pushes their callbacks to the execution stack.
13.3.3 The event loop cycle
When you know the steps of an event loop cycle (when it reads from the task and microtask queues) and understand how it pushes the callbacks to the execution stack, you’ll be able to decide the best way to schedule the execution of the onMounted() and onUnmounted() hooks.
NOTE
You can read the execution steps in more detail in the HTML specification ). You can also find a great article explaining the event loop at
The following sections present the simplified steps of an event loop cycle.
STEP 1
Execute all the microtasks in the microtask queue in order, even those that were added while executing other microtasks in this same cycle (figure 13.5). While the microtasks are executing, JavaScript’s thread is blocked—that is, the browser can’t respond to user interactions. (Note that this same situation happens when you execute synchronous JavaScript code.)
While the microtasks are being
All microtasks are executed in order.
processed, the DOM is frozen.
Their callbacks are pushed into the stack.
Microtasks
c()
μT1
μT2
μT3
b()
!
Ta
T sks
a()
DOM
T1
T2
Execution stack
Event loop
Figure 13.5
Execute all the microtasks in the microtask queue.
STEP 2
Execute the oldest task in the task queue. Only those tasks that were added to the task queue before the start of this cycle are considered (figure 13.6). While the task is executing, JavaScript’s thread is still blocked.


312
CHAPTER 13
The component lifecycle hooks and the scheduler
While the task is being
The oldest task is executed. Its
processed, the DOM is frozen.
callback is pushed into the stack.
Microtasks
c()
b()
!
Tasks
a()
DOM
T1
T2
Execution stack
Event loop
Figure 13.6
Execute the oldest task in the task queue.
STEP 3
Enable the browser to render the DOM if changes are pending (figure 13.7).
The browser is given the chance
The pending task will be executed next.
to update the DOM.
Microtasks
Ta
T sks
DOM
T1
Execution stack
Event loop
Figure 13.7
Enable the browser to render the DOM.
STEP 4
Go back to step 1 until there are no more tasks in the task queue.
With this knowledge, think about how you can design a scheduler that schedules the onMounted() hooks to run after the application is fully mounted.
13.3.4 The fundamentals of the scheduler
The mountDOM() function executes synchronously when the application is loaded into the browser. When this code finishes executing, if no more synchronous JavaScript code is left, the execution stack becomes empty. At this point, you want the onMounted() hooks to execute in the order in which they’re scheduled, and for this purpose, the microtask queue is perfect (figure 13.8).
The execution order is guaranteed for microtasks, and if those microtasks enqueue more microtasks, all of them will be executed in the same cycle. Think about





13.3
The scheduler
313
When mountDOM() finishes, it’s
The onMounted() hooks are waiting in
popped from the execution stack.
the microtask queue until the next cycle.
Microtasks
onMounted()
onMounted()
)
mountDOM()
mountDOM()
Tasks
mountDOM()
DOM
Execution stack
Event loop
Figure 13.8
The onMounted() hooks are executed in the order in which they’re scheduled.
how to implement a scheduler based on this idea. You want to collect all the onMounted() lifecycle hooks in a queue of pending jobs:
const jobs = []
function enqueueJob(job) {
jobs.push(job)
}
Another function processes the jobs. The solution is as simple as dequeuing the jobs and executing them in order:
function processJobs() {
while (jobs.length > 0) {
const job = jobs.shift()
job()
}
}
To schedule the processJobs() function into the microtask queue so that it runs when the execution stack is empty, you can use the queueMicrotask() function (which is present in the global Window object. Be careful that you don’t schedule the processJobs() function multiple times. To prevent this situation, you can have an isScheduled Boolean variable keep track of whether the function is already scheduled:
let isScheduled = false
function scheduleUpdate() {
if (isScheduled) return
isScheduled = true
queueMicrotask(processJobs)
}
You want to call the scheduleUpdate() function every time a job is enqueued:
314
CHAPTER 13
The component lifecycle hooks and the scheduler
function enqueueJob(job) {
jobs.push(job)
scheduleUpdate()
}
When the first job is enqueued, the processJobs() function is scheduled to run. All the jobs that are added before processJobs() is executed don’t schedule the function again because the isScheduled flag is set to true.
With this task, you have all the fundamentals of the scheduler. Now it’s time to implement that feature.
13.3.5 Implementing a scheduler
You want to implement the scheduling logic in a separate file. Create a new file called scheduler.js inside the src/ folder. In that file, write the code in the following listing.
Listing 13.3
The scheduler (scheduler.js)
let isScheduled = false
Private flag to indicate
const jobs = []
whether the processJobs()
function is scheduled
export function enqueueJob(job) {
jobs.push(job)
Private array of jobs
scheduleUpdate()
to execute
}
Enqueues a job
function scheduleUpdate() {
if (isScheduled) return
Schedules an update
isScheduled = true
queueMicrotask(processJobs)
Queues a microtask to run
}
the processJobs() function
function processJobs() {
while (jobs.length > 0) {
Pops and executes job
const job = jobs.shift()
functions until the queue
job()
is empty
}
isScheduled = false
Sets the flag to false
}
The jobs might be asynchronous, as is the case for the onMounted() and onUnmounted() hooks. Let’s do one more thing to prevent floating promises () in the application: wrap the result of calling each job() function inside a Promise and add callbacks to handle the result or reject the promise. When the promise resolves, you don’t want to do anything else, so you can keep that callback empty. When the promise is rejected, you want to log the error to the console so that the developer knows something went wrong. In the scheduler.js file, write the code shown in bold in the following listing.
13.3
The scheduler
315
Listing 13.4
Handling promises in the scheduler (scheduler.js)
function processJobs() {
Saves the result
while (jobs.length > 0) {
in a variable
const job = jobs.shift()
const result = job()
Wraps the result
inside a promise
Promise.resolve(result).then(
() => {
If the promise resolves, you
// Job completed successfully
have nothing else to do.
},
(error) => {
console.error(`[scheduler]: ${error}`)
If the promise is
}
rejected, logs the
)
error to the console
}
isScheduled = false
}
Now that you have a scheduler, you can use it to schedule the execution of the lifecycle hooks.
Exercise 13.1
What is the order of the console.log() resulting from running the following code?
console.log('Start')
setTimeout(() => console.log('Timeout'))
queueMicrotask(() => console.log('Microtask 1'))
enqueueJob(() => console.log('Job'))
queueMicrotask(() => console.log('Microtask 2'))
console.log('End')
Try to reason your way to the answer before running the code. Draw a diagram with the task and microtask queues, if that helps.
Find the solution at
13.3.6 Scheduling the lifecycle hooks execution
The mountDOM() function is the right place to schedule the execution of the onMounted() hooks. Right after a component virtual DOM node is created, you want to enqueue the onMounted() hook in the scheduler. Open the mount-dom.js file, and add the code shown in bold in the following listing.
Listing 13.5
Scheduling the onMounted() method of a component (mount-dom.js) import { setAttributes } from './attributes'
import { addEventListeners } from './events'
import { DOM_TYPES } from './h'
316
CHAPTER 13
The component lifecycle hooks and the scheduler
import { enqueueJob } from './scheduler'
import { extractPropsAndEvents } from './utils/props'
export function mountDOM(vdom, parentEl, index, hostComponent = null) {
switch (vdom.type) {
// --snip-- //
Imports the enqueueJob()
function from the scheduler
case DOM_TYPES.COMPONENT: {
createComponentNode(vdom, parentEl, index, hostComponent)
enqueueJob(() => vdom.component.onMounted())
Enqueues the
break
component’s
}
onMounted() hook
in the scheduler
default: {
throw new Error(`Can't mount DOM of type: ${vdom.type}`)
}
}
}
// --snip-- //
Let’s do the same thing with the onUnmounted() hook. This time, the destroyDOM() function is the right place to schedule the execution of the onUnmounted() hooks, right after the component is removed from the DOM. Open the destroy-dom.js file, and add the code shown in bold in the following listing.
Listing 13.6
Scheduling the onUnmounted() method of a component (destroy-dom.js) import { removeEventListeners } from './events'
import { DOM_TYPES } from './h'
import { enqueueJob } from './scheduler'
Imports the enqueueJob()
import { assert } from './utils/assert'
function from the
scheduler
export function destroyDOM(vdom) {
const { type } = vdom
switch (type) {
// --snip-- //
Enqueues the
component’s
case DOM_TYPES.COMPONENT: {
onUnmounted()
hook in the
vdom.component.unmount()
scheduler
enqueueJob(() => vdom.component.onUnmounted())
break
}
default: {
throw new Error(`Can't destroy DOM of type: ${type}`)
}
}
delete vdom.el
}
13.3
The scheduler
317
That’s it! The onMounted() and onUnmounted() hooks are scheduled to run after the application is fully mounted and unmounted (respectively), and they’re executed in the order in which they’re scheduled. Nevertheless, it’s a good idea to use your framework to debug the execution of an application so you can see how the scheduler works and how the hooks are executed at the right time (exercise 13.2).
Exercise 13.2
Create a component that receives a name as a prop and renders that name. The component should also have an onMounted() hook that logs to the console where the component was mounted, including the name prop. Your component should look something like this:
const NameComponent = defineComponent({
onMounted() {
console.log(`Component mounted with name: ${this.props.name}`)
},
render() {
return h('p', {}, [this.props.name])
}
})
Now create an App top-level component that includes five NameComponent components, each with a different name. Mount the application into the DOM like so: const App = defineComponent({
render() {
return hFragment([
h(NameComponent, { name: 'Alice' }),
h(NameComponent, { name: 'Bob' }),
h(NameComponent, { name: 'Charlie' }),
h(NameComponent, { name: 'Diana' }),
h(NameComponent, { name: 'Eve' })
])
}
})
createApp(App).mount(document.body)
Instrument the application in the browser, adding a log breakpoint in the mountDOM() call inside the application’s instance mount() method. Set another log breakpoint in the line immediately after the mountDOM() call so you’ll know when the application has finished mounting.
What is the order in which the operations are logged to the console? Debug the application, setting breakpoints when the onMounted() hooks are scheduled and when they’re executed. Did the scheduler do what you expected—that is, enqueue the hooks and execute them in order after mountDOM() finished executing?
Find the solution at
318
CHAPTER 13
The component lifecycle hooks and the scheduler
13.4
Publishing version 4 of the framework
Now let’s publish the new version of the framework. In the package.json file, update the version number to 4.0.0:
"version": "3.0.0",
"version": "4.0.0",
Remember to move your terminal to the root of the project and run npm install so that the package version number in the package-lock.json file is updated: $ npm install
Finally, place your terminal inside the packages/runtime folder, and run $ npm publish
Congratulations! The new version of your framework is published.
Exercise 13.3: Challenge
Refactor the TODOs application so that it saves the to-dos in the browser’s local storage. Load the to-dos from local storage when the application is mounted, and save them to local storage when they are added, removed, or updated.
Find the solution .
Summary
The lifecycle of a component is the sequence of events that it goes through from the moment it’s created until it’s removed from the DOM.
A lifecycle hook is called at a specific moment in the component’s lifecycle.
The mounted lifecycle hook is scheduled to run after the component is mounted into the DOM.
The unmounted lifecycle hook is scheduled to run after the component is removed from the DOM.
The scheduler, which is in charge or running jobs in the order in which they’re scheduled, is based on the microtask queue.
The event loop runs continuously in the browser’s JavaScript run time and decides what code to execute next. In every cycle, it executes all pending microtasks first, followed by the oldest task in the task queue; finally, it gives the browser the opportunity to render the DOM.
Testing asynchronous
components
This chapter covers
Testing synchronous components
Testing components with asynchronous behavior
Implementing the nextTick() function
Testing components whose behavior is purely synchronous is straightforward. But components can have asynchronous hooks or event handlers, so testing them becomes a bit more complicated (and interesting) in these cases.
The main question the tester must face is how you know when all the asynchronous jobs have finished executing and the component has re-rendered. In this chapter, you’ll implement a nextTick() function. This function returns a Promise that resolves when all the pending jobs in the scheduler have finished executing. This way, by awaiting this Promise, you can be sure that the component has re-rendered, and you can check what’s in the Document Object Model (DOM).
NOTE
You can find all the listings in this chapter in the listings/ch14/
directory of the book’s repository (. The code in this chapter can be checked out from the ch14 label (
$ git switch --detach ch14.
319
320
CHAPTER 14
Testing asynchronous components
Code catch-up
In chapter 13, you defined two new functions that can be passed as arguments to the defineComponent() function: onMounted() and onUnmounted(). You implemented two new methods in the Component class, also called onMounted() and onUnmounted(), that wrap those two functions inside a Promise and bind them to the component instance.
Then you implemented a simple scheduler to run the hooks asynchronously. The scheduler’s API consists of one function: enqueueJob(), which queues a job to be executed asynchronously.
The scheduler uses two more functions internally:
scheduleUpdate()—Queues the processJobs() function to be executed as a microtask
processJobs()—Runs all the jobs in the scheduler
Last, you modified the mountDOM() and destroyDOM() functions to enqueue the onMounted() and onUnmounted() hooks, respectively, as jobs in the scheduler.
14.1
Testing components with asynchronous behavior:
nextTick()
If you build an application with your own framework, you want to unit-test it—as you normally do (because you do test your applications, right?). Before you introduced asynchronous hooks, testing components was straightforward. Let’s look at an example.
NOTE
I’m using the Vitest testing library for the examples—the same one I used in the repository of the project. Its API is very similar to Jest’s ), so if you’re familiar with Jest, you’ll feel right at home.
Suppose that you have a component called Counter, consisting of a button that increments a counter when clicked. If you render the component into the DOM, you get something like this:
<span data-qa="counter">0</span>
<button data-qa="increment">Increment</button> After the button is clicked, the counter is incremented and the DOM is updated:
<span data-qa="counter"> 1</span>
<button data-qa="increment">Increment</button> You can test this simple component as follows (and note that I’m using data-qa attributes to select the elements in the DOM):
14.1
Testing components with asynchronous behavior: nextTick()
321
import { test, expect, beforeEach, afterEach } from 'vitest'
import { createApp } from 'fe-fwk'
import { Counter } from './counter'
let app = null
Creates an application
and mounts it into the
DOM before each test
beforeEach(() => {
app = createApp(Counter)
app.mount(document.body)
})
Unmounts the
application after
each test
afterEach(() => {
app.unmount()
})
test('the counter starts at 0', () => {
Selects the
counter element
const counter =
document.querySelector('[data-qa="counter"]')
Checks that the
expect(counter.textContent).toBe('0')
counter starts at 0
})
test('the counter increments when the button is clicked', () => {
const button = document.querySelector('[data-qa="increment"]') const counter = document.querySelector('[data-qa="counter"]') Clicks the
button.click()
increment button
expect(counter.textContent).toBe('1')
Checks that the
})
counter is 1
But what if the component has an onMounted() hook that fetches data from a server asynchronously? Then you can’t assume that right after mounting the application, the components have already updated their state or re-rendered their view.
In this case, your framework needs to give the developer a function—typically called nextTick()—to return a Promise that resolves when all the pending jobs in the scheduler have finished executing. This function would wait for all components to run their onMounted() hooks, as follows:
test('the component loads data from a server', async () => {
const app = createApp(MyComponent)
app.mount(document.body)
// At this point, the component has gone through its first render, but
// the onMounted() hooks haven't finished executing.
await nextTick()
// At this point, MyComponent's onMounted() hook has finished executing.
// You can safely check what is rendered in the DOM.
})
322
CHAPTER 14
Testing asynchronous components
Let’s look at a realistic example with a loading state so that this concept is clearer. For the example, we’ll assume that the nextTick() function already exists in your framework, but you won’t implement it until section 14.1.3.
14.1.1 Testing a component with an asynchronous onMounted() hook
Suppose that you have a component called TodosList, which loads a list of to-dos from a server when it’s mounted. While the data is being loaded, the component renders a loading indicator. You might use the following code to implement such a component:
export const TodosList = defineComponent({
state() {
return {
isLoading: true,
todos: [],
}
},
async onMounted() {
const todos = await fetch('https://api.example.com/todos')
this.updateState({ isLoading: false, todos })
},