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:
- Caching
- Batching
- Lazy evaluation
- Handle nested effects
- Support nested properties accessing from Object, Array, Set and Map
- Circle dependency detection
- ✨🪄🧙💫
Further readings:
- useSignal() is the Future of Web Frameworks
- Signal boosting
- Thinking Locally with Signals
- Code from the Vue 3 Reactivity course
- Understanding signals-based state management in React 📡
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 ❤️❤️❤️