The core of any JavaScript framework is the state, which plays a vital role in web application development. The state determines what to render on the screen and when to render. This article explores the implementation of a state similar to React in vanilla JavaScript and its integration with the JSX template engine.
If you are not familiar with the JSX template engine, you can check out my article, "How to create JSX template engine from scratch"
How to create JSX template engine from scratch
Rahul Sharma ・ Nov 23 '22
What is state?
The state is a JavaScript object that holds application data. Any change in the state updates the application's UI. This article focuses on creating a signal state object responsible for holding application data, with state changes triggering UI updates.
The hooks pattern, specifically useState and useEffect, will be followed to implement a React-like state.
useState: Creates the state, taking the initial state as an argument and returning the current state along with the function to update it.
useEffect: Tracks state changes, executing a callback function whenever the state changes.
Let's Understand this with a counter-example.
import { useState, useEffect } from "react";
const Counter = () => {
const [count, setCount] = useState(0);
useEffect(() => {
console.log("count changed");
}, [count]);
return (
<div>
<h1>{count}</h1>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
};
In the example above, a counter component was created to increase the count when the button is clicked. The state was created using useState, and changes in the state are monitored using useEffect.
The objective is to replace useState and useEffect with custom hooks.
For creating the state, will make use of the Javascript Proxy API.
What is Proxy API?
The Proxy object allows you to create an object that can be used in place of the original object, but which may redefine fundamental Object operations like getting, setting, and defining properties. Proxy objects are commonly used to log property accesses, validate, format, or sanitize inputs, and so on.
To understand the Proxy API better, consider the following example:
const checkout = {
price: 100,
quantity: 1,
total: 100
};
// Change quantity
checkout.quantity = 2;
console.log(checkout.total); // 100
In this example, a checkout object has been created, encapsulating attributes such as price, quantity, and total. Notably, adjusting the quantity to 2 does not affect the total, resulting from the necessity to manually update the total for each change in quantity or price.
To mitigate this issue, the Proxy pattern can be employed. The Proxy accepts two parameters: the object intended for proxying and the handler object. The handler object incorporates methods such as get and set, which are invoked during operations on the object.
const checkout = {
price: 100,
quantity: 1,
total: 100
};
const handler = {
get: function(target, key) {
if (key === 'total') {
return target.price * target.quantity;
}
return target[key];
},
};
const checkoutProxy = new Proxy(checkout, handler);
checkoutProxy.quantity = 3;
console.log(checkoutProxy.total); // 300
In the provided example, accessing the total property of the checkout object triggers the get method of the handler object. The get method operates with two arguments, namely the target object and the accessed key, verifying whether the key is 'total.' If true, it yields the result of the multiplication of price and quantity; otherwise, it returns the value associated with the key in the target object.
Utilizing the set method allows for updating the total in response to changes in either price or quantity. The implementation approach is adaptable, with a preference for exclusively employing the get method for state management, as exemplified in the aforementioned instance.
Let's see how to utilize the Proxy API to create the state.
To begin, create a new file to centralize all the state-related code. I've named the file state.js.
// Observe the changes in the state
let targetFunc;
class Observer {
constructor() {
this.subs = new Set();
}
add() {
targetFunc && this.subs.add(targetFunc);
}
notify() {
this.subs.forEach((sub) => sub && sub());
}
}
// Helper functions
const isFunction = (target) => typeof target === 'function';
const isObject = (target) => typeof target === 'object' && target !== null;
const clone = (acc, target) => {
if (isObject(acc)) {
Object.keys(acc).forEach((key) => {
if (isObject(acc[key])) target[key] = clone(acc[key], target[key]);
else target[key] = acc[key];
});
} else {
target = acc;
}
return target;
};
// Hooks
const setter = (prx, dep) => (data) => {
const result = isFunction(data) ? data(prx.data) : data;
if (isObject(result)) clone(result, prx.data);
else prx.data = result;
dep.notify();
};
const createOptions = (dep) => ({
get(target, key) {
dep.add();
if (isObject(target[key]))
return new Proxy(target[key], createOptions(dep));
return target[key];
},
});
// Public functions
export const useState = (data) => {
const dep = new Observer();
const prx = new Proxy({ data }, createOptions(dep));
return [() => prx.data, setter(prx, dep)];
};
export const useEffect = (fun) => {
targetFunc = fun;
targetFunc();
targetFunc = null;
};
Let's dive into the different sections:
- Observer Class
- Helper functions
- Private functions
- Hooks
1. Observer Class
In the provided code, a class named Observer has been established. This class serves the purpose of monitoring alterations in the state. It encompasses two methods, namely, add and notify.
- add: This method is used to add the function to the subscribers set.
- notify: This method is triggered when the state changes. It calls all the functions in the subscribers set.
2. Helper functions
- isFunction: Checks if the target is a function.
- isObject: Checks if the target is an object.
- clone: Clones data from the source to the target, but only if the data is an object.
3. Private functions
setter: This function serves to modify the state. It takes a proxy object and an observer object as inputs. The function it returns is responsible for updating the state. It checks whether the data is a function; if so, it calls the function with the current state as an argument. Otherwise, it updates the state with the provided data. Following the state update, it triggers the notify method of the observer object.
createOptions: This function generates the options object for the proxy. It takes the observer object as an input and returns the options object. This options object contains a get method, activated each time the state is accessed. It adds the function to the subscriber's set and returns the value associated with the key from the state. If the key's value is an object, it creates a new proxy object for that object and returns it.
4. Hooks
useState: This function is utilized for state creation. It takes the initial state as an argument and returns the current state along with the function to update the state. It initiates a new observer object and a new proxy object. The function returned is responsible for providing the current state, while the setter function is used to update the state.
Note:** The function returning the current state is intentional; it enables tracking changes in the state. Returning the state directly would hinder our ability to monitor state alterations.useEffect: This function monitors changes in the state. It takes a callback function as an argument and is triggered whenever the state undergoes a change. It sets the callback function as the targetFunc and executes it. Afterwards, it sets the targetFunc to null.
Note:** A dependency array is unnecessary because useEffect inherently monitors changes in the state used within the callback function.
Time to add our custom hooks. Let's update the counter component with our custom hooks.
import { useState, useEffect } from "./state";
const Counter = () => {
const [count, setCount] = useState(0);
useEffect(() => {
console.log("count changed");
});
return (
<div>
<h1>{count()}</h1>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
};
Next, connect the state.js file to the JSX runtime.
import { useEffect } from './state';
const appendChild = (parent, child, j = 0) => {
if (Array.isArray(child)) {
child.forEach((nestedChild, i) => appendChild(parent, nestedChild, i));
} else {
if (!parent.childNodes[j]) {
parent.appendChild(
child.nodeType ? child : document.createTextNode(child)
);
} else if (child !== parent.childNodes[j].data) {
parent.childNodes[j].data = child;
}
}
};
export const jsx = (tag, props) => {
const { children } = props;
if (typeof tag === 'function') return tag(props);
const element = document.createElement(tag);
Object.entries(props || {}).forEach(([name, value]) => {
if (name.startsWith('on') && name.toLowerCase() in window)
element.addEventListener(name.toLowerCase().substr(2), value);
else element.setAttribute(name, value);
});
// Updated Here
useEffect(() => {
const list = Array.isArray(children) ? children : [children];
const res = list.map((child) => {
const value = typeof child === 'function' ? child() : child;
return value;
});
appendChild(element, res);
});
return element;
};
export const jsxs = jsx;
The only modification is the inclusion of the useEffect function in the jsx runtime. It activates the appendChild function with the updated state whenever there is a state change.
Note: Whenever the state is updated, it doesn't re-render the entire component. It only re-renders the part of the component linked to the state. This is due to our use of the Proxy API for state creation, which only re-renders the component when the state is accessed. If the state is not accessed, the component won't undergo a re-render.
Thanks for reading. I hope you enjoyed it. If you have any questions, please leave them in the comments section below. I'll be happy to answer them.
Demo: https://stackblitz.com/edit/stackblitz-starters-byy5vn
Thanks for reading! I hope you enjoyed this article. Feel free to share your thoughts in the comments below.
Must Read If you haven't
Maximizing Performance: How to Memoize Async Functions in JavaScript
Rahul Sharma ・ Oct 20 '23
What, Why and How Javascript Static Initialization Blocks?
Rahul Sharma ・ Jan 20 '23
Simplify JavaScript's Async Concepts with One GIF
Rahul Sharma ・ Nov 2 '23
More content at Dev.to.
Catch me on
Youtube Github LinkedIn Medium Stackblitz Hashnode HackerNoon
Top comments (2)
I want to use Rxjs in your jsx-template-with-state:
it works but I have to run (when useEffect has no function it does not work):
useEffect( () => console.log(timer())
why is this (not working when useEffect not runs a function)
Otherwise: Congratulations for your clever idea!
hans.schenker@windowslive.com
useState in Typescript:
const appendChild = (parent: HTMLElement, child: string | HTMLElement, j = 0) => {
if (Array.isArray(child)) {
child.forEach((nestedChild, i) => appendChild(parent, nestedChild, i));
} else {
if (!parent.childNodes[j]) {
parent.appendChild(
child.nodeType ? child : document.createTextNode(child)
);
} else if (child !== parent.childNodes[j].data) {
parent.childNodes[j].data = child;
}
}
};
export const jsx = (tag: string | Function, props: any) => {
const { children } = props;
if (typeof tag === 'function') return tag(props);
const element = document.createElement(tag);
Object.entries(props || {}).forEach(([name, value]) => {
if (name.startsWith('on') && name.toLowerCase() in window)
element.addEventListener(name.toLowerCase().substr(2), value);
else element.setAttribute(name, value);
});
useEffect(() => {
const list = Array.isArray(children) ? children : [children];
const res = list.map((child) => {
const value = typeof child === 'function' ? child() : child;
return value;
});
appendChild(element, res);
});
return element;
};
export const jsxs = jsx;
useEffect in Typescript:
import { useState, useEffect } from "./state";
const appendChild = (parent: HTMLElement, child: string | HTMLElement, j = 0) => {
if (Array.isArray(child)) {
child.forEach((nestedChild, i) => appendChild(parent, nestedChild, i));
} else {
if (!parent.childNodes[j]) {
parent.appendChild(
child.nodeType ? child : document.createTextNode(child)
);
} else if (child !== parent.childNodes[j].data) {
parent.childNodes[j].data = child;
}
}
};
export const jsx = (tag: string | Function, props: any) => {
const { children } = props;
if (typeof tag === 'function') return tag(props);
const element = document.createElement(tag);
Object.entries(props || {}).forEach(([name, value]) => {
if (name.startsWith('on') && name.toLowerCase() in window)
element.addEventListener(name.toLowerCase().substr(2), value);
else element.setAttribute(name, value);
});
useEffect(() => {
const list = Array.isArray(children) ? children : [children];
const res = list.map((child) => {
const value = typeof child === 'function' ? child() : child;
return value;
});
appendChild(element, res);
});
return element;
};
export const jsxs = jsx;