Build your own signals 🚥

In this post, I would like to write about signals as it is one of the most trendy thing in the web UI libraries / frameworks community. We are going to discover how all magic happens underneath the hood by building one. Stay tuned and read on guys 😎

You probably have seen / used some kind of signals implementation before regardless of web technologies of choice. It is available in various ecosystems such as React, Vue, Angular, Solid, Svelte, Qwik, et cetera. There is a plenty of explanation out there about signals, yet my favorite one is from Solid.js author - Ryan Carniato

Signals are actually directed data graph

It is a convenient and powerful technique that deliver intuitive developer experience yet performant application out of the box. We are going to create one from scratch in approximately 50 lines of code.

First of, let's define API surface and test cases for our signals library. We are going implement signal, effect and computed respectively to pass all tests below:

import { test, vi } from 'vitest'
import { signal, effect, computed } from './signals'

test('signals independently 🚀', ({ expect }) => {
  const mockFunc = vi.fn()
  const [getValue, setValue] = signal(1)
  const computedValue = computed(() => getValue() * 2)
  const disposeEffect = effect(() => mockFunc(getValue()))

  expect(getValue()).toBe(1)
  expect(computedValue()).toBe(2)
  expect(mockFunc).toHaveBeenCalledWith(1)
  expect(mockFunc).toHaveBeenCalledTimes(1)

  setValue(2)

  expect(getValue()).toBe(2)
  expect(computedValue()).toBe(4)
  expect(mockFunc).toHaveBeenCalledWith(2)
  expect(mockFunc).toHaveBeenCalledTimes(2)

  const calls = mockFunc.mock.calls
  expect(calls[0][0]).toBe(1)
  expect(calls[1][0]).toBe(2)

  disposeEffect()
  setValue(3)
  expect(computedValue()).toBe(6)

  // Since the effect is disposed, the mock function should not be called again
  expect(mockFunc).toHaveBeenCalledTimes(2)
})

We can think of signal as a special data structure with subscription built-in. Whenever its value changes, it will notify effect and computed to re-run. With that in mind, let's create our first implementation of signal:

type Subscription = {
  run: () => void
  dependencies: Set<Set<Subscription>>
}

let activeSubscription: Subscription | null = null

export function signal<T>(initialValue: T) {
  let value = structuredClone(initialValue)
  const subscriptionSet: Set<Subscription> = new Set()

  const get = () => {
    if (activeSubscription) {
      subscriptionSet.add(activeSubscription)
      activeSubscription.dependencies.add(subscriptionSet)
    }

    return value
  }

  const set = (nextValue: T) => {
    value = nextValue
    for (const sub of subscriptionSet) {
      sub.run()
    }
  }

  return [get, set] as const
}

There is quite a bit going on with this snippet, let's break it down piece by piece. First of all, we define Subscription type with two properties - run is a function to execute subscription on demand and dependencies to keep track of which signals this subscription depends on. Next, activeSubscriptions is used to keep track of current subscriptions waiting for signals to connect. Next comes signal function, it returns a tuple of getter and setter functions on signal's value. As we can see, whenever we access value via getter function, it will track whether activeSubscription is available. If yes, we will collect it as part of subscriptionSet and add signal to subscription's dependencies. Accordingly, whenever we update value of signal via setter function, we will notify all subscriptions by calling run function.

And then it comes effect function:

export function effect(func: Function) {
  const subscription: Subscription = {
    run() {
      activeSubscription = subscription
      func()
      activeSubscription = null
    },
    dependencies: new Set(),
  }

  subscription.run()

  return function cleanup() {
    for (const dep of subscription.dependencies) {
      dep.delete(subscription)
    }
    subscription.dependencies.clear()
  }
}

As we can see, effect function takes in a callback which will be executed later. As we can see, we create a subscription inside effect function and store it in activeSubscription variable which can be collected later by signal's getter function. The returned value of effect is a cleanup function that can be called anytime to detach subscription from signal's subscription set.

Last but never be the least is computed function:

export function computed(fn: Function) {
  const [get, set] = signal(undefined)
  effect(() => set(fn()))
  return get
}

Surprisingly, computed is simply a combination of signal and effect together. We create another signal, run effect with passed in callback function to collect value and then set it to signal's value. Whenever related signals change, the callback function will be executed and computed's signal value will also be updated accordingly

And now all the tests should pass ✅ Please feel free to access the final code in this repo 😊

Signals at its core is really all about keeping good track of data and its graph of dependencies for notifying on demand. Please note this is the essence of signals only, the production-ready library typically come with much more 🔋 batteries-included features such as:

  1. Caching
  2. Batching
  3. Lazy evaluation
  4. Handle nested effects
  5. Support nested properties accessing from Object, Array, Set and Map
  6. Circle dependency detection
  7. ✨🪄🧙💫

Further readings:

There you have it guys, I hope this article is useful and good enough to demystify signals 🪄 - long live data structures, algorithms and design patterns. Together, we make the world a better place through quality software 💞

❤️❤️❤️ Be well, Be happy ❤️❤️❤️