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 effect and a derived function.
#
Signal
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.
#
Effect
export function effect(fn: Function) {
subscriber = fn
fn()
subscriber = null
}
This effect function temporarily sets a global
subscriber to the function you pass in, runs it once, and then clears
the subscriber. Any signal accessed during that execution registers the
function in its subscribers list.
#
Derived
export function derived(fn: Function) {
const derived = signal(fn())
effect(() => {
derived.value = fn()
})
return derived
}
Derived creates a new signal whose value is computed
from other signals. Internally, it uses an effect to automatically recompute
whenever its dependencies change.
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.