Writing a reactive library in Javascript [from scratch]
Adam Dimitry Enzo Ambrosino
Posted on April 11, 2022
Introduction
There are a lot of good libraries and frameworks to handle state management and reactivity. From simple and short utilities such as S.js to heavy solutions like Solid.
As for any lib/framework, it is important to choose wisely. To help me choose, I have decided to only use a library/framework if I can understand its source code, at least partially. Being the newbie that I am, I easily get lost even when trying to 'refactor' the code into something I should understand.
Since I have not found a good lightweight library that "Just works"™ for me, and to better learn the way such a system could be implemented, I have decided to build it myself. (Re-inventing the wheel is cool)
I wrote it in an afternoon, took a week to test it, then two weeks to make a Gitlab pipeline, and voilà. You can use Valup, which is short for "value update". It's a little, lightweight, easy, no-magical-nonsense, project.
I thought it would be nice to make an article about it to help people like me understand the process.
So, how do we do it? Here are the steps:
- Implement an event emitter system
- Implement a data wrapper
- Make the wrapper emit events
- Add quality-of-life improvements
At each step, I'll describe the interfaces and what to implement before showing a simplified (and commented) version of my code. You're free to try and implement the interfaces yourself before looking at my version.
1) Implementing an Event Emitter
What is an 'Event Emitter'? It is a system that fires a function, called 'Event Listener', when a specific 'Event' is fired. In the DOM you may already be used to such events (onclick, onblur, etc.).
An event emitter should implement the following interface or a variant.
interface EventEmitter {
public events: Map<string, Array<Function>>;
// List of events with associated listeners.
// Example: {"myevent": [f1, f2, f3]}
public addListener: (event: string, listener: Function) => void;
// Add an entry in the 'events' map at 'event' key.
public removeListener: (event: string, listener: Function) => void;
// Remove the entry from 'events' at 'event' if the exact function exists in that list.
public emit: (event: string) => void;
// Fires all listeners to 'event'.
// Example: for {"myevent": [f1, f2, f3]},
// 'emit("myevent")' will execute () => {f1();f2();f3();}
}
I took inspiration from the great github gist A very simple EventEmitter in pure Javascript.
Here's a simplified and commented version of my code.
class EventEmitter {
_events = new Map();
// Custom getter to force the property to be a map and not anything else
get events() {
if (!(this._events instanceof Map)) {
this._events = new Map();
}
return this._events;
}
// Same as 'addEventListener', I just prefer the
// "myObject.on('myEvent', () => {...})" syntax.
on(event, listener) {
let currentEvent = this.events.get(event);
// If the event doesn't exist, create it.
if (typeof currentEvent !== "object") {
currentEvent = [];
}
currentEvent.push(listener);
this.events.set(event, currentEvent);
}
removeListener(event, listener) {
// Index of listener in the array if it exists, else -1.
const index = this.events.get(event)?.indexOf(listener) ?? -1;
if (index <= -1) return; // Guard clause, exit immediately
const currentEvent = this.events.get(event);
currentEvent.splice(index, 1); // Remove listener from array
this.events.set(event, currentEvent);
}
emit(event, ...args) {
// The "...args" is for listeners arguments,
// Example:
// const logger = (a) => console.log(a);
// myObject.on('log', logger);
// myObject.emit('log', 'Hello'); // logs 'Hello'
const currentEvent = this.events.get(event);
if (typeof currentEvent !== "object") return; // Guard clause
// Fire all listeners with arguments.
currentEvent.forEach((listener) => {
listener.apply(this, args);
});
}
}
2) Implementing a data wrapper
Since our reactive system doesn't use magic, we have to tell it what data to use, what events to listen to, and how to update things. We can't simply say myObject = {foo: "bar"}
and expect it to fire event listeners on 'myObject' because we replaced it with something that doesn't implement an event listener.
Therefore, we have to write a system to read/write the data that we can control, accessors. Here's an example interface:
interface DataWrapper {
private _data: any;
public get data();
public set data(newData: any);
}
// Or with a generic type
interface DataWrapper<T> {
private _data: T;
public get data();
public set data(newData: T);
}
// Or with functions instead of accessors
interface DataWrapper<T> {
private _data: T;
public getData: () => T;
public setData: (newData: T) => void;
}
I chose to apply the first system, and call my wrapper R
, which stands for 'Reactive'. This will be the base object called everywhere, hence the single letter name.
class R {
_val = undefined;
get val() { return this._val }
set val(nval) { this._val = nval }
constructor(newValue) { this._val = newValue }
}
// Usage:
// const myObject = new R({foo: "bar"});
// console.log(myObject.val); // '{"foo":"bar"}'
// myObject.val = {foo: 'baz'}; // updated
3) Make the wrapper emit events
Our data accessor should emit and receive events, it should apply the log of an EventEmitter. One way to do it would be to make the wrapper extend
the EventEmitter, the way I chose is to encapsulate it as a property of my wrapper. We'll have façade
methods (from the design pattern of the same name) that use the EventEmitter.
Here's an example of an how to extend the previous interface:
interface DataWrapper {
//...
private _eventEmitter: EventEmitter;
public addListener: (event: string, listener: Function) => void;
public removeListener: (event: string, listener: Function) => void;
// Custom notifier, use the 'emit' function with
// any arguments you'd like.
public notify: (event: string, state: any) => void;
}
Here's how I implemented it:
class R {
//...
_eventEmitter = new EventEmitter();
// addListener
on(event, listener) {
this._eventEmitter.on(event, listener);
}
removeListener(event, listener) {
this._eventEmitter.removeListener(event, listener);
}
notify(event, state) {
this._eventEmitter.emit(event, { state });
// I chose to wrap the state in a 'state' property.
// First to ensure that we emit an object, but also because I
// want to make sure I, myself, won't forget that a listener
// is used specifically for my library.
}
}
We now have a working system! It's not much, but we can already use it.
const username = new R('ADEA');
const greeter = ({state}) => {
console.log(`Hello, ${state.name}!`)
};
username.on('greet', greeter);
username.notify('greet', {name: "ADEA"});
// "Hello, ADEA!";
Although I admit, the 'reactive' side is quite lacking. We don't have a way to know when a value changes to automatically fire the listeners.
But the implementation is actually quite easy, thanks to using accessors. We simply have to define the events we want to fire, for example changing
before the value has changed and changed
when the update has been done, then notify
them.
class R {
//...
set val(newValue) {
const oldState = {
prev: this._val,
current: this._val,
next: newValue
};
const newState = {...oldState, current: newValue};
this.notify("changing", oldState);
this._val = newValue;
this.notify("changed", newState);
}
}
Now we can use it in a reactive way:
const reactiveUsername = new R('Alice');
const greeter = ({state}) => console.log(`Hello, ${state.current}!`);
const goodbye = ({state}) => console.log(`Goodbye, ${state.current}`.);
reactiveUsername.on('changing', goodbye);
reactiveUsername.on('changed', greeter);
reactiveUsername.val = 'Bob';
// "Goodbye, Alice."
// "Hello, Bob!"
You can play with it, use custom events associated with changed/changing, try to change a DOM value reactively, etc.
Here's the final code from what we've written so far:
class EventEmitter {
_events = new Map();
get events() {
if (!(this._events instanceof Map)) {
this._events = new Map();
}
return this._events;
}
on(event, listener) {
let currentEvent = this.events.get(event);
if (typeof currentEvent !== "object") currentEvent = [];
currentEvent.push(listener);
this.events.set(event, currentEvent);
}
removeListener(event, listener) {
const index = this.events.get(event)?.indexOf(listener) ?? -1;
if (index <= -1) return;
const currentEvent = this.events.get(event);
currentEvent.splice(index, 1);
this.events.set(event, currentEvent);
}
emit(event, ...args) {
const currentEvent = this.events.get(event);
if (typeof currentEvent !== "object") return;
currentEvent.forEach((listener) => {
listener.apply(this, args);
});
}
}
class R {
_eventEmitter = new EventEmitter();
_val = undefined;
get val() { return this._val }
set val(newValue) {
const oldState = {
prev: this._val,
current: this._val,
next: newValue
};
const newState = {...oldState, current: newValue};
this.notify("changing", oldState);
this._val = newValue;
this.notify("changed", newState);
}
constructor(value) { this._val = value }
on(event, listener) {
this._eventEmitter.on(event, listener);
}
removeListener(event, listener) {
this._eventEmitter.removeListener(event, listener);
}
notify(event, state) {
this._eventEmitter.emit(event, { state });
}
}
We have successfully implemented a reactive system in less than 100 lines of code! And the code is not "that bad". Of course we have to put spaces and comments, separate the classes in different files, write some quality-of-life improvements, etc. But it works, is easy, and can be understood from the source code.
4) Adding quality-of-life improvements
This part is for you! Now that you've successfully implemented the skeleton of your library, you can change it to your liking.
I'll just enumerate a few improvements ideas without explaining how to implement them. Some of them I've written in my source code, with Typescript.
// Improvement 1:
// Have a strict value checking and non-strict one
// (nonstrict emits at every update, strict emits only when differs)
const strictVal = new R(false, {strict: true});
const nonstrictVal = new R(false, {strict: false});
strictVal.val = false; // doesn't update
strictVal.val = true; // updates
strictVal.val = true; // doesn't update
nonstrictVal.val = false; // updates
nonstrictVal.val = false; // updates
// Improvement 2:
// Use factory methods to create reactive values.
const r1 = R.data(0);
const r2 = R.strictData(0);
// Improvement 3:
// Allow for method chaining.
const counterChain1 = R.data(0)
.on('changing', () => console.log('changing'))
.on('changed', () => console.log('changed'))
.on('increment', ({state}) => { counterChain1.val = state.current + 1 })
.on('decrement', ({state}) => { counterChain1.val = state.current - 1 })
// Improvement 4: (not in Valup (yet?))
// Clean an event from all listeners
counterChain1.clean('increment')
counterChain1.cleanAll()
counterChain1.val = 1; // nothing happens
Valup's source code (gitlab)
Valup's doc page (gitlab pages)
Valup's NPM entry
Posted on April 11, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.