Recently, I have been experimenting with different approaches for building vanilla JavaScript apps. And I got the idea to recreate basic React functionality in order to get a similar workflow as in React. This would enable me to keep the benefits of vanilla JavaScript while having the structure of React apps. It would also make it easy to migrate code into React if the app grows.
By the end of this post, I will show you how to make a counter component with code that looks almost identical to React code, without using any React. As can be seen here:
import * as elements from 'typed-html';
import { notReact } from '../notReact';
const Counter = () => {
const [count, setCount] = notReact.useState(0);
const increaseCounter = () => {
setCount(count+1);
}
notReact.addOnClick("increaseCount", increaseCounter);
let isHigherThan5: string;
notReact.useEffect(()=>{
isHigherThan5 = count > 5 ? "Yes" : "No";
}, [count, isHigherThan5]);
return (
<div>
<h1>Counter: {count}</h1>
<button id="increaseCount">Increase count</button>
<p>Is the count higher than 5? <strong>{isHigherThan5}!</strong></p>
</div>
);
}
export default Counter;
You can find the repository here.
Setup
The first thing that I did was that I installed webpack and typescript. The main reason why I'm using typescript is because it makes it easy to use jsx, otherwise it's not mandatory. The same could likely be done with babel as well.
After a standard webpack and typescript installation, I installed typed-html npm install --save typed-html
. This is a package that lets us use jsx inside of typescript tsx
files.
After it was installed, I added the following lines into the typescript config file.
{
"compilerOptions": {
"jsx": "react",
"jsxFactory": "elements.createElement",
}
}
This factory comes with some limitations.
<foo></foo>; // => Error: Property 'foo' does not exist on type 'JSX.IntrinsicElements'.
<a foo="bar"></a>; // => Error: Property 'foo' does not exist on type 'HtmlAnchorTag'
We can't use props and components like we usually would in React, instead, a component will be a function call and the function arguments will be props.
Now, what does the jsx factory even do?
It transpiles the jsx into a string. That works for me, because I wanted to do the rending with a simple .innerHTML
. But if you want to get some other kind of output, you could use some other factory or even make your own.
You could also avoid using jsx and just use template literals instead.
Before I started coding I also had to create an index.html file.
/public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<title>App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="bundle.js"></script>
</body>
</html>
Rendering
Now that everything was set up, it was time to dive into JavaScript.
First I made a file called notReact.ts
and put it inside of the /src
folder. This file is where all of the rendering and state logic was located in.
First I made a function closure and put two functions inside of it. One for initialization and one for rendering.
export const notReact = (function() {
let _root: Element;
let _templateCallback: ITemplateCallback;
function init(rootElement: Element, templateCallback: ITemplateCallback) {
_root = rootElement;
_templateCallback = templateCallback;
render();
}
function render() {
_root.innerHTML = _templateCallback();
}
return {init, render};
})();
type ITemplateCallback = { (): string; }
init()
has two arguments, a root element that will be used as a template container and a callback function that returns a string, containing all of the html.
The render()
function calls the template callback and assigns it to the .innerHTML
of the root element.
Next, I made the index.ts
and the App.tsx
file and put both of them inside of the /src
folder.
Then I initialized the rendering and called the App
component inside of the index.ts
file.
import App from "./App";
import { notReact } from "./notReact";
const render = () => {
const root = document.getElementById('root');
notReact.init(root, App);
}
window.addEventListener("DOMContentLoaded", () => render());
Inside of the App
component I wrote a simple "Hello world".
import * as elements from 'typed-html';
const App = () => {
return (
<h1>
Hello world;
</h1>
);
}
export default App;
State and event listeners
Now that the rendering was done, it was time to write the useState
hook, while also creating a basic counter application to test it out.
First I created another component called Counter.tsx
and put it inside of the components
folder.
I wrote it the same way it would be written in regular React, with the exception of the onClick
event that I omitted for now.
import * as elements from 'typed-html';
import { notReact } from '../notReact';
const Counter = () => {
const [count, setCount] = notReact.useState(0);
const increaseCounter = () => {
setCount(count+1);
}
return (
<div>
<h1>Counter: {count}</h1>
<button>Increase count</button>
</div>
);
}
export default Counter;
After that, I had to change the App component:
import * as elements from 'typed-html';
import Counter from './components/Counter';
const App = () => {
return (
<div>
{Counter()}
</div>
);
}
export default App;
With everything being ready, it was time to write the useState hook.
export const notReact = (function() {
let hooks: Array<any> = [];
let idx: number = 0;
function useState(initValue: any) {
let state;
state = hooks[idx] !== undefined ? hooks[idx] : initValue;
const _idx = idx;
const setState = (newValue: any) => {
hooks[_idx] = newValue;
render();
}
idx++;
return [state, setState];
}
function render() {
idx=0; //resets on rerender
...
}
return {useState, init, render};
})();
There are two local variables. An array variable called hooks
that contains all of the state values. And the idx
variable which is the index used to iterate over the hooks
array.
Inside of the useState()
function, a state value together with a setter function get returned for each useState()
call.
Now we have a working useState
hook, but we can't test it out yet. We need to add an onclick
event listener to the button first. The problem here is that if we add it directly into the jsx, the function will be undefined because of the way the html is being rendered here.
To fix this, I had to update the notReact.ts
file again.
export const notReact = (function() {
const _eventArray: IEventArray = [];
function render() {
_eventArray.length = 0; //the array gets emptied on rerender
...
document.addEventListener('click', (e) => handleEventListeners(e));
function handleEventListeners(e: any) {
_eventArray.forEach((target: any) => {
if (e.target.id === target.id) {
e.preventDefault();
target.callback();
}
});
}
function addOnClick(id: string, callback: any) {
_eventArray.push({id, callback});
}
return {useState, useEffect, init, render, addOnClick};
})();
type IEventArray = [{id: string, callback: any}] | Array<any>;
I made a local variable named eventArray
. It's an array of objects, containing all elements that have an onclick
event, together with a callback function for each of those events.
The document
has an onclick
event listener. On each click it checks if the target element is equal to one of the event array elements. If it is, it fires it's callback function.
Now let's update the Counter component so that the button has an onclick event:
const Counter = () => {
...
notReact.addOnClick("increaseCount", increaseCounter);
...
return (
<div>
<h1>Counter: {count}</h1>
<button id="increaseCount">Increase count</button>
</div>
);
}
Side effects
The last thing that I added was the useEffect
hook.
Here is the code:
export const notReact = (function() {
let hooks: Array<any> = [];
let idx: number = 0;
function useEffect(callback: any, dependancyArray: Array<any>) {
const oldDependancies = hooks[idx];
let hasChanged = true;
if (oldDependancies) {
hasChanged = dependancyArray.some((dep, i) => !Object.is(dep, oldDependancies[i]));
}
hooks[idx] = dependancyArray;
idx++;
if (hasChanged) callback();
}
return {useState, useEffect, init, render, addOnClick};
})();
It saves dependancies from the last render and checks if they changed. If they did change the callback function gets called.
Lets try it in action! I added a message bellow the button that changes if the counter gets higher than 5.
Here is the final counter Component code:
import * as elements from 'typed-html';
import { notReact } from '../notReact';
const Counter = () => {
const [count, setCount] = notReact.useState(0);
const increaseCounter = () => {
setCount(count+1);
}
notReact.addOnClick("increaseCount", increaseCounter);
let isHigherThan5: string;
notReact.useEffect(()=>{
isHigherThan5 = count > 5 ? "Yes" : "No";
}, [count, isHigherThan5]);
return (
<div>
<h1>Counter: {count}</h1>
<button id="increaseCount">Increase count</button>
<p>Is the count higher than 5? <strong>{isHigherThan5}!</strong></p>
</div>
);
}
export default Counter;
Conclusion
This is it! The component is looking a lot like actual React now. Changing it for React would be trivial, the only thing that would need to be changed is the onclick
event and the imports.
If you enjoy working in React, an approach like this might be worth trying out. Just keep in mind that this code is proof of concept, it isn't very tested and there are definitely a lot of bugs, especially when there are a lot of different states. The code has a lot of room for improvement and expansion. It's not a lot of code though, so it would be easy to change it based on your project requirements. For a more serious application, you would most likely have to implement some sort of event loop that would synchronize the state changes.
I didn't go very in depth about my implementation of the useState and useEffect hooks. But if you want more details, you can check out this talk, it's what inspired my implementation.
Again, all of the code can be found in this repository.
Thank you for reading! 😁
Top comments (1)
Setting new state by passing new state as a parameter not working
you have to explicitly write the variable name and assign it to new value