← Back to Let's build a virtual DOM library!

Writing custom components

So far our VDOM library can render HTML elements. We could render a full web application like this, but that's not much different to writing HTML by hand. For our virtual DOM library to be really powerful, we need to be able to write code that has state and reacts to user input. This is where components come in. Components are sort of like custom elements. They let us break our code into reusable chunks, and more importantly they let us attach state and behaviour to our elements.

In our library we are going to start by implementing class components. The first component we'll aim to support is a Counter. This component will render a number and a button. When you click the button, the number will increase. The code for the Counter component will look like this:

class Counter extends Component {
state = { count: 0 };
constructor(props) {
super(props);
}
increment() {
this.setState({ count: this.state.count + 1 });
}
render() {
// The button text can be customised with the `buttonText` prop.
const buttonText = this.props.buttonText || "+1";
return createVNode("div", null, [
`Counter value: ${this.state.count} `,
createVNode("button", { onclick: () => this.increment() }, [
buttonText,
]),
]);
}
}

Our goal is for this component to render some HTML that looks like this:

<div>
Counter value: 0
<button>+1</button>
</div>

Notice how our Counter class is extending a base Component class. This class will come from our VDOM library, and it will handle a lot of the state management and re-rendering that you would expect from a component. For now though, it can be very simple:

class Component {
constructor(props) {
// Initialize props and state for each new instance
this.props = props || {};
this.state = {};
}
// We will implement this later
setState(newState) {}
// This will be implemented by the extending component
render() {}
}

Let's see what happens when we try to mount some Counter components to the DOM. We'll use the same app() function pattern that we used in the previous section.

function app() {
return createVNode("div", { className: "container" }, [
createVNode(Counter),
createVNode(Counter, { buttonText: "Add One" }),
]);
}
mount(app(), document.getElementById("app"));
// Error!
// Uncaught DOMException: Failed to execute 'createElement' on 'Document':
// The tag name provided ('class Counter extends Component {...}') is not a valid name.

An error! We did expect this, since our mount() function can't handle components yet. It can only handle vnodes whose type property is a valid HTML tag name. To mount a component, we'll need to call their render() function to get their VDOM tree, and then mount that instead.

Our components are classes, and classes in JavaScript are actually just functions, so we can add an if (typeof vnode.type === "function") branch to our mount() function to handle components.

if (typeof vnode.type === "function") {
// If the vnode type is a function, we can assume it is a component.
domNode = getComponentVNode(vnode, parentDom);
} else {
// For everything else, we assume vnode.type is a tag name and create a HTMLElement.
domNode = document.createElement(vnode.type);
}

Now we just need to write the getComponentVNode() function:

function getComponentVNode(vnode, parentDom) {
// The vnode's type property is the component class
const Component = vnode.type;
// Create an instance of the component with the props passed in, and call
// the component's render() function to get its VDOM tree.
const instance = new Component(vnode.props);
const newVNode = instance.render();
// Now that we have a "regular" vdom tree, we can pass it back to mount()
return mount(newVNode, parentDom);
}

With this small change, our VDOM library can now render custom components. This is a good step in the right direction, but our buttons don't actually do anything yet.

Continue onto the next section to learn how to handle state and events so that our components can react to user input.

Next: Handling state and events →