building react-mini
taher33
Posted on March 17, 2024
after using react for the past 3 years 2 of these professionally I've become descent at using the framework and my knowledge of the internal working of it became better with time, but still I don't understand a lot of parts that are essential to it's working and with the increase of tools and ways you can use react I decided to take a step back and try to understand the very basics of it for web dev, web dev is html css and javascript, a script tag is added to the html file has a script tag for js that browsers can run, so that's what I want code that looks very similar to react that I can just shove inside a browser.
so no vite no CRA no nextjs I just want js
let's try building a library that looks similar to reactjs in order to understand how it really works under the hood
first thing is that react doesn't work with jsx, jsx is a completely different project so react doesn't really understand it so a transpiler must come in to save us, enter babel
If you try using react without jsx you'll have write something like this
import { jsx as _jsx } from "react/jsx-runtime";
function App() {
const [count, setCount] = useState(0);
return /*#__PURE__*/_jsx("a", {
href: "#"
});
}
I don't want to write this so why force anyone to do that, what I want is jsx so babel will transform the jsx for us to the code above (maybe we will build babel our selves too next??)
let's install it and configure it npm i -D @babel/cli @babel/core @babel/plugin-transform-react-jsx
in the package.json file we add this config
"babel": {
"presets": [],
"plugins": [
[
"@babel/plugin-transform-react-jsx",
{
"throwIfNamespace": false,
"runtime": "automatic",
"importSource": "../packages/jsx"
}
]
]
}
and we add a script to run babel
"build": "babel src -d dist"
so the input would be:
function Profile() {
const user = {
firstName: "helo",
lastName: "yello",
};
return (
<div>
<img src="avatar.png" className="profile" />
<h3>{[user.firstName, user.lastName].join(" ")}</h3>
</div>
);
}
and the output would look like:
import { jsx as _jsx } from "../packages/jsx/jsx-runtime";
import { jsxs as _jsxs } from "../packages/jsx/jsx-runtime";
function Profile() {
const user = {
firstName: "helo",
lastName: "yello"
};
return _jsxs("div", {
children: [_jsx("img", {
src: "avatar.png",
className: "profile"
}), _jsx("h3", {
children: [user.firstName, user.lastName].join(" ")
})]
});
}
so you can see that all html elements are turned into function calls for jsx
or jsxs
ok transpilation is done and we are back into js land but what about the imports from jsx-runtime
we don't have that set yet so let's build it
first let's create a packages folder and this is where we will put the logic for react-mini, and add a jsx folder we will add the logic for jsx (this is something I took from the react code base, it's not specific to them but I liked this file structure)
in the jsx folder let's create the jsx-runtime file and export the functions the code needs
export function jsx(tag, props, children) {
console.log({ tag, props, children });
}
export function jsxs(tag, props, children) {
console.log("hello", { tag, props, children });
}
just logging the arguments for now
forgot to mention this but I have a index.html where I import the entry point for the code in dist folder that babel produces so that I can run that code in the browser
doing this you'll run into problems trying to import into the index.js since it's not a module so let's set the script tag in our html
<script src="dist/index.js" type="module"></script>
but just opening this html file will throw a CORS error, I know right this thing just can't stop making our lives miserable.
to fix this we need a server to serve this html, let's create server.js and put this gpt code inside:
const http = require("http");
const fs = require("fs");
const path = require("path");
const hostname = "127.0.0.1"; // localhost
const port = 3000; // You can use any available port number
const server = http.createServer((req, res) => {
let filePath = path.join(__dirname, req.url === "/" ? "index.html" : req.url);
let contentType = "text/html"; // Default content type
// Check if the requested file is a JavaScript file
if (path.extname(filePath) === ".js" || path.extname(filePath) === "") {
contentType = "application/javascript"; // Set content type to JavaScript
}
if (path.extname(filePath) === "") filePath = filePath + ".js";
// Check if the requested file exists
fs.access(filePath, fs.constants.F_OK, (err) => {
if (err) {
console.log({ err, pathext: path.extname(filePath) });
res.writeHead(404);
res.end("File not found");
return;
}
// Read the file and serve it
fs.readFile(filePath, (err, content) => {
if (err) {
res.writeHead(500);
res.end(`Error loading file: ${err}`);
return;
}
// Serve the file with the appropriate content type
res.writeHead(200, { "Content-Type": contentType });
res.end(content, "utf-8");
});
});
});
server.listen(port, hostname, () => console.log("server is running"));
you can see in line 16 that I'm checking for an empty extensions and that is because of this import { jsx as _jsx } from "../packages/jsx/jsx-runtime";
the import doesn't have the "js" extension and that is causing 404 errors so for now this is a quick and dirty fix until I find out how it's done for something like vite or next.js
I use this script to run it "dev": "npm run build && node server.js"
transpile the jsx and run the server
ReactDOM
let's create a folder inside packages and this would hold our ReactDOM implementation, react doesn't care about the UI so it can be used for anything, examples are react native react ink and the most known react dom.
ReactDOM cares about displaying to the browser so let's build that first
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
this is from a react project so let's try to mimic that
const ReactDOM = {};
function createRoot(element) {
return {
render: (root) => {
console.log(root);
},
};
}
ReactDOM.createRoot = createRoot;
export default ReactDOM;
and let's import this inside our component
ReactDOM.createRoot(document.getElementById("root")).render(
<App/>
);
after running the program it would log undefined for the root but that's because our jsx handling functions don't return anything yet
remember our transpiled code would look like this
ReactDOM.createRoot(document.getElementById("root")).render(
jsx(App,{})
);
so let's fix that quickly
export function jsx(tag, props) {
return {
tag,
props,
};
}
export function jsxs(tag, props) {
return {
tag,
props,
};
}
I don't do anything fancy for now I just return whatever I receive
after running the code, the render function would log this
{
tag:App,
children:{}
}
but this isn't very useful to us, the function components have their own implantation so for now let's ignore them, I just want to show something in the browser
ReactDOM.createRoot(document.getElementById("root")).render(
<div>
<h1>title here</h1>
<span>span here </span>
</div>
);
this would be simpler to handle and we won't have to deal with any logic for functional or class components
the log for render function would become
{
"tag": "div",
"props": {
"children": [
"text here ",
{
"tag": "span",
"props": {
"children": "text here 222"
}
}
]
}
}
this is more useful to us and we can use it to create dom elements in the browser, so let's add more logic in the render function
function createRoot(element) {
return {
render: (root) => {
let newEl;
if (!root.tag) {
newEl = document.createTextNode(root);
} else {
newEl = document.createElement(root.tag);
}
walkTree(root.props.children, newEl);
document.body.insertBefore(newEl, element);
},
};
}
function walkTree(children, parentEl) {
if (Array.isArray(children)) {
children.forEach((el) => {
if (!el.tag) {
//since no tag is given it's just text
parentEl.appendChild(document.createTextNode(el));
} else {
const newEl = document.createElement(el.tag);
walkTree(el.props.children, newEl);
parentEl.appendChild(newEl);
}
});
return;
}
if (!children.tag) {
//since no tag is given it's just text
parentEl.appendChild(document.createTextNode(children));
} else {
const newEl = document.createElement(children.tag);
walkTree(children.tag.props.children, newEl);
parentEl.appendChild(newEl);
}
}
render now creates the root dom node then calls to walkTree function to traverse the children and creates the dom elements for each child and appends them to their correspondent parent recursively
running the code, will put the dom elements inside our browser for us to see, and congrats we got stuff displaying.
functional components
in this section I'll try to handle functional components since they don't seem to be working for now
one thing that you'll notice after logging the result from jsx function for functional components is that we get the function signature so we can just call it and it'll return it's children whatever they are and we can just shove them inside the walkTree for it to process, some changes need to be made though
let's add logic to check if the tag is a function
if (typeof children.tag === "function") {
const functionCallRes = children.tag(children.props);
walkTree(functionCallRes.props.children, newEl);
return;
}
we just call the function and it would return to us what we need, we use a the children.props for the function since it does expect it if the components receives props
and let's updates the render function so it doesn't blow up if the root is a functional component
render: (root) => {
const newEl = document.createElement("div");
walkTree(root, newEl);
document.body.insertBefore(newEl, element);
}
we can also add support for the style
prop for our elements so we can add styles in the future, the code for it is pretty straight forward
if (children.props.style) {
Object.keys(children.props.style).forEach((key) => {
newEl.style[key] = children.props.style[key];
});
}
this is for the case where we create a dom element, so here in the else statement:
if (!children.tag) {
//since no tag is given it's just text
parentEl.appendChild(document.createTextNode(children));
} else {
const newEl = document.createElement(children.tag);
if (children.props.style) {
Object.keys(children.props.style).forEach((key) => {
newEl.style[key] = children.props.style[key];
});
}
walkTree(children.props.children, newEl);
parentEl.appendChild(newEl);
}
react package
now that we got rendering to the screen, let's get to the real meat of react and try to implement some hooks
useState
useState is the bread and butter of UI development,"UI is a function of state".
ok what is the useState hook then?? well looking at the code for it
const [counter, setCounter] = React.useState(10);
it's just a function that takes the initial state and returns a tuple, first element is just the value for the state, second element is the function to update the state and cause a rerender.
let's try to implement that:
function useState(initialState) {
let state = initialState;
function dispatch(newState) {
state = newState;
//rerender
}
const tuple = [initialState, dispatch];
return tuple;
}
rerendering is just removing the old elements from the dom and inserting the new ones, insert part is done already in the render function now we just need to remove the dom elements and call the render function with the correct arguments and rerendering should be done.
But Taher you might say react doesn't blow up the whole app for a state update and you are correct react does handle updates better by updating only the parts that are effected by the state change, this is exciting to implement but for now I just want to build a counter not a stripe website.
so let's see how we can do it:
function rerenderApp(element, root, appElement) {
return () => {
appElement.remove();
createRoot(element).render(root);
};
}
function createRoot(element) {
return {
render: (root) => {
const newEl = document.createElement("div");
walkTree(root, newEl);
document.body.insertBefore(newEl, element);
ReactDOM.rerender = rerenderApp(element, root, newEl);
},
};
}
rerenderApp is a function that returns a function so that we can set the needed params for rendering and removing elements from the dom, parent element for our app is removed from the dom and render just repaints to the screen, easy simple and quick to implement (I'm open to better ways to handle this).
let's create a counter
function Profile() {
const [counter, setCounter] = React.useState(10);
return (
<div>
<button
onClick={() => {
const newCount = counter + 1;
setCounter(newCount);
}}
>
change counter: {counter}
</button>
</div>
);
}
looks like any counter ever written in react so let's run it
running this and clicking the change counter button won't change anything in the browser, and that's because the state is being reset every time we call the render function, so we need to keep track of the states
const stateMap = {
states: [],
calls: -1,
};
function useState(initialState) {
const callId = ++stateMap.calls;
if (stateMap.states[callId]) {
return stateMap.states[callId];
}
function dispatch(newState) {
stateMap.states[callId][0] = newState;
//rerender
stateMap.calls = -1;
ReactDOM.rerender();
}
const tuple = [initialState, dispatch];
stateMap.states[callId] = tuple;
return tuple;
}
so we are keeping track of two things:
-
states
which is an array of tuples first element is the state value second is the update state function -
calls
is just to keep track of the index of the state that we are trying to get
in first render the states array is empty so we fill it up by the tuples that we create and we keep track of them plus the callId
after updating the state we call dispatch, this will update the the specific state with the correct callId
then reset the calls number and rerender the whole app
rerendering the app will call useState again so it would const callId = ++stateMap.calls;
if we don't reset calls before rerender calls would be increasing endlessly and would just try to access a state that doesn't exist, by resetting calls
we would have access to the correct state.
let's try to rerun the counter again
nothing would happen cause I forgot to implement onClick
in react dom
if (children.props.onClick) {
newEl.addEventListener("click", children.props.onClick);
}
we add this inside the case where we handle dom elements so the walk function would like this
function walkTree(children, parentEl) {
if (!children) return;
if (Array.isArray(children)) {
children.forEach((el) => {
if (!el.tag) {
//since no tag is given it's just text
parentEl.appendChild(document.createTextNode(el));
} else {
walkTree(el, parentEl);
}
});
return;
}
if (typeof children.tag === "function") {
walkTree(children.tag(children.props), parentEl);
return;
}
if (!children.tag) {
//since no tag is given it's just text
parentEl.appendChild(document.createTextNode(children));
} else {
const newEl = document.createElement(children.tag);
if (children.props.style) {
Object.keys(children.props.style).forEach((key) => {
newEl.style[key] = children.props.style[key];
});
}
if (children.props.onClick) {
newEl.addEventListener("click", children.props.onClick);
}
walkTree(children.props.children, newEl);
parentEl.appendChild(newEl);
}
}
let's try running the counter again
and boom state is updating in the screen
useRef
useRef is just useState without the returning the dispatch function so we can reuse most of the logic
function useRef(initialState) {
const callId = ++stateMap.calls;
if (stateMap.states[callId]) {
return stateMap.states[callId];
}
stateMap.states[callId] = { current: initialState };
return stateMap.states[callId];
}
I don't create the dispatch function for useRef since we don't need it, I set the state to an object and return that, this worked for me when updating the current it would actually update the states array and not something weird, it's the difference between returning just the value or a reference to the object (that' what I think at least)
const ref = React.useRef(null);
console.log("ref", ref.current);
the ref.current value will be preserved after rerenders, but no implementation of the ref prop for dom elements yet
useEffect
this hook will be simple after implementing useState since it uses the same trick of keeping track of states (in this case dependency arrays) in an object that won't be destroyed after every rerender.
React.useEffect(() => {
console.log("this is called");
}, []);
this is a simple use case for useEffect and we can see that it takes two arguments, first one is a callback and second is the dependency array
function useEffect(cb, depArr) {
if (!depArr) throw new Error("you don't want this buddy");
const callId = ++depMap.calls;
const prevDeps = depMap.deps[callId];
if (
!prevDeps ||
depArr.length !== prevDeps.length ||
depArr.some((value, id) => {
return value !== prevDeps[id];
})
) {
cb();
depMap.deps[callId] = depArr;
}
}
you can tell that I'm a better software dev than the whole react team since I do tell you that you must specify the dep array for useEffect, I added that cause I thought it was funny that's all
we keep track of the prev dep arrays in this object
const depMap = {
deps: [],
calls: -1,
};
same way with the states tracking, we check if the prevDeps are there or not, if they are not then this is the first render and we should run the effect call back and we register the deps for the next renders, if there is any change the deps from the previous ones then we fire the call back and update the deps that we are tracking
and there we have it, functioning useEffect hook so let's use it to do the only useful thing for this hook, fetching dog images
React.useEffect(() => {
fetch("https://dog.ceo/api/breeds/image/random").then((res) =>
res.json().then((res) => setImageSrc(res.message))
);
console.log("this is called");
}, [counter]);
every time we change the counter we fetch a random dog image and put it inside the imageSrc state
<img src={imageSrc} />
we are setting the image src to the state, and update our ReactDOM logic so it sets the src
attribute for image tags
if (children.tag === "img" && children.props.src) {
newEl.src = children.props.src;
}
and there we have it every time we update the the counter we see a new dog image
let's build a todo app
the best way to see if your library works is to build something with it, so let's build a quick todo app
function Todo() {
const [todos, setTodos] = React.useState(["first todo"]);
const [inputVal, setInputVal] = React.useState("");
return (
<div>
<label title="todo title">
todo title
<input
value={inputVal}
onChange={(evt) => {
setInputVal(evt.target.value);
}}
/>
</label>
<button
onClick={() => {
if (!inputVal) return;
setTodos([...todos, inputVal]);
}}
>
add todo
</button>
<ul>
{todos.map((el) => {
return <li>{el}</li>;
})}
</ul>
</div>
);
}
we must add logic for the input tag element in ReactDOM
if (children.tag === "input") {
newEl.value = children.props.value ?? "";
newEl.oninput = children.props.onChange;
}
looks ugly but it's functional, typing into the input causes a bug, it goes out of focus on every character typed because I destroy the whole app on state change so we might have to create vdom and not rerender everything, or just use a ref for the input value
<input
onChange={(evt) => {
inputVal.current = evt.target.value;
}}
/>
and now no bugs only features, todos can be added to the list after clicking the add todo button.
this is a very bare bones implementation for the react api but it really helped me understand some of the magic that we take for granted when working with react.
here is the repo so that you can check it out
building this also gave me new found respect for anyone that worked on these libraries, they are very smart people that I hold in high regard, and this is after trying to create something as simple as this, the amount of features that are just in react core: suspense, fiber reconciler, concurrent mode, server components and the list goes on, it is actually crazy.
I would like to build more, and explore more of react but let's call it for this one.
thank you for reading this far, I hope this article has been of some value to you and any feedback is good feedback.
some resources I used for understanding react more deeply:
this is a great talk by sophie alpert
this is the implementation of hooks that I used
Dan Abramov explaining react fiber
Posted on March 17, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.