📡 Signals for Fine-Grained Reactivity ​
Before we dive into Re4, let’s understand the core primitive that powers its reactivity system - built around signals
.
State is what makes UIs dynamic. Whether it’s a counter, a form, or an interactive canvas full of moving shapes, the app needs to react when values change. Different frameworks solve this with different tools - stores, proxies, observables - each with tradeoffs.
Signals offer a simpler alternative.
They’re lightweight, precise, and designed for fine-grained updates: only the parts of your code that use a signal will re-run when it changes.
What Is a Signal? ​
A signal is a wrapper around a value that can track which code is using it and notify that code when the value changes.
Here’s a basic example:
const count = signal(1);
console.log(count.value); // 1
count.value = 2;
console.log(count.value); // 2
It may look like a getter/setter, but signals also remember which parts of your app used count.value
. That’s where the magic begins.
Reacting to Changes with effect
​
To respond when a signal changes, wrap your logic in an effect
.
effect(() => {
console.log('Count is:', count.value);
});
Now, whenever count.value
changes, this effect re-runs:
count.value = 3;
// logs: Count is: 3
No need to manually subscribe - signals track usage automatically when accessed inside an effect.
This makes it easy in situations where you want to update just a part of the DOM based on a signal. For example:
const count = signal(0);
const el = document.querySelector('#countDisplay');
effect(() => {
el.textContent = `Count is: ${count.value}`; // auto-tracked dependency
});
setInterval(() => {
count.value += 1; // trigger update every second
}, 1000);
This code:
- Gets the display element
- Uses
effect()
to update the text whenevercount.value
changes. - Updates the signal every second, which re-runs the effect and updates the DOM
Derived Values with computed
​
Let’s say you want to calculate a value based on other signals - for example,total = price × quantity
. You don’t want to update total
manually every time.
That’s what computed
is for:
const price = signal(10);
const quantity = signal(2);
const total = computed(() => price.value * quantity.value);
console.log(total.value); // 20
quantity.value = 3;
console.log(total.value); // 30
total
updates automatically when price
or quantity
changes. And it’s read-only - it always reflects the latest derived value.
Let’s Write a Mini Signals Runtime from Scratch ​
Step 1: The signal
function ​
We create a signal by wrapping a value and tracking who depends on it.
let currentEffect = null;
function signal(initialValue) {
let value = initialValue;
const subscribers = new Set();
return {
get value() {
if (currentEffect?.firstRun) {
subscribers.add(currentEffect.run);
}
return value;
},
set value(newValue) {
if (newValue !== value) {
value = newValue;
for (const run of subscribers) run();
}
},
};
}
Step 2: The effect
function ​
This runs a function and records which signals it accessed. Those signals can then notify this function when they change.
function effect(fn) {
const context = {
firstRun: true,
run: () => {
currentEffect = context;
fn();
currentEffect = null;
context.firstRun = false;
},
};
context.run();
}
Try it out: ​
const a = signal(1);
const b = signal(2);
effect(() => {
console.log('a + b =', a.value + b.value);
});
a.value = 5; // re-runs effect
b.value = 7; // re-runs again
Output:
a + b = 3
a + b = 7
a + b = 12
Since we’re here, let’s also implement a computed
function - for values that depend on other signals but update automatically.
The computed
function ​
computed()
works just like effect()
, but instead of running side effects, it returns a read-only signal that tracks its dependencies and recalculates when they change.
function computed(fn) {
const result = signal();
effect(() => {
result.value = fn();
});
return {
get value() {
return result.value;
},
};
}
Try it out: ​
const price = signal(50);
const quantity = signal(2);
const total = computed(() => price.value * quantity.value);
console.log(total.value); // 100
quantity.value = 3;
console.log(total.value); // 150
This lets you build derived reactive values that stay up-to-date without any extra wiring.
This is the core idea:
- A value that tracks who’s watching
- Notifies them when it changes
Real libraries optimize this with batching, better data structures, and scheduling - but this example gives you the mental model.
Signals vs Observables ​
You might wonder - are signals like observables or event emitters?
Not quite:
Aspect | Signals | Observables |
---|---|---|
Push/Pull | Pull-based (you read) | Push-based (you subscribe) |
Tracking | Automatic on access | Manual .subscribe() |
Timing | Always synchronous | Can be synchronous or asynchronous |
Signals fit better in declarative UIs where logic depends on data, and the system figures out when to re-run that logic.
TL;DR ​
- A signal wraps a value and re-runs any code that depends on it when it changes.
- Use
effect()
to run code that reacts to signal changes automatically. - Use
computed()
for values derived from other signals - they’re read-only and stay in sync. - Signals are synchronous, auto-tracked, and minimal.
- Perfect for building fast, fine-grained reactivity with little runtime overhead.
🚀 Up Next ​
How Re4 uses signals under the hood (coming soon!).
Resources & Credits ​
Like Re4? Follow me on X for more updates:
— Aadi (Follow on X)