Hey JavaScript community 👋 It's been 7 months of intense thinking and tinkering, and Awai has finally reached release candidate stage.
In this tutorial, which will consist of multiple parts, I am going to show you how I came to certain ideas and how I implemented Awai state management library, which is designed to extract business logics from UI layer, handle side effects and race conditions with ease, and declaratively describe application flow using async functions.
What's the goal?
Throughout my career I've worked with many React projects and saw different approaches to state management. The worst, IMO is handling logics with React hooks, since it breaks separation of concerns principle by mixing business logics with UI layer. Another problem is that all those hooks are dependant on each other, leading to redundant re-renders and stale props, which you have to solve with useRef
, etc. I am pretty sure most of you faced these problems. But why do those problems exist at all?
I hope, after reading all the parts which I plan to write here, you will see that state management can actually be very simple.
How it works?
Fundamental part of Awai is thennable object, which plays role of event emitter. Thennable is an object which implements then
method and can be used as a Promise.
const thennable = {
awaiters: [],
then(onfulfilled) {
this.awaiters.push(onfulfilled);
},
emit(value) {
for (const resolve of awaiters) {
resolve(value);
}
}
};
As soon as it is used as a promise, then
method is called receiving resolving callback as an argument. The callback should be called when thennable resolves.
For example, if you write await thennable
, the callback resolving this await
will land in awaiters
array of our object, and it's up to us when to call it.
Same as we can await promise multiple times, I've tried to do it with custom thennable, and this is how I came to an idea of re-resolving the same thennable object multiple times.
Once classic JS promise is settled, it resolves with the settled value as soon as you await it again. With custom implementation we resolve all the awaiters, and cleanup the array, making further subscriptions to thennable possible without resolving them right away.
Now let's create a class that creates thennable re-resolvable event. It will be named AwaiEvent
to be aligned with naming inside of the library.
class AwaiEvent {
#awaiters = [];
then(onfulfilled) {
this.#awaiters.push(onfulfilled);
}
emit(value) {
const awaiters = this.#awaiters;
this.#awaiters = [];
for (const resolve of awaiters) {
resolve(value);
}
}
}
This is how thennable can resolve with different values:
const event = new AwaiEvent();
setTimeout(() => event.emit('hello'), 100);
setTimeout(() => event.emit('awai'), 200);
const value1 = await event;
const value2 = await event;
console.log(`${value1} ${value2}`); // hello awai
So exciting! We can now use this event in order to replace classical event emitters. How? Just await it in the loop 😎. You can also combine this event with other promises using Promise.race
, Promise.all
, etc.
const event = new AwaiEvent();
while(true) {
const value = await event;
console.log(event);
}
Any time you call event.emit(...)
the emitted value will be logged.
By the way, this is exactly how event listening was implemented in the early versions of Awai: GitHub commit 😄.
Right now this util is called scenario
, which has drastically evolved, and can be used in a variety of different ways. But let's not cover its functionality in this part. Nevertheless, feel free to check the docs.
How is state node implemented?
State is a very simple object, which has get
. & set
methods, and events
property. get
method returns a current value. set
method updates the value inside of the object and emits the thennable event we created above. Have a look at the basic state node implementation:
const state = (initialValue) => {
let value = initialValue;
const events = {
changed: new AwaiEvent()
};
const get = () => value;
const set = (newValue) => {
value = newValue;
events.changed.emit(newValue);
};
return { events, get, set };
};
Easy, right? Now, if we create a state node, we can react to its events, and this is a great start for a state management library.
How to connect this weirdo to React?
Let's write a very simple React hook, which listens to every emit and updates React state once our event is emitted.
const useStateValue = (stateNode) => {
const initialValue = stateNode.get();
const [value, setValue] = useState(initialValue);
useEffect(() => {
let mounted = true;
const listen = async () => {
while (mounted) {
const newValue = await stateNode.events.changed;
if (mounted) {
setValue(newValue);
}
}
};
listen();
return () => {
mounted = false;
};
}, [stateNode]);
return value;
};
Finally we can use our so far primitive state manager library with React:
const counterState = state(0);
const increment = () => {
counterState.set(counterState.get() + 1);
};
const Counter = () => {
const counter = useStateValue(counterState);
return (
<div>
<p>{counter}</p>
<button onClick={increment}>Increment</button>
</div>
);
};
Summary
I hope you enjoyed the reading. Happy to answer your questions. See you in the next parts.
Here you can find a playground with the example we have created together: CodeSandbox.
Top comments (0)