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.