Signals from scratch
Decoupling components with signals in modern web development
In modern web development, handling communication between different parts of an application efficiently can make or break your code’s maintainability. This is where signals come in. Signals provide a clean way to broadcast events and react to them, without creating tight coupling between components. In this post, we’ll explore what signals are, why they matter, and even build a simple signal system from scratch.
What are signals and why do they matter?
Imagine you have multiple parts of your application that need to react when something happens, like updating a counter, showing a notification, or refreshing a component, but you don’t want these parts tightly connected. This is where signals come in.
A signal is a simple mechanism to broadcast events and react to them. Think of it as a messenger that tells subscribers, “Hey, something happened!” without forcing them to know exactly who sent the message or how it works internally.
Signals are useful because they help you:
- - Decouple components: Components don’t need to know about each other’s internals.
- - Manage side effects cleanly: Actions can happen in response to signals instead of being manually orchestrated.
- - Keep code predictable: By centralizing reactions to events, you can easily track what triggers what.
Signals from scratch
Enough theory, lets dive in. Our plan is to create a signal
, a derive
function and an effect
function.
First, we will create a function called 'signal' that manages a reactive value. A signal stores a value and lets other parts of your code automatically react whenever that state changes.
export function signal<T>(defaultValue: T) {
const subscriptions = new Set<Function>()
let value = defaultValue
return {
get value(): T {
if (subscriber) {
subscriptions.add(subscriber)
}
return value
},
set value(newValue: T) {
value = newValue
subscriptions.forEach((fn) => fn())
},
}
}
Let's break this down
const subscriptions = new Set<Function>()
Here we keep track of all the functions (effects
) that need to run when the signal value changes.
get value(): T {
if (subscriber) {
subscriptions.add(subscriber)
}
return value
},
The getter returns the current value of the signal. If there's an active subscriber, that subscriber is added to the subscriptions set. Any effect that reads the signal is automatically registered to re-run later if the signal values changes.
set value(newValue: T) {
value = newValue
subscriptions.forEach((fn) => fn())
},
When the signal value is updated, every subscribed function gets called with the new value.
In the example we can see that every effect is triggered once during creation and then again when we change the value of 'var1'.
Pretty neat so far! But the real question is: how do we use these signals now in our code?
Ramping up
What we’ve built here is essentially how signals work under the hood in many modern frameworks. One big missing piece, though, is cleanup. Without it, when a component is destroyed, the signal will keep its subscriber in memory and continue to call it on every change. In real-world implementations, cleanup is critical to avoid memory leaks.