DEV Community

loading...
Cover image for Recreating the React workflow in vanilla JavaScript

Recreating the React workflow in vanilla JavaScript

maturc profile image Matija Turčec Updated on ・6 min read

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;
Enter fullscreen mode Exit fullscreen mode

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",
  }
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Here is the result:
result

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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};
})();
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Here is the result so far:
counter demo

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};
})();
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

final demo

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! 😁

Discussion

pic
Editor guide