Home
☎️

How to invent reactive JavaScript

Web applications are full of state, and wrangling and efficiently updating all that state has more solutions than it does problems. One solution I’d like to dive into is Reactive programming.

For example, in an imperative programming setting, a:=b+ca := b + c would mean that aa is being assigned the result of b+cb + c in the instant the expression is evaluated, and later, the values of bb and cc can be changed with no effect on the value of aa. On the other hand, in reactive programming, the value of aa is automatically updated whenever the values of bb or cc change, without the program having to explicitly re-execute the statement a:=b+ca := b + c to determine the presently assigned value of aa.

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 .gets!)
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:
  1. set() article to the value returned by calling its function
  2. While calling its function, keep track of the Values that are used
  3. 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 👋