In this article we are going to reverse-engineer parts of React from scratch as we build out a counter app. We will stay well under 100 lines of spacious code the whole time. We start with a counter app built with Web APIs and imperative commands, and slowly refactor our way to core React abstractions, ending in declarative functional components using hooks and JSX. Along the way, the reader will hopefully gain an intuitive understanding for why React looks the way it does, the logic behind some of React's design, and finally, understand why React is so popular, namely, that it represents a suite of improvements over the "vanilla" alternative of sequential, imperative Web API invocations, as we will see.
We will build a counter app with no styles. At each stage of the refactoring journey, you should have a fully working counter-app HTML page that you can open in a browser.
1. Imperative Web APIs
We start with a root div. Everything else is programmatic. We create a container div, inside of which we place the div displaying the state, and both the plus and minus buttons.
<!DOCTYPE html>
<body>
<div id="root"></div>
<script>
let state = 0;
const containerDiv = document.createElement("div");
const stateDiv = document.createElement("div");
stateDiv.innerText = state;
const plusButton = document.createElement("button");
plusButton.innerText = "+";
plusButton.onclick = () => {
state += 1;
stateDiv.innerText = state;
};
const minusButton = document.createElement("button");
minusButton.innerText = "-";
minusButton.onclick = () => {
state -= 1;
stateDiv.innerText = state;
};
const root = document.getElementById("root");
root.appendChild(containerDiv);
containerDiv.appendChild(stateDiv);
containerDiv.appendChild(plusButton);
containerDiv.appendChild(minusButton);
</script>
</body>
This is a fully functioning counter-app and would be a fine stopping point full of good code... if this is all you wanted.
Before we move on, I want to remind the reader to stop here and contemplate what they are looking at. For all the immensity and complexity of the frontend, for all the frameworks and abstractions and optimizations, at the end of day, you end up with something like the above. Here's an element, here's its value, here's what to do on interaction, and here's where it goes in the DOM. (I enjoy contemplating the fundamental simplicity of it, and it is valuable to keep in mind when things get complicated.)
Now, moving on, there is room for improvement. The element creation logic is redundant and imperative, which is error-prone when compared to a declarative approach. Function calls are, in a sense, declarative, so let's wrap all this imperative element setup into a single function, which will allow us to declare the elements we want, while hiding all the error-prone logic. Enter the first React API of the journey, React.createElement.
2. React.createElement
The following is only showing the changes within the script tag of the html. Everything else is unchanged.
const React = {
createElement: (type, props, children) => {
const element = document.createElement(type);
element.onclick = () => props?.onClick?.();
const isText = typeof children == "string";
const isArray = Array.isArray(children);
if (isText) element.innerText = children;
if (isArray) children.map((c) => element.appendChild(c));
return element;
},
};
let state = 0;
const stateDiv = React.createElement("div", {}, `${state}`);
const increment = () => {
state += 1;
stateDiv.innerText = state;
};
const decrement = () => {
state -= 1;
stateDiv.innerText = state;
};
const counter = React.createElement("div", {}, [
stateDiv,
React.createElement("button", { onClick: increment }, "+"),
React.createElement("button", { onClick: decrement }, "-"),
]);
const root = document.getElementById("root");
root.appendChild(counter);
Excellent! Now we just say what props and children we want each React element to have, and React.createElement
takes care of the imperative commands required to set them all up.
But there's room for improvement. Notice how I had to create the stateDiv
before I wrote my increment
and decrement
functions? That's because my onClick
functions are also responsible for updating the display of stateDiv
. That is confusing. The onClick
functions should only have to control state logic, not also rendering logic. We should be able to handle that in the background. But, if we try and handle rendering logic ourselves, we need to know what has changed across the entire application. For example, increment
and decrement
"know" that changes to state require updating stateDiv
. But, that kind of just-so coupling is a recipe for disaster in more complex apps. A better approach to controlling rendering logic is to track state changes and to react to them by re-rendering only what has changed. React uses a virtual dom strategy to ensure accurate, performant state updates. We, on the other hand, are just going to obliterate everything on every state change, and then bring it all back.
No matter how intelligent your rendering logic is, a nice thing to do is hide it from the users of your API. That way, all they have to concern themselves with is the logic of their own application, i.e., their application state. They control their logic, and tell the API where the data appears in the UI, and the API takes care of the rest under-the-hood.
So, what we will do is give the user access to the root-level render, and take care of the rest for them. This will introduce our next React API, which will be the React v18 API, ReactDOMClient.createRoot and the created root's root.render
. (Previous versions of React used ReactDOM.render
, which would be a very similar implementation to what's below.)
3. ReactDOMClient.createRoot
In what follows, note particularly how the element.onclick
function within React.createElement
is now handling the rerender internally.
const ReactDOMClient = {
createRoot: (rootElement) => {
let app;
return {
render: (appComponent) => {
app = appComponent;
rootElement.appendChild(app());
},
unmount: () => {
while (rootElement.firstChild) {
rootElement.removeChild(rootElement.firstChild);
}
},
_rerender: () => {
root?.unmount?.();
rootElement.appendChild(app());
},
};
},
};
const React = {
createElement: (type, props, children) => {
const element = document.createElement(type);
element.onclick = () => {
props?.onClick?.();
root?._rerender();
};
const isText = typeof children == "string";
const isArray = Array.isArray(children);
if (isText) element.innerText = children;
if (isArray) children.map((c) => element.appendChild(c));
return element;
},
};
window.state = 0;
const increment = () => (window.state += 1);
const decrement = () => (window.state -= 1);
const counter = () =>
React.createElement("div", {}, [
React.createElement("div", {}, `${window.state}`),
React.createElement("button", { onClick: increment }, "+"),
React.createElement("button", { onClick: decrement }, "-"),
]);
const root = ReactDOMClient.createRoot(document.getElementById("root"));
root.render(counter);
Great! Our increment and decrement functions only deal with state logic now. Also, rendering is essentially out of the hands of API users. We just give our main component to the root.render
function and everything is taken care of from there.
But, there is a major flaw in this code. We are using window.state
to keep track of changes. We're doing that because we need something that sticks around between DOM updates. The problem is that window
is global. Global state is not a bad thing, per se, but using it to track local changes to the "counter component" is not ideal. We want local state tracked locally. In Javascript, a great way to manage local state is with classes, which we'll get to next.
This is also the first time in the refactoring journey where the word "component" has come up. I, the API user, am conceptualizing my counter as a single thing with buttons and a display of value. Using classes to compartmentalize local state into "components" is a powerful abstraction. It's such a good idea, in fact, that Web APIs have already adopted the concept into specifications, generally referred to as Web Components.
4. Class Components
I am only showing the top-level objects that changed. Everything else is unchanged. Of particular note is the Counter
class, and the syntax to render said class in ReactDOMClient
. With repeat calls to app.render()
local class state is maintained under closure.
const ReactDOMClient = {
createRoot: (rootElement) => {
let app;
return {
render: (appComponent) => {
app = new appComponent();
rootElement.appendChild(app.render());
},
unmount: () => {
while (rootElement.firstChild) {
rootElement.removeChild(rootElement.firstChild);
}
},
_rerender: () => {
root?.unmount?.();
rootElement.appendChild(app.render());
},
};
},
};
class Counter {
constructor() {
this.state = 0;
this.increment = () => (this.state += 1);
this.decrement = () => (this.state -= 1);
}
render() {
return React.createElement("div", {}, [
React.createElement("div", {}, `${this.state}`),
React.createElement("button", { onClick: this.increment }, "+"),
React.createElement("button", { onClick: this.decrement }, "-"),
]);
}
}
const root = ReactDOMClient.createRoot(document.getElementById("root"));
root.render(Counter);
This is starting to look really familiar if you wrote React back when classes were the recommended way to write components. (I'm not going to implement any of the state lifecycle components in this post, since it would take too long.)
A seemingly small critique in the above code is how verbose our render function is. There are repeat calls to React.createElement
decreasing the readability of our render function. This readability issue becomes a big problem in larger apps. The problem is compounded when you consider the importance of what you are doing, namely, creating your DOM. Visualizing the DOM is incredibly important to writing frontend code. HTML is perfect at this. Much like our calls to React.createElement
, HTML is declarative. You "tell the browser" what you want to see, and how you want it to look, and the browser takes care of the rest. React.createElement
is similarly declarative to HTML, but it is lacking the terse markup-like syntax that makes visualization second-nature. Matrix-like levels of "seeing the code" are of immense utility, and should not be dismissed quickly. In this space, there are two common approaches. One is JS-in-HTML, where you start with HTML and figure out how to expand it to allow JS to control parts of it. The alternative is HTML-in-JS, where you start with JS and figure out how to expand it to allow HTML-like syntax to exist within it, and generate the DOM elements it otherwise would within a .html
file. This is the approach React takes, and it does this via JSX.
Now, you can go about building a naive JSX parser by using template strings to inject state variables, and then write a regex to extract named capture groups, and use the results like a knock-off abstract syntax tree to generate corresponding React.createElement
calls...
// Warning: This is inspirational
// ... aka, code don't work!
const elements = (...vars) => `<div onClick=${v[0]}>${v[1]}</div>`;
const r = /<(?<type>.*?)(>|\s(?<props>.*)>)(?<children>.*)<\/.*>/;
const {
groups: { type, props, children },
} = r.exec(elements(() => increment, state));
React.createElement(type, props, children);
But, that's a bit much. Instead, I'm going to try and shrink the syntax down as much as I can, so you can see the effect it has on readability.
5. A Knock-Off JSX Syntax
If you squint at the render
function, its almost like JSX props and children?
const JSX = {
div: (props, children) => React.createElement("div", props, children),
button: (props, children) => React.createElement("button", props, children),
};
const { div, button } = JSX;
class Counter {
constructor() {
this.state = 0;
this.increment = () => (this.state += 1);
this.decrement = () => (this.state -= 1);
}
render() {
return div({}, [
div({}, `${this.state}`),
button({ onClick: this.increment }, "+"),
button({ onClick: this.decrement }, "-"),
]);
}
}
Hopefully you get the point that readability is important, and JSX has a huge role to play on that front in React.
This is all looking pretty good. But, a problem with the above code is that we are using a class without really using a class. We are ignoring most of the important features of classes, i.e., methods and state management. Yes, we do have state and methods, defined in the constructor
, but it feels "too simple" for all the power classes make available. In fact, even in complex applications, React components are often "simple". That is, dependent upon data or logic that is independent of the machinery of classes. Add to this the fact that, when React detects a function component, it can render it more performantly, and we quickly begin reaching the conclusion that function components are to be preferred over class components.
When there is no state to manage, the transition to function components is easy. But, when you have state to deal with, things get more complicated. For example, our counter component, while relatively simple, still has state that needs to persist across DOM changes. So, hard question: how do you use function components when you also have to manage local state? In a previous step we used the window
but that was global state, and we still want to avoid that. Another option is to take function components and use them to generate classes, and then manage class state, all internally. We can then give API users entry points into controlling the internal state. Almost like little hooks into the internal state, giving them access to precisely what they want and nothing more. This is where hooks enter the scene.
The hook abstraction has even more upsides in that it also allows you to reuse component logic by reusing custom hooks. The most important thing to keep in mind when using hooks (in my opinion) is that you are using a function like a class. This can be confusing at first, but can be incredibly powerful once you get the hang of it.
Normally, React does a very clever thing to allow it to manage state without having to refer to this
or some other indicator of class instance. I am not that clever, and so what you are about to see is React.useState
where a reference to the this
keyword is being passed around per function invocation, in order to keep track of state.
6. React.useState
This is the last section so I'm going to show the whole HTML file so you can copy and play with it however you like. The things to note are (1) the Counter class is now a function, (2) the Counter function is using React.useState
with a supplied _this
(unlike real React), and (3) the ReactDOMClient
is now instantiating a generic FunctionComponent
class per function component, in order to keep track of state. A more clever implementation would detect when no state is needed, and skip class creation as a result.
<!DOCTYPE html>
<body>
<div id="root"></div>
<script>
const ReactDOMClient = {
createRoot: (rootElement) => {
let app;
class FunctionComponent {
constructor(f) {
this.state = undefined;
this.render = () => f(this);
}
}
return {
render: (appComponent) => {
app = new FunctionComponent(appComponent);
rootElement.appendChild(app.render());
},
unmount: () => {
while (rootElement.firstChild) {
rootElement.removeChild(rootElement.firstChild);
}
},
_rerender: () => {
root?.unmount?.();
rootElement.appendChild(app.render());
},
};
},
};
const React = {
createElement: (type, props, children) => {
const element = document.createElement(type);
element.onclick = () => {
props?.onClick?.();
root?._rerender();
};
const isText = typeof children == "string";
const isArray = Array.isArray(children);
if (isText) element.innerText = children;
if (isArray) children.map((c) => element.appendChild(c));
return element;
},
useState: (initialState, _this) => {
_this.state = _this.state ?? initialState;
return [
_this.state,
(newState) => {
_this.state = newState;
},
];
},
};
const JSX = {
div: (props, children) => React.createElement("div", props, children),
button: (props, children) =>
React.createElement("button", props, children),
};
const { div, button } = JSX;
function Counter(_this) {
const [state, setState] = React.useState(0, _this);
const increment = () => setState(state + 1);
const decrement = () => setState(state - 1);
return div({}, [
div({}, `${state}`),
button({ onClick: increment }, "+"),
button({ onClick: decrement }, "-"),
]);
}
const root = ReactDOMClient.createRoot(document.getElementById("root"));
root.render(Counter);
</script>
</body>
This is awesome! If we packed away all our React abstractions into a library, we'd be left with just these last 14 lines of declarative code! Let's quickly looks at those 14 lines in comparison with real React code.
Our React:
function Counter(_this) {
const [state, setState] = React.useState(0, _this);
const increment = () => setState(state + 1);
const decrement = () => setState(state - 1);
return div({}, [
div({}, `${state}`),
button({ onClick: increment }, "+"),
button({ onClick: decrement }, "-"),
]);
}
const root = ReactDOMClient.createRoot(document.getElementById("root"));
root.render(Counter);
Real React:
function Counter() {
const [state, setState] = React.useState(0);
const increment = () => setState(state + 1);
const decrement = () => setState(state - 1);
return (
<div>
<div>{state}</div>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
);
}
const root = ReactDOMClient.createRoot(document.getElementById("root"));
root.render(Counter);
Not too shabby!
Conclusion
I hope this has been an informative exploration of React. Seeing how you would naively go about building a common tool, like React, can be a healthy exercise for understanding the real tool better. It takes away the magic of the thing, and lets you reason about it more intuitively going forward.
It should be obvious by now that this is not real React code. But, if you are curious to see how the real code is written, note that React is open-source on Github. Check it out to learn more.
A related blog post of mine on this topic is Reinventing Redux through React Refactors wherein I take a similar journey starting with vanilla React state management and eventually rediscover, and naively implement, the core Redux abstractions.
Thanks for reading! :D