How to invent reactive JavaScript
Web applications are filled with state and effectively managing and updating that state has more solutions than problems. One solution that I would like to explore is Reactive programming.
For example, in an imperative programming setting, would mean that is being assigned the result of in the instant the expression is evaluated, and later, the values of and can be changed with no effect on the value of . On the other hand, in reactive programming, the value of is automatically updated whenever the values of or change, without the program having to explicitly re-execute the statement to determine the presently assigned value of .
You can get whispers of this in the React framework itself, but this concept is thoroughly explored in frameworks such as Svelte, SolidJS, and Vue.js, or libraries such as Jotai and Starbeam.
Let’s invent it ourselves.
🔗 Value
Before we do anything fancy, we need to establish a concept of a “value.” A Value has some state with some
get
and set
methods.
class Value {
constructor(initial) {
this.state = initial
}
get() {
return this.state
}
set(newValue) {
this.state = newValue
}
}
const clicks = new Value(0)
console.log(clicks.get()) // => 0
clicks.set(1)
console.log(clicks.get()) // => 1
Value
is nothing more than a variable at this point. Importantly, we want to add the ability to “listen” to values. When the value’s state
changes we’d like to be notified somehow.
Let’s accomplish this with a new method - addListener.
class Value {
constructor(initial) {
this.state = initial
// Maintain a list of subscribers
this.subscribers = []
}
get() {
return this.state
}
addListener(fn) {
this.subscribers.push(fn)
}
set(newValue) {
this.state = newValue
// Let our subscribers know
this.subscribers.forEach((fn) =>
fn(newValue)
)
}
}
Now instead of calling
.get()
manually, we can establish a listener on our clicks
Value. For now let’s console.log
.
export const clicks = new Value(0)
clicks.addListener((clicks) =>
console.log(
`Clicked ${clicks} ${
clicks === 1 ? "time" : "times"
}!`
)
)
clicks.set(1) // Clicked 1 time!
clicks.set(2) // Clicked 2 times!
clicks.set(100) // Clicked 100 times!
🔗 Values of values
Listening to a single Value automatically is handy, but we’d like to build our reactivity model up further. Consider an interface for writing a blog post. We’d like Value’s for a title and body, and a new piece of data representing the markup of the blog post: computed from the Values within it.
const title = new Value("")
const body = new Value("")
const article = new ComputedValue(() => {
return `
<article>
<h1>${title.get()}</h1>
${body.get()}
</article>
`
})
title.addListener((data) =>
console.log("New title:", data)
)
article.addListener((data) =>
console.log("New article:", data)
)
title.set("My cool post")
// New title: My cool post
// New article: [fancy html here]
Just as we can listen for updates on the title, we’d like to listen for updates on the html contents as well. However, we’re not going to be
set()
ing article ourselves. We want that to happen automatically.
Let’s attempt to write ComputedValue.
class ComputedValue extends Value {
constructor(fn) {
super()
// Now we have `this.state`
}
}
ComputedValue is a Value. We want to be able to
get
our computed value, and listen to its changes.
But where do we go from here? As a first attempt, we can try to define our own
get
to call the function passed in as an argument.
class ComputedValue extends Value {
constructor(fn) {
super()
this.fn = fn
}
get() {
return this.fn()
}
}
And we can attempt to
get()
the ComputedValue like so:
const title = new Value("")
const body = new Value("")
const article = new ComputedValue(() => {
return `
<article>
<h1>${title.get()}</h1>
${body.get()}
</article>
`
})
title.set("My cool post")
body.set("I'm still working on it")
console.log(article.get())
// <article>
// <h1>My cool post</h1>
// I'm still working on it
// </article>
We do indeed get a rendered article. But there’s one glaring issue…
title.addListener((data) =>
console.log("New title:", data)
)
article.addListener((data) =>
console.log("New article:", data)
)
title.set("My cool post")
// New title: My cool post
We establish two listeners: one for the title and one for the article. When setting the title, the first listener does its thing without a hitch, but the second listener is nowhere to be found.
Sure we can
get()
the article, but we want reactivity . The whole point of all of this is to make addListener
work its magic. For that we’ll need to try a bit harder.
🔗 Listen closely
Remember that a Value’s listeners are invoked whenever
set
is called:
set(newValue) {
this.state = newValue
// Let our subscribers know
this.subscribers.forEach((fn) =>
fn(newValue)
)
}
Since ComputedValue extends Value, its definition of
set
is the same. We just need to make sure we call it. But when?
There are a couple ways to approach this, but ultimately we want our ComputedValue to listen to its “dependencies.” For example, our article ComputedValue:
const article = new ComputedValue(() => {
return `
<article>
<h1>${title.get()}</h1>
${body.get()}
</article>
`
})
Has a “dependency” on
title
and body
, and should set
its value whenever title
or body
update.
Put another way, a function which sets the value of
article
to the result of this function should be added to the listeners of the title and body values.
But how do we know that article “depends” on title and body? We’re sort of just looking at it and reading through the code - but JavaScript may need some help. Let’s talk through a couple approaches.
🔗 Approach #1: An explicit list of dependencies
The conceptually simplest way to address this problem is to force the user to list out the dependencies of the ComputedValue.
class ComputedValue extends Value {
constructor(fn, dependencies) {
super()
// A function to update our value
const update = () => this.set(fn())
// Listen to each of the dependencies
dependencies.forEach((dep) => {
dep.addListener(update)
})
}
}
And to make use of it:
const title = new Value("")
const body = new Value("")
const article = new ComputedValue(() => {
return `
<article>
<h1>${title.get()}</h1>
${body.get()}
</article>
`
}, [title, body]) // explicit!
Using this ComputedValue is still the same.
// Listen to changes to our ComputedValue
article.addListener((data) =>
console.log("New article:", data)
)
title.set("My cool post")
// New article:
// <article>
// <h1>My cool post</h1>
//
// </article>
body.set("Just some cool stuff")
// New article:
// <article>
// <h1>My cool post</h1>
// Just some cool stuff
// </article>
body.set("Wait...")
// New article:
// <article>
// <h1>My cool post</h1>
// Wait...
// </article>
We’re left with some reactive values, and reactive values which depend on other values . The downside is that we need to manually specify these dependencies with an array, and we need to make sure we have them all there. Maybe a lint rule can help us, but it’s still an extra step.
🔗 Approach #2: Leaning on templates
Another option to tap into the template string we’re using for the return value of article, leading to the following API (notice the lack of
.get
s!)
const title = new Value("")
const body = new Value("")
const article = computed`
<article>
<h1>${title}</h1>
${body}
</article>
`
This magic “computed” thing denotes a tagged template, which will replace our definition of
ComputedValue
entirely. I encourage you to look at the API for tagged templates (they’re neat and can be used for all sorts of hacks!) but we’ll quickly define our function together.
Our
computed
function will define a Value, and then build the string up from the arguments passed into tagged templates (the array of strings, and the expressions invoked with ${ }
). These expressions will themselves be Values, so we’ll make sure to re-build the string when any of those change. Let’s look at the code to accomplish this.
function computed(strings, ...dependencies) {
const value = new Value(undefined)
function update() {
// Build up a return string
let result = ""
// Loop through `strings`
for (
let i = 0;
i < strings.length;
i++
) {
result += strings[i]
// Based on the tagged templates API, we'll
// have `i` strings and `i-1` dependencies.
if (i < strings.length - 1) {
result += dependencies[i].get()
}
}
value.set(result)
}
// Establish listeners on all the dependencies
dependencies.forEach((dep) => {
dep.addListener(update)
})
// Call update
update()
return value
}
And voilà! Our magic ComputedValue works just as if we passed in the dependencies explicitly.
const title = new Value("")
const body = new Value("")
const article = computed`
<article>
<h1>${title}</h1>
${body}
</article>
`
article.addListener((data) =>
console.log("New article:", data)
)
title.set("My cool post")
// New article:
// <article>
// <h1>My cool post</h1>
// </article>
body.set("Just some cool stuff")
// New article:
// <article>
// <h1>My cool post</h1>
// Just some cool stuff
// </article>
body.set("Wait...")
// New article:
// <article>
// <h1>My cool post</h1>
// Wait...
// </article>
We’ll need to put in a little extra work to make our template literal approach production-ready (what if we want to use
${ }
for things that aren’t values? We’ll call addListener
on them and break!) but the seed is there.
As a happy consequence of accessing the dependent values directly, users no longer need to think about
.get()
in their template definitions.
const summary = computed`
${title} - ${body}
`
A few open questions remain with this approach. What if we want summary to not display
body
but instead body.length
? What if we wanted to render different branches based on the value of body
? Our templating language is going to need to be smarter for real-world use.
🔗 Approach #3: Dependencies as needed
For a third and final approach to determining the dependencies of a ComputedValue, let’s revisit our original API:
const title = new Value("")
const body = new Value("")
const article = new ComputedValue(() => {
return `
<article>
<h1>${title.get()}</h1>
${body.get()}
</article>
`
})
Instead of passing in our dependencies explicitly (approach #1) or migrating to a fancy templating language (approach #2), we can create some new magic with the following:
-
set()
article to the value returned by calling its function -
While calling its function, keep track of the Values that are used
-
For any Values used (title and body), add a listener to update our ComputedValue
Our definition of ComputedValue will therefore start as follows:
class ComputedValue extends Value {
constructor(fn) {
super()
// TODO: Begin tracking the Values used
// in fn() and add listeners
this.set(fn())
// Stop tracking
}
}
We can establish a global listener.
class ComputedValue extends Value {
constructor(fn) {
super()
// We want Values that we depend on
// to use this listener
ComputedValue.GlobalListener = () =>
this.set(fn())
// Set our value
this.set(fn())
// Stop tracking
ComputedValue.GlobalListener = undefined
}
}
Next we’ll need to revisit Value. If there’s a current “global listener,” we’ll add it to our list of subscribers.
class Value {
// ...
get() {
if (ComputedValue.GlobalListener) {
this.subscribers.push(
ComputedValue.GlobalListener
)
}
return this.state
}
}
Now we have reactive ComputedValues. The results are the same as the previous approaches:
// Listen to changes to our ComputedValue
article.addListener((data) =>
console.log("New article:", data)
)
title.set("My cool post")
// New article:
// <article>
// <h1>My cool post</h1>
//
// </article>
body.set("Just some cool stuff")
// New article:
// <article>
// <h1>My cool post</h1>
// Just some cool stuff
// </article>
body.set("Wait...")
// New article:
// <article>
// <h1>My cool post</h1>
// Wait...
// </article>
But there’s one catch. Since we only track Values that appear when calling
fn()
on mount, it’s possible for Values to never have their listeners set up.
Consider a “latch” which returns a number of clicks when enabled.
const enabled = new Value(false)
const clicks = new Value(0)
const latch = new ComputedValue(() => {
if (enabled.get()) {
return clicks.get()
} else {
return "Not enabled"
}
})
latch.addListener((data) =>
console.log(`Clicks: ${data}`)
)
enabled.set(true)
// Clicks: 0
clicks.set(1)
// *crickets*
Notice how changing
enabled
kicks off an update to latch but changing clicks
does not. This is because while the definition of latch contains a reference to clicks in its source code, the get()
method of clicks is never called when the ComputedValue is set up.
enabled.get()
is false and clicks.get()
is simply never tracked.
🔗 Attempt #3a: Correctly syncing our dependencies as needed
To fix this, we’ll need to make sure that our “global listener” doesn’t just simply set the value like we currently do: it also needs to set up the listeners again.
We’ll do this using a new
sync
method, which will itself be passed as the “global listener” - responsible for setting up the dependency tracking and updating our internal state.
class ComputedValue extends Value {
constructor(fn) {
super()
// Store fn, we'll need it later
this.fn = fn
// Track our dependencies and update
this.sync()
}
sync() {
// Set up a listener to re-sync
ComputedValue.GlobalListener = () =>
this.sync()
// Set our value to what `fn` returns
this.set(this.fn())
// Clear the listener
ComputedValue.GlobalListener = undefined
}
}
The definition of
Value.get()
has not changed, but for posterity:
get() {
if (ComputedValue.GlobalListener) {
this.subscribers.push(
ComputedValue.GlobalListener
)
}
return this.state
}
Re-running our latch example shows that setting
enabled
to true correctly re-wires the listeners to also listen for clicks
, and our example code works:
const latch = new ComputedValue(() => {
if (enabled.get()) {
return clicks.get()
} else {
return "Not enabled"
}
})
latch.addListener((data) =>
console.log(`Clicks: ${data}`)
)
enabled.set(true)
// Clicks: 0
clicks.set(1)
// Clicks: 1
…until we set clicks again
clicks.set(2)
// Clicks: 2
// Clicks: 2
…and again
clicks.set(1000)
// Clicks: 1000
// Clicks: 1000
// Clicks: 1000
// Clicks: 1000
The issue lies with our definition of
get
, where we simply addListener
without checking if it’s already there.
get() {
if (ComputedValue.GlobalListener) {
// ⚠️ Too much pushing
this.subscribers.push(
ComputedValue.GlobalListener
)
}
return this.state
}
We can guard this with an
if
statement:
get() {
if (
ComputedValue.GlobalListener &&
// Don't subscribe twice
!this.subscribers.find(
(sub) =>
sub ===
ComputedValue.GlobalListener
)
) {
this.subscribers.push(
ComputedValue.GlobalListener
)
}
return this.state
}
… but unfortunately we’re creating a new function every time for the GlobalListener (via
() => this.sync()
). Instead, we’ll need to make sure the global listener is the same every time we call sync, so we can detect if it’s already set.
We can do this by making
this.sync
bound in the constructor:
constructor(fn) {
super()
// Store fn, we'll need it later
this.fn = fn
// Maintain a stable version of `sync`
// like it's 2014
this.sync = this.sync.bind(this)
// Track our dependencies and update
this.sync()
}
And passing
this.sync
- whose value will no longer change according to ===
- in as the GlobalListener:
sync() {
// Set up a listener to re-sync
ComputedValue.GlobalListener = this.sync
// Set our value to what `fn` returns
this.set(this.fn())
// Clear the listener
ComputedValue.GlobalListener = undefined
}
We now arrive at a fully-functioning reactive Computed Store capable of tracking its own dependencies by usage.
const enabled = new Value(false)
const clicks = new Value(0)
const latch = new ComputedValue(() => {
if (enabled.get()) {
return clicks.get()
} else {
return "Not enabled"
}
})
latch.addListener((data) =>
console.log(`Clicks: ${data}`)
)
enabled.set(true)
// Clicks: 0
clicks.set(1)
// Clicks: 1
clicks.set(2)
// Clicks: 2
clicks.set(1000)
// Clicks: 1000
🔗 Wrapping up
This article aims to be a gentle introduction to reactive programming, some of the approaches we can take to implement it in the JavaScript language, as well as some insight into API design and trade-offs.
I hope you explore these concepts further to refine the ergonomics and performance of some of these approaches, or at the very least develop an appreciation for these patterns when you come across them in every day frontend development.
The source code for our three approaches can be found on GitHub. Thanks for reading