Duck Who Codes's logo

Duck Who Codes

Exploring Reactivity: Part 2


In Part 1, we took a brief look at how reactivity came to our frontend world, the different types of reactivity models, and how they improve upon writing plain JavaScript. In that post, what particularly sparked my curiosity was the concept of signal-based frameworks and their ability to track dependencies automatically. I’ve always wondered how this feature is implemented.

In this post, I’ll try building a signal-based reactivity system from scratch. Let’s see what we’ll learn at the end of the journey.

TC39 Signals proposal

This proposal was briefly mentioned in the footnotes of Part 1. It’s a collaborative effort from multiple framework authors/maintainers to align the Signals concept to the JavaScript language. I highly encourage you to take a look at it. In this post, I will use it to guide my implementation.

If we scroll down to the Design goals -> Core features section, we can identify 3 primitives for a signal-based reactivity model:

  1. A writable signal type for holding state.
  2. A computed signal type whose value depends on other signals and is lazily calculated and cached.
  3. A way to react to changes in the two signal types above.

Let’s write code to build each of them.

Building Signals

signal

First up, the fundamental unit: a writable signal. I’ll call the function to create it, simply, signal. At its core, it just needs to hold a value and provide ways to get and set it.

/**
 * Just a container that holds a value.
 */
const signal = <T>(value: T) => {
  return {
    get() {
      return value
    },
    set(newValue: T) {
      value = newValue
    },
  }
}

// Create a signal.
const foo = signal('bar')

// Read from a signal.
foo.get()

// Write to a signal.
foo.set('baz')

Okay, that works, but it’s not reactive yet. Changing foo doesn’t automatically trigger anything else. We need the next piece.

effect

A signal isn’t very useful if nothing can react when its value changes. We need a way to run some code (a “side effect”) whenever a signal it depends on is updated via set. Let’s call this function effect.

/**
 * Runs the callback immediately and reruns it
 * whenever any signal read inside the callback changes.
 */
const effect = (callback: () => void) => {
  // TODO
}

// What we expect:
const count = signal(0)

effect(() => {
  console.log("Count is:", count.get())
}) // Output: Count is: 0

count.set(1) // Output: Count is: 1
count.set(2) // Output: Count is: 2
count.set(2) // No output, value didn't change

Here’s the crux: How does an effect know which signals it uses? And how does a signal know which effects to notify when its set method is called?

This leads us to the core idea of automatic dependency tracking. When an effect runs its callback, any signal.get() called inside that callback should somehow register the callback as a dependent. Then, when signal.set() is called, the signal can notify all its registered dependents.

To manage this, we need a way to know which effect is currently running. A common pattern is to use a global stack (let’s call it EffectStack). Before running an effect’s callback, we push it onto the stack. Inside signal.get(), we peek at the top of the stack – if there’s an effect there, we know that effect depends on this signal, so we add the effect to the signal’s internal list of dependents. After the effect’s callback finishes, we pop it off the stack.

Let’s refactor signal and implement effect with this tracking mechanism:

// --- EffectStack: Manages the currently running effect ---
/**
 * A simple, singleton stack to keep track of the active effect.
 */
class EffectStack {
  private static instance: EffectStack
  private stack: (() => void)[] = []

  static getInstance(): EffectStack {
    if (!EffectStack.instance) {
      EffectStack.instance = new EffectStack()
    }
    return EffectStack.instance
  }

  push(effect: () => void) { this.stack.push(effect) }
  pop() { return this.stack.pop() }
  peek() { return this.stack[this.stack.length - 1] }
}

// --- Updated `signal` ---
const signal = <T>(value: T) => {
  const dependents = new Set<() => void>() // Store effects here

  return {
    get() {
      // Automatic dependency tracking:
      // 1. Check if there's an active effect running.
      const currentEffect = EffectStack.getInstance().peek()
      if (currentEffect) {
        // 2. If yes, add this effect to our dependents.
        //    Now, this signal knows the effect cares about its value.
        dependents.add(currentEffect)
      }
      return value
    },
    set(newValue: T) {
      // Avoid triggering effects if the value hasn't actually changed.
      if (Object.is(value, newValue)) {
        return
      }
      value = newValue
      // Notify all dependent effects that the value has changed.
      // Use [...dependents] to iterate over a snapshot, avoiding issues
      // if an effect modifies the dependent set during execution.
      ;[...dependents].forEach((effect) => effect())
    },
  }
}

// --- `effect` implementation ---
const effect = (callback: () => void) => {
  const effectWrapper = () => {
    const stack = EffectStack.getInstance()
    stack.push(effectWrapper) // Push this wrapper onto the stack
    try {
      callback() // Run the actual user code
    } finally {
      stack.pop() // Always pop, even if callback throws error
    }
  }
  // Run the effect immediately to establish initial dependencies
  effectWrapper()
}

// --- Let's test reactivity! ---
const count = signal(0)

effect(() => {
  console.log("Count is:", count.get())
}) // Output: Count is: 0

count.set(1) // Output: Count is: 1
count.set(2) // Output: Count is: 2
count.set(2) // No output, value didn't change

When we change count, our console.log inside the effect automatically reruns. We’ve got basic reactivity working!

computed

Now for the arguably more complex, but powerful, primitive: computed signals. Remember in Part 1 how we appreciated that Vue’s computed (or React’s useMemo with dependencies) avoids unnecessary work? That’s what we want here.

A computed signal:

This means a computed needs to:

Let’s try building it:

/**
 * A signal whose value is derived from other signals.
 * It's lazy, cached, and reactive.
 */
const computed = <T>(calculation: () => T) => {
  // It needs its own value and dependency tracking, like a signal.
  let cached: T
  const dependents = new Set<() => void>()
  let isStale = true // Start as stale, needs calculation on first get

  const markStale = () => {
    isStale = true
    // Notify our dependents that we might have changed.
    ;[...dependents].forEach((effect) => effect())
  }

  const trackAndCalculate = () => {
    const stack = EffectStack.getInstance()
    stack.push(markStale) // Signals inside calculation will track `markStale`
    try {
      cached = calculation() // Perform the actual calculation
    } finally {
      stack.pop()
    }
    isStale = false // Value is now fresh
  }

  return {
    get() {
      // Like `signal.get()`, track who depends on this computed.
      const currentEffect = EffectStack.getInstance().peek()
      if (currentEffect) {
        dependents.add(currentEffect)
      }

      // If stale, recalculate before returning.
      if (isStale) {
        trackAndCalculate()
      }
      return cached
    },
    // Note: No `set` method for computed signals!
  }
}

// --- Testing `computed` ---
const firstName = signal('Duc')
const lastName = signal('Nguyen')

const fullName = computed(() => {
  console.log('Calculating full name...') // To see when it runs
  return `${firstName.get()} ${lastName.get()}`
}) // No output, `fullName` hasn't been accessed.

fullName.get()
// Output: Calculating full name...

fullName.get()
// No output, calculation is cached.

lastName.set('Tran')
// No output, calculation is lazy.

fullName.get()
// Output: Calculating full name...

There we go, we have our 3 core primitives: signal, effect, and computed. Our reactivity model is technically complete, but, can we do more?

Rendering to the DOM: Putting it together

With the primitives we’ve built, how do we connect this to updating a user interface in the browser?

Remember the model from Part 1: view = render(state). Rendering the UI is fundamentally a side effect of our application’s state. So, if we have a function that generates the HTML and writes it to a DOM element, we can just wrap its execution in an effect:

// Execute the component to set up its signals and effects.
// Get back a function that returns the HTML representing the component's state.
const render = component()

// The main render effect, its job is to write the HTML to the DOM.
const renderMyApp = () => {
  root.innerHTML = render()
}
effect(renderMyApp)

That’s a very naive implementation but it works. I was amazed by this and couldn’t resist…

A Tiny Framework is Born: DuckJS 🦆✨

Yes, you’re right! Another day, another JavaScript framework is born. Check out the live demo here!

Here’s a snippet of what writing a “DuckJS application” looks like:

// --- State ---
const numberOfDucks = signal(1)
const isLuckyNumber = computed(() => numberOfDucks.get() % 7 === 0)

// --- Actions ---
const increaseDucks = () => numberOfDucks.set(numberOfDucks.get() + 1)
const decreaseDucks = () =>
  numberOfDucks.get() > 0 && numberOfDucks.set(numberOfDucks.get() - 1)
const randomizeDucks = () =>
  numberOfDucks.set(Math.floor(Math.random() * (50 - 5 + 1)) + 5) // between 5 and 50

// --- Components
const Ducks: Component = () => {
  effect(() => {
    if (isLuckyNumber.get()) {
      window.fireConfetti()
    }
  })

  return () =>
    Array.from(
      { length: numberOfDucks.get() },
      () => `<span class="shrink-0">${DUCK_SVG()}</span>`,
    )
}

const Message: Component = () => {
  return () =>
    isLuckyNumber.get()
      ? '<div class="text-2xl text-green-500">🎉 Yay! Lucky number! 🎉</div>'
      : ''
}

// --- Mounting the app
createApp(Ducks, document.getElementById('ducks'))
createApp(Message, document.getElementById('msg'))

const moreButton = document.getElementById('more-ducks-btn')
moreButton?.addEventListener('click', increaseDucks)

const lessButton = document.getElementById('less-ducks-btn')
lessButton?.addEventListener('click', decreaseDucks)

const randomButton = document.getElementById('random-ducks-btn')
randomButton?.addEventListener('click', randomizeDucks)

// --- Somewhere else is the HTML

My apologies for the slightly awkward string-based HTML and the manual event listener attachment (real frameworks are much smarter about DOM operations!), but hopefully, it demonstrates the core idea. The createApp function uses our effect primitive to wire up the component’s rendering logic to the reactive state.

Conclusion

And there we have it! We’ve journeyed from the theoretical ideas in Part 1 to actually implementing a working, albeit simple, signal-based reactivity system. We built signal, effect, and computed from scratch, leveraging automatic dependency tracking, and even made a mini-framework, DuckJS.

This post was definitely code-intensive, but I hope seeing the mechanics laid bare demystifies some of the “magic” behind modern frontend frameworks. Understanding these fundamentals certainly scratched my itch that started with switching between Vue and React!

Thank you for sticking with me through this two-part dive into reactivity. I hope you found this exploration as insightful and fun as I did. All the source code for the reactivity model and DuckJS is available here if you want to tinker further!