VueNuxt.dev
Deep Dives

Javascript Reactivity

Why we need it and how it has evolved
Return to Insight

Intro

The complexity of modern web apps has greatly outgrown the humble architecture that Javascript was initially designed for. This has led to a fascinating journey of experimentation and evolution toward finding the best way to combine the ever-increasing demands of modern web apps with Javascript's limitations.

In this deep dive, I will explore in detail some of our language's underlying challenges and some of the best design patterns that emerged to address them. I will start from the very basics and move through crucial milestones that paved the way to where we find ourselves today with reactivity.


Execution Order

Let's use the following example to get a taste of where our problems begin:

var count = 0
var double = count * 2

count = 5

console.log(count, double) // 5 0

The following code would output 5 in the first part of our log because that's the value of count when printing to the console. However, the second part would be 0 because we do the reassignment of count = 5 after we declare and assign double, which at that time was count = 0 * 2 = 0. This leads us to our first problem: our variable's value are affected by the execution order in our code.

This happens because Javascript, a scripting language, executes its code sequentially from top-to-bottom.

In old-school Javascript, we used to define variables with var, as in our example. However, given our initial problem, Javascript introduced two new variable keywords into its syntax to improve this ambiguity: const and let.

In our example, count is a let because we do reassign its value further down. double, on the other hand, would be better defined as const because nothing changes its value in our code.

let count = 0
const double = count * 2

count = 5

console.log(count, double) // 5 0
Open Playground

This variable definition improvement can be subtle yet powerful because it communicates the intended use of the variables and gives us clear implications to consider as we read through our code.


Functions

We can solve this sequential execution issue by converting double into a function and invoking it whenever necessary, ensuring the latest value.

let count = 0
const double = () => count * 2

count = 5

console.log(count, double()) // 5 10
Open Playground

The output of the console log for double is now 10 because we evaluate the value of count when we run the function. Since the reassignment of count happens before we call double(), count = 5 * 2 = 10.


Variable Types

In Javascript, we have two main variable types, each handled quite differently by our programming language. So, let's review the key difference between the primitive and reference types.

Primitive types are always affected by the execution order in our code.

In our example, count is a primitive type defined as let.

However, we converted double from a primitive type const to a function, a reference type. This solved our stale value issue. But why did it solve it?

Reference types only store pointers to memory, or in other words, they refer to memory locations of values rather than the actual values themselves.

This difference is key to understanding why it breaks us out of the limitation of sequential execution affecting primitive types. We only read what is stored at those memory locations when we invoke the function.


Coupling

We are making progress, but our logic now has a code smell. Looking closely, you will see that we have coupled count into double, which makes our code fragile and hard to maintain.

This is an anti-pattern because if we moved our double function somewhere else, it would break since count might no longer be defined, forcing us to take something external with it everywhere.

To improve this, we can receive the value of count as an argument in the double function, making it pure, which is a better practice. By doing so, we also make our double function generic. It has nothing to do with count; it just doubles any number we pass in.

let count = 0
const double = (number) => number * 2

count = 5
console.log(count, double(count)) // 5 10

After our changes, our code looks cleaner:

// we went from this
const double = () => count * 2
// to this
const double = (number) => number * 2

I like it when code design improves the naming of our logic. It is a clear sign for me that we are on the right track, design-wise.


Dependencies

Our code looks better, but it is far from perfect because we introduced a manual and repetitive dependency.

Every time we update the value of count, we need to remember to run double as well.

let count = 0
const double = (number) => number * 2

count = 5
console.log(count, double(count)) // 5 10

count = 10
console.log(count, double(count)) // 10 20

This approach is tedious, prone to side effects, and completely manual. The ideal solution would automate this dependency through some mechanism so that whenever there is an update to count, we can automatically run double.

For me, this is the beginning of reactivity. We needed a way to react to updates by automatically running dependent logic.

Before tackling this problem, we must dive into some technical fundamentals of the Javascript language.


Block Scope

Let's dive into the key concept of block scope. In a nutshell, block scope means we have a nesting of scripts in our execution order. Let me explain further:

let count = 0
count = 5

In the code above, count is in the root scope. If we add a function double, we create a new block scope whcih consists of count * 2 for this function like so:

function double(count) {
  count * 2
}

You can think of double as a mini script or an independent script nested inside our root script. While parsing the code, JS will not execute any of its functions or define the values of variables inside. It will register the variable's name and go through everything inside it to assign all the pointers in memory.

This concept was already proved in our first problem when we converted double from a var to a function

let count = 0

function double(count) {
  count * 2
}

count = 5

We can get away with declaring count inside the double function even though we have declared count at the root level precisely because each declaration happens in a different scope (root and function).

Arrow functions also create a new scope:

const double1 = () => count * 2

Suppose JS cannot find a variable's local declaration (current scope). In that case, JS will recursively look at the parent scope until reaching the root to find a matching declaration before throwing an exception.

JS will always use the variable closest to the current scope.

let count = 0

const double1 = () => count * 2

function double2(count) {
  count * 2
}

In our double1 example, we do not define any count variables within its scope, so it will go up one level and find let count and use that. double2 declares count as an argument in its scope, so it will use this in favor of anything declared at higher levels.

In Javascript, you can identify the boundaries of a block scope through {} curly braces or () => in the case of arrow functions, a shorthand syntax.


State

Block scope is a critical concept to understand because it enables us to keep things around in memory, a process also known as state.

Let's continue with the following function, which returns a stateful object, which is a reference type, like this:

function withState(value) {
  return {
    value: () => value,
    set: (newValue) => value = newValue,
  }
}

Let's break this down. We have a let variable named value, which can be set as an argument for the function withState to initialize it. We can assign the invocation of this function to a const variable with an initial value of 0, like this:

const count = withState(0)

We can not access the value of count directly because it is no longer a primitive type. It is now an instance of our withState function, which returns a reference type. This means that to access its value, we can use count.value(). If we want to change its value, we can do so with count.set() and pass any number we want as an argument.

const count = withState(0)

// read current value
console.log(count.value()) // 0
// set new value
count.set(5)
// print new value
console.log(count.value()) // 5
Open Playground

In some programming languages, the local variables within a function exist for only the duration of that function's execution.

This is not obviously the case with Javascript because we can still get a correct value with count.value() after the function withState() finishes executing.


Closures

The fact that we can keep state is possible because functions in Javascript form closures, another weird yet key concept to understand.

A closure combines a function and the lexical (surrounding) environment within which that function was declared.

When we assign withState to our count const, we form a closure that includes our value variable declared inside our function, along with the methods we are nesting with the return object of our function. Javascript will keep all this in memory until we drop count from our scope.

const count

let value

const object = {
  value: () => value
  set: (newValue) => value = newValue
}

In other words, the lexical environment of count includes itself, a variable value, and an object with two functions: value() and set(). All this is kept in memory, as a state, for as long as count is in-scope.


Encapsulation

Knowing all these things, we can now return to our dependency problem and automate the link between count and double.

Consider this function:

function withState(value) {
  let double = 0
  return {
    value: () => value,
    double: () => double,
    set: (newValue) => {
      value = newValue
      double = value * 2
    }
  }
}

We can use it like this:

const count = withState(0)
console.log(count.value(), count.double()) // 0 0

count.set(5)
console.log(count.value(), count.double()) // 5 10
Open Playground

The above example demonstrates encapsulation in practice. We have abstracted the mechanism we use to automate our count and double dependency into a single place, ensuring we can run logic to react to state updates.

By encapsulating, we have accomplished three things for us:

  • it solves the order of execution issue
  • keeps everything under the same scope
  • and automates the dependency between our logic

Object Oriented

Our withState example is starting to look like a class. Generally, this is what OOP object-oriented programming attempts to solve: creating a new scope or instance in memory to keep state and having a single place to colocate dependencies between state and logic so they can run together in-scope.

In Javascript, classes don't really exist. We have the class keyword, which is syntactic sugar. Under the hood, they convert to something similar to our example above (mainly thanks to closures).

In the previous section, we added our double logic into our withState function. This automated our dependency side-effect; however, we achieved this by hard-coding logic, which, as you guessed it, introduces a fresh set of issues.

Let's consider that our feature needs a new triple method. The naive approach would be to add a new state variable, a new getter method, and wire the new mutation logic to the set method of our withState function like so:

function withState(value) {
  let double = 0
  let triple = 0
  return {
    value: () => value,
    double: () => double,
    triple: () => triple,
    set: (newValue) => {
      value = newValue
      double = value * 2
      triple = value * 3
    }
  }
}

This change seems innocent, but you can also quickly see how it abstracts a lot of hard-coded logic into a single scope, leading to bloated modules that break the single responsibility principle. Most importantly, if we want to share this instance/scope between modules, we are forced to use the singleton pattern, which is not the most memory efficient and prone to unintended mutations.


Effects

It would be much nicer to be able to send logic into our withState function, which we can run automatically after each state change. This would distance ourselves from the problems with OOP and open the door to all the benefits of the functional paradigm.

However, if we make the shift from pulling state from within scope to pushing logic into the scope of our state, we need a new name to better communicate this: effect.

An effect signals a functional mechanism that lets us take in any logic to execute it as an effect caused by a change in state.

We must start by renaming our withState function to withEffect. Then, we will add a second argument to our set method to receive a function through it. Finally, we will run this function argument with the updated value of our state.

function withEffect(value) {
  return {
    value: () => value,
    set: (newValue, effect) => {
      value = newValue
      effect(value)
    },
  }
}

Great, instead of hardcoding all the stuff inside our withState function, we can define it dynamically from the outside. This new argument of our set method receives any function with the logic we want to run after each update.

Now, we can use it like this:

const count = withEffect(0)
let doubled = 0
let tripled = 0


count.set(1, (count) => {
  doubled = count * 2
  tripled = count * 3
})

console.log(count.value(), doubled, tripled)
// 1 2 3

This approach is much better. We can mutate our count with the set method to 1 and then pass an anonymous function that receives the new count value. We set the values of doubled to 2 and tripled to 3 from the root scope. All while not encapsulating any logic inside our withState function or abstracting the visibility of the effects of our logic. We have improved our code so much that we can now extract our factor logic into a new business logic module, making testing it a breeze:

import { double, triple } from './business-logic'

const count = withEffect(0)
let doubled = 0
let tripled = 0

count.set(1, (count) => {
  doubled = double(count)
  tripled = triple(count)
})

console.log(count.value(), doubled, tripled)
// 1 2 3
Open Playground

This shift might sound like a trivial improvement, but we have made significant strides in cleaning up our design, which can be proved by us being able to test all parts of our code:

  • We can test the business logic independently. (unit-test)
  • We can test the withEffect function without any dependencies or cross-concerns. (integration-test)
  • We can test the whole integration logic in feature.js (component-test)

Lastly, let's not overlook the significant memory optimization this shift would have in our app's performance. Instead of maintaining a bloated singleton class in memory, we only keep our state and a minimal interface that takes outside logic into scope.

Most importantly, this memory benefit would compound with frameworks like Vue because we would clear the lexical environment of our state when we unmount or destroy a component's instance. Letting Javascript's garbage collector do the heavy work for us.


Subscriptions

At this point, you might think we are done. Sadly, we are not. We have another problem to tackle. Our effect mechanism is singular. Only a one-to-one relationship between our state and the outside effect logic can exist. In other words, we can only handle one effect function per state. This singularity falls short of the real-world demands of modern web apps. We need to improve our solution to a one-to-many relationship model. To accomplish this, we will need another shift, which means a new name: subscriptions.

A subscription is a decentralized mechanism that lets us take in multiple logic blocks that execute after a change in state, resulting in multiple side effects to other parts of our code.

Let's begin by changing the name of our function to withEffects, plural, to signal our code design improvement.

Then, let's do some cleanup. We must remove the second argument effect in our set method and remove the function call in the body: effect(value). Also, let's convert our arrow function to standard function declarations for consistency

function withEffects(value) {
  return {
    value() {
      return value
    },
    set(newValue) {
      value = newValue
    },
  }
}

Now, we can add the subscribe method and the subscriber set.

function withEffects(value) {
  const subscribers = new Set()

  return {
    value() {
      return value
    },
    set(newValue) {
      value = newValue
    },
    subcribe(fn) {
      subscribers.add(fn)
      return () => subscribers.remove(fn)
    }
  }
}

We decoupled the effect logic from the state setter to allow multiple effects for each state change. We can now take in effect logic multiple times using the subscribe method, which just adds it to our subscriber set. To unsubscribe automatically, we set its return to an arrow function that removes the effect logic from our set. Beautiful!

By using a set to handle our subscriber logic, we ensure we only have unique values in our effect logic. This design choice eliminates the possibility of duplicate logic runs, which are complex and painful to debug.

We need to create a private _update method to wire the state change to our effect logic set. Run this._updated after updating our state in the set method.

We need to append the _update call with this because we define this function in the return object's block scope.

function withEffects(value) {
  const subscribers = new Set()

  return {
    value() {
      return value
    },
    set(newValue) {
      value = newValue
      this._update(value)
    },
    subcribe(fn) {
      subscribers.add(fn)
      return () => subscribers.remove(fn)
    },
    _update(value) {
      subscribers.forEach((fn) => fn(value))
    }
  }
}

This update method would run a forEach loop, passing the updated value to each function in our subscriber set.

Our new code design looks like this:

import { double, triple } from './business-logic'

const count = withEffects(0)
let doubled = 0
let tripled = 0

count.subscribe((count) => {
  doubled = double(count)
})

count.subscribe((count) => {
  tripled = triple(count)
})

count.set(1)

console.log(count.value(), doubled, tripled)
// 1 2 3
Open Playground

All improvements enable us to no longer mix the concerns of double and triple into a single-effect function. We can now also subscribe from multiple parts of our code or even across files, components, and modules, all while keeping the memory costs low and our interfaces dumb, which is always a-o-k in my view.


DevEx

We are still not done, though. We have covered all the functional requirements, but we can still improve a very important non-functional one: devex. The developer experience of using our code design is not the best because we need to manually invoke nested functions for getting and setting the state of our withEffects instance, which is not the most natural for this data structure.

const count = withEffects(0)
// get
count.value()
// set
count.set(5)

We could improve things significantly by keeping our effects mechanism (running dependent logic after each update) intact while simplifying the get and set behavior to look like that of a primitive type.

count.value // instead of count.value()
count.value = 5 // instead of count.set(5)

We can achieve this by modifying our return object a bit. We can prepend our value property with get and set keywords, which JS does under the hood with primitive types anyway.

So, let's refactor. We can prepend our value() function with the get keyword. Then, we can add value (the name of our property) after set (the old name of our method) to turn it into a keyword of our property.

withEffects.js
function withEffects(value) {
  const subscribers = new Set()

  return {
    get value() {
      return value
    },
    set value(newValue) {
      value = newValue
      this._update(value)
    },
    subscribe(fn) {
      subscribers.add(fn)
      return () => subscribers.remove(fn)
    },
    _update(value) {
      subscribers.forEach((fn) => fn(value))
    }
  }
}

This refactor improves the devex significantly because we can read its value without invoking a function and set it with the natural assignment operator of =. All while retaining the subscribe method to send logic into our state's scope.

const count = withEffects(0)

console.log(count.value) // 0

count.subscribe((count) => console.log(count))  

count.value = 1 // 1
Open Playground
Return to Insight

Copyright © 2025