Understanding JSX by implementing our own
JSX has transformed how we generate the DOM nodes in our web applications. People seem to either love or hate JSX, and I'm certainly in the love it group.
A lot of the early hate focused on the "mixing view and logic" argument. I find it rather unconvincing. The entire front end is pretty much a view, and the established alternatives are mostly focused on custom template languages anyway, which typically replicate the same coding constructs as a custom DSL, or XML attributes (containing a custom DSL) or similar.
I like just using JavaScript (or TypeScript) in the "templates" instead. All the power, and not another framework specific syntax to learn (well, apart from JSX). And you can still separate your "views" from your logic if you really want to, via pure template components.
Anyway, JSX is really just syntactic sugar transformed into some simple function calls. The nesting ability makes it powerful, and maps neatly onto the tree shaped DOM.
As an exercise to understand JSX better, and demystify its magic, implementing a very simple variant of JSX can be helpful.
Let's have a go.
The JSX transformation
Let's look at some simple JSX.
<div style="padding: 1em">
<h1>Title</h1>
<p>This is a text. And I can include a {variable}.</p>
<button onClick={() => console.log('Hello!')}>Click me!</button>
</div>
This is basically just plain HTML with some escapes to JavaScript triggered by curly brackets.
A JSX transformer like babel or tsc will convert this to JavaScript that looks something like this.
createElement('div', { style: 'padding: 1em'},
createElement('h1', null, 'Title'),
createElement('p', null, 'This is a text. And I can include a ', variable, '.'),
createElement('button', { onClick: () => console.log('Hello!') }, 'Click me!')
)
Not too mystical. Note that the function name createElement
is arbitrarily chosen, and is actually something a JSX transformer needs to know before transforming. For example, for old school React, it referred to the React.createElement
function.
Nowadays, JSX is its own first class citizen, so newer React projects use JSX.createElement
instead. Other libraries like preact and vue have their own implementations.
Implementing our own
The signature of the function we need to implement is createElement(tag, attr, ...children)
, so let's try it:
function createElement(tag, attr, ...children) {
// Create the DOM element based on the tag we receive
const el = document.createElement(tag);
// Add the attributes, if any
if (attr) {
Object.keys(attr).forEach((key) => {
const value = attr[key];
el.setAttribute(key, value);
});
}
// Add the children, if any
if (children) {
children.forEach((child) => {
if (child instanceof HTMLElement) {
// If the child is already an HTMLElement, we can append it directly
el.appendChild(child);
} else {
// Otherwise, we assume it's just text, and create a text node first
const textNode = document.createTextNode(child.toString());
el.appendChild(textNode);
}
});
}
// Return the DOM element
return el;
}
This is a very simplistic implementation that supports basic HTML tags, adds attributes naïvely, and appends children. It is almost enough to support our example above.
A piece is missing, though. The returned DOM element doesn't appear anywhere, which is something JSX in itself doesn't do for you. Something must insert the generated DOM tree into the actual DOM.
React has its implementation ReactDOM.render
, and other implementations have theirs. We can make a very simple one for our JSX implementation, as follows.
function render(target, child) {
while (target.children.length) {
target.removeChild(target.children[0]);
}
target.appendChild(child);
}
Calling it render
is perhaps an overstatement. It simply eradicates the existing DOM tree under the target node and inserts the new node. React, for example, mounts the rendered tree in its shadow DOM and starts an event loop that responds to changes, diffing them into the real DOM in a performant way.
We'll leave that part of it out of this simple tutorial, since it's way beyond the scope of understanding JSX itself.
Our little render function is good enough to do the following:
render(document.getElementById('root'), <h1>Hello JSX!</h1>);
To update the contents, you have to trigger render()
again:
render(document.getElementById('root'), <h1>Hello again!</h1>);
Now, a few pieces are missing. For example, the onClick
handler in our example cannot be set in an attribute like we do with other attributes. It will set something, but not actually trigger the callback.
So, to fix this, we add special handling on all attributes starting with on
.
if (attr) {
Object.keys(attr).forEach((key) => {
const value = attr[key];
if (key.startsWith("on")) {
const event = key.substring(2).toLowerCase();
el.addEventListener(event, value);
} else {
el.setAttribute(key, value);
}
});
}
Now clicking buttons, and any other event, will work as expected.
The last vital piece for this to be usable is dealing with children. Children may be arrays themselves, for example when we .map()
over an array of data to generate a list of JSX elements.
To handle this, we update our children handling:
if (children) {
children.forEach((child) => {
if (Array.isArray(child)) {
child.forEach((child2) => {
el.appendChild(ensureElement(child2));
});
} else {
el.appendChild(ensureElement(child));
}
});
}
ensureElement()
is the extracted logic to handle nodes that are not already HTMLElement
s:
function ensureElement(child) {
if (child instanceof HTMLElement) {
return child;
}
return document.createTextNode(child.toString());
}
Now we handle the mapped arrays as well.
Finally, the true power of JSX is the ability to create components. As you may have noticed, we only handle simple HTML tags for now.
But this is not too hard. Components are just functions returning JSX, which in turn is converted into function calls that return DOM nodes. So to handle custom components, all we need to do, is check if the passed tag
is a function.
if (typeof tag === "function") {
// This is a custom component, so we just call it.
return tag({ ...attr, children });
}
Components expect their attributes as props
, and its children as props.children
. So combining the attributes and children into an object makes a custom component happy.
function MyWrappedListComponent({ title, description, children }) {
return (
<div>
<h2>{title}</h2>
<p>{description}</p>
<ul>{children}</ul>
</div>
);
}
We can now render this as JSX:
render(
document.getElementById('root'),
<MyWrappedListComponent title="Title" description="This is some longer text.">
<li>One</li>
<li>Two</li>
<li>Three</li>
</MyWrappedListComponent>
)
Now, our complete implementation looks like this:
function createElement(tag, attr, ...children) {
if (typeof tag === "function") {
// This is a custom component, so we just call it.
return tag({ ...attr, children });
}
// Create the DOM element based on the tag we receive
const el = document.createElement(tag);
// Add the attributes, if any
if (attr) {
Object.keys(attr).forEach((key) => {
const value = attr[key];
if (key.startsWith("on")) {
const event = key.substring(2).toLowerCase();
el.addEventListener(event, value);
} else {
el.setAttribute(key, value);
}
});
}
// Add the children, if any
if (children) {
children.forEach((child) => {
if (Array.isArray(child)) {
child.forEach((child2) => {
el.appendChild(ensureElement(child2));
});
} else {
el.appendChild(ensureElement(child));
}
});
}
}
// Return the DOM element
return el;
}
// Check if child is a DOM element, or create a text node of it.
function ensureElement(child) {
if (child instanceof HTMLElement) {
return child;
}
return document.createTextNode(child.toString());
}
And that is all!
Well, at least for a basic understanding of JSX. Various implementations add plenty of complexity to deal with cases like booleans and null values, added functionality like translating className
to class
, transforming style
objects to style rules, and so forth. But in its simplest form, JSX is not too hard to implement.