Let’s build a React from scratch: Part 1 — VirtualDOM and Renderer
Disclaimer: The content below is just for learning and have some key insights while using framework like React. While the inspiration is React, the idea is to deliver the core concepts of what it takes to build a library like React at its most basic level.
As a child, I always enjoyed learning things by building them from scratch. It's fun and Satisfying to the core. Well, today I am writing this series to bring out that childhood me a little bit. I thought what would be a better topic than learning React (without React), as it's so popular and reachable to many many readers.
Special thanks and shout out to my inspiration, for this series from Tejas Kumar, and his amazing talk on Deconstructing React
The content will be covered in multi-part series, especially 4 for this one. I wanted to cover all the main aspects which make React what it is.
- Part 1 — VirtualDOM and React renderer
- Part 2 — State Management and React Hooks
- Part 3 — React Suspense and Concurrent Mode
- Part 4 — Server Side Rendering and its Challenges
😇 Setting Things up
In order for us to get to the essence of this series, I would love it if you can ride along with me. I mean literally! (virtually of course :)). So first things first, how do we set up our stage for what’s to come.
We will keep it simple. I will be using Typescript to help me out with some JSX Syntax and we will run it using Stackblitz (web-container). So all you need is a modern browser (Chromium-based) that’s all. Or You can choose your favorite setup as long as you can run Typescript that’s all we will need.
This is a blank node-js template starter screen you will see when you start off in stackblitz. First, we install typescript and initialize it for the project.
❯ npm i -D typescript
warn preInstall No description field
warn preInstall No repository field
warn preInstall No license field
┌ [1/3] 🔍 Resolving dependencies
└ Completed in 4.561s
┌ [2/3] 🚚 Fetching dependencies
└ Completed in 1.11s
┌ [3/3] 🔗 Linking dependencies
└ Completed in 0.301ssuccess Saved lockfile "package-lock.json"
success Updated "package.json"success Install finished in 5.986s~/projects/node-gulnyl 6s❯ npx typescript --initCreated a new tsconfig.json with:
We will need Typescript primarily for the JSX syntax. So let’s go ahead and enable the same in tsconfig.json
.
So we change this line
// "jsx": "preserve", /* Specify what JSX code is generated. */to look like this,
...
"jsx": "react",
...// Also to avoid Typscript complaining about types please set strict mode to false."strict": false /* Enable all strict type-checking options. */,
Next, we will need the main file where our application code will go. For this we will useapp.tsx
which will be our application and our React library both, and anhtml
page where our React app will run. Lastly, we will need a local web-server to serve our page. For this, I created a very simple tsx
and html
page which are served viaserve
(web-server) to finish setting things up.
// package.json"scripts": {
"dev": "tsc -w"
},// index.html<div id="myapp"></div>
<script src="app.js"></script>// app.tsx// This will our main app file.// ---- Library ---// ---- Application ---console.log('Hello World');// Open two terminals for the following commands respectively.npm run dev
npx serve .
This will start Typescript compilation as well as serve the HTML file from the web container, which when opened in a new window looks like this.
Congratulations!! 🎉🎉 You have done it, now it's time to have lots of fun. Basically, we have a webpage serving a typescript compiled file (in watch mode) with JSX support for React library. Now let’s get going with some hands-on coding.
👼 Our First JSX
Because we have JSX support, we can write something like this on our app.tsx
(in fact, all our coding will happen on this file only, so if not mentioned you can assume I am referring to this file only).
const App = <div>Hello JSX!</div>;
If you look at the compilation screen, you will see something like this,
But we don’t have any React in the Code yet, why is it complaining about React not being there. To understand, let’s add our own great React library so that typescript has something to work with and we can see what’s going on.
// ---- Library ---const React = {};// ---- Application ---const App = <div>Hello JSX!</div>;
with this our compilation succeeds, as it's now found a reference to React. Let’s look at the compiled js
code as well to see what it has generated for us.
Ah hah! we see how JSX is compiled by Typescript to generate the code React.createElement()
. But our React is just a blank object yet, so this call must fail when we try to run this code on the browser, and sure enough, if we refresh the browser we see this.
So let’s define our createElement
function in our React and for now just log what’s being passed to this function to see what’s happening.
// ---- Library --- //
const React = {
createElement: (...args) => {
console.log(args);
},
};// ---- Application --- //
const App = React.createElement('div', null, 'Hello JSX!');
Sure enough, what we see are the args which look like HTML Tag, any props, and then any children for that element. Let’s modify our App to make it a little bit more comprehensive HTML to see what’s going on.
// ---- Application --- //
const App = (
<div draggable>
<h2>Hello React!</h2>
<p>I am a pargraph</p>
<input type="text" />
</div>
);
So, what do we see here, we see our createElement
being called multiple times as per the structure of the JSX. And the order of the call is also such that top level elements are called after their children are called. Also, note that because we don’t have any children for input
an element so it only has two arguments. I have added the draggable
attribute on the top level div
just to illustrate the props structure and how it's passed to us. Let’s see the generated code to reconfirm these observations.
Please note, here, that we also expectcreateElement
to return something, as of now we are not returning anything only logging the args that’s why the children portions for the top-level element is like [undefined, undefined, undefined
]. So let’s fix that in the next section 🥶
🧚♂️ Introducing Virtual DOM 🧞♂️
DOM or Document Object Model is the official W3C way of specifying the structure of the HTML document we are working with which Browsers can understand and render. Virtual DOM, loosely speaking is a similar structure which can represent our page structure and its details but how to re-present it and how it should look like is completely upto to the implementor.
React Library mostly works in such Virtual DOM environment only and then hands over the representation to some renderer like ReactDOM for Web and ReactNative for Mobile which takes this Tree like structure and knows how to render it out to a WebPage or to a Native Mobile application.
For this post, let’s try to build our own representation which we can work with. We will keep it really simple, we will simply create an simple/plain JS Object when createElement
is called keeping the tag, props and childrens and return that from createElement
function. Take a look at the implementation below.
// ---- Library --- //
const React = {
createElement: (tag, props, ...children) => {
const el = {
tag,
props,
children,
};
console.log(el);
return el;
},
};
So what we did here is we split the args from ...args
to explicitly receive all tag
, props
and children
and specifically for children, collected them all in an array with accepting ...children
. With this change our console looks something like this.
This what Virtual DOM looks like, this structure which will form the basis of all other computation and rendering for our application is very important to get right.
👯 ReactElement vs ReactComponent
So far our App is just a direct DOM tree (somewhat), and its a called a ReactElement. When we would want to perform more complex operations within the DOM, like add a variable state etc., we can’t simply use Elements directly, we may want to wrap them in a function which is known more popularly as ReactComponent. Let’s try to do so in our app by modifying App
to be a function and when the function is called it returns the same Virtual DOM tree we are familiar with so far. This involves changes in both App
as well as the createElement
function as it now needs to handle inputs like Components which are simply functions wrapping VirtualDOM instead of the actual VirtualDOM withtag
, props
and children
we are familiar with so far.
// ---- Application --- //
const App = () => {
return (
<div draggable>
<h2>Hello React!</h2>
<p>I am a pargraph</p>
<input type="text" />
</div>
);
};console.log(<App />);
Note, now, because tag
is a function here and we are not handling function inputs yet in createElement
So, it will not form the entire DOM tree and it will look something like this like this.
Now let’s handle, our createElement
function to handle a ReactComponent. All we do is check the type of the first param ( tag
in this case), if its a function we simply call the function so that it returns the required Elements from it.
// ---- Library --- //
const React = {
createElement: (tag, props, ...children) => {
if (typeof tag === 'function') {
return tag(props, ...children);
}
const el = {
tag,
props,
children,
};
return el;
},
};
With this change, our code is back in action constructing the Virtual DOM we aimed for.
🧳 Let’s Render our VirtualDOM (renderer)🪖
Alright, we have come a long way in building our nice little React library. but so far everything is happening on console. Let’s now add the final piece to the puzzle which takes this tree representation and renders it out to the actual Browser.
Let’s think for a minute how will we go about doing that. Well, we will figure it out, for now we know we need a render
function which takes a ReactComponent as argument and we will also need an actual DOM element where we want to render or mount this. For this, we have the one and only div
element with id myapp
where we want to render our App
in our index.html
file.
<div id="myapp"></div> <-- This will be our mount point.
<script src="app.js"></script>
Ok, now we are ready to start defining out render function with this context in mind and see what it has as inputs to work with.
// ---- Library --- //
const render = (el, container) => {
console.log(el);
console.log(container);
};// ---- Application --- //
render(<App />, document.getElementById('myapp'));
Great! we have our root node for the VirtualDOM(<App/>
) and actual DOM node where we want to mount our App
. So, as browsers don’t understand our VirtualDOM, this function will start building out the Actual DOM tree from the VirtualDOM tree. we will use our Browser API to create DOM nodes and relations corresponding to the virtual DOM.
- First, create the actual DOM node of type tag by calling
document.createElement(tag)
API. - Second, once a node is created, we will set all the props from the VirtualDOM node to the actual HTML node by looping overall
props
from the virtual node. - Third, if the node has any children, then we have to do the same above steps recursively and append the child to the current DOM(we just created) node as container for the childs.
- Lastly, we wrap up by
container.appendChild(domEl)
which takes the node and appending it to the container it was supposed to in the Browser.
// ---- Library --- //
const render = (el, container) => {
// 1. First create the document node corresponding el
let domEl = document.createElement(el.tag);// 2. Set the props on domEl
let elProps = el.props ? Object.keys(el.props) : null;
if (elProps && elProps.length > 0) {
elProps.forEach((prop) => (domEl[prop] = el.props[prop]));
} // 3. Handle creating the Children.
if (el.children && el.children.length > 0) {
// When child is rendered, the container will be
// the domEl we created here.
el.children.forEach((node) => render(node, domEl));
} // 4. append the DOM node to the container.
container.appendChild(domEl);
};
We are so close now, but if you run it, it will not work. Because, the only other thing we need to take care of is the case of elements (VirtualDOM nodes)that are not full-fledged DOM nodes but just text nodes. Before I solve for this try to see what happens when you try to run the above code.
Basically, to understand this error, try to see how the text elements are passed to render from VirtualDOM. Consider the <h2>
element for example here. It has just a text as a child.
When render()
is called with just 'Hello World'
, the tag
, props
, and children
properties are missing and we are trying to create an HTML node with document.createElement(el.tag)
where el.tag
is undefined
and you can’t create a object out of undefined
that’s what the above error is all about. So, let’s fix the last error here by handling the text nodes by simply creating actual text nodes in HTML and doing the same append to the container like above. (Also, for simple cases, TextNodes won’t have any children of their own so we can simply return from it )
// ---- Library --- //
const render = (el, container) => {
let domEl;
// 0. Check the type of el
// if string we need to handle it like text node.
if (typeof el === 'string') {
// create an actual Text Node
domEl = document.createTextNode(el);
container.appendChild(domEl);
// No children for text so we return.
return;
}
// 1. First create the document node corresponding el
domEl = document.createElement(el.tag);
// 2. Set the props on domEl
let elProps = el.props ? Object.keys(el.props) : null;
if (elProps && elProps.length > 0) {
elProps.forEach((prop) => (domEl[prop] = el.props[prop]));
}
// 3. Handle creating the Children.
if (el.children && el.children.length > 0) {
// When child is rendered, the container will be
// the domEl we created here.
el.children.forEach((node) => render(node, domEl));
}
// 4. append the DOM node to the container.
container.appendChild(domEl);
};
And with this change. Viola!! see our Browser, our app is rendered without Errors. Take a moment to congratulate yourself for all the hard work you have done so far. Really amazing, isn’t it.
🐸 Conclusion 🐳
Well, we started off with pretty much nothing as far as React is concerned. Then we learned how JSX is parsed and called. We created our own React which ultimately creates a Tree structure that we can work with. That’s our very own VirtualDOM, lastly, we wrote our own renderer which takes this VirtualDOM tree and converts it into an actual DOM Tree which the browser can render. In fact if you just look at the Application code, it looks pretty much like you are using React 💥💥, in fact, because we have a function (ReactComponent) we can use some variables here as well.
// ---- Application --- //
const App = () => {
const myName = 'Arindam';
return (
<div draggable>
<h2>Hello {myName}!</h2>
<p>I am a pargraph</p>
<input type="text" />
</div>
);
};render(<App />, document.getElementById('myapp'));
Well, that’s where I would like to conclude for the first part. There are a lot of exciting things ahead of us like State Management, Concurrent Mode, SSR, etc. See you in the next sections! Happy Coding! ☃️☃️