Angular Signals mark a major shift in how change detection works in Angular. They give Angular a proper reactivity system — no longer relying heavily on Zone.js.
But what is reactivity, really?
At its core, reactivity is about keeping your data and UI in sync. When data changes, the UI should update. When the UI triggers a change, the data should reflect that. It is a two-way relationship.
Signals are Angular’s new primitive for managing this relationship. A signal wraps a value and notifies anything that depends on it when that value changes.
In Angular’s own words: A signal is a lightweight wrapper around a value. Angular tracks where signals are read and when they are updated. This allows Angular to respond to state changes and update the DOM efficiently. This behaviour is what we call reactivity.
A basic Signals usage in Angular looks like this:
const count = signal(0);
// Signals are getter functions — calling them reads their value
console.log('The count is: ' + count());
// Update the value
count.set(3);
// Update based on the previous value
count.update(value => value + 1);
There is also computed()
for derived values:
const count = signal(0);
const doubleCount = computed(() => count() * 2);
These are lazily evaluated and memoised — Angular only re-calculates doubleCount
when count
changes.
Let implement a minimal version of signal
and computed
from scratch to understand how they work.
We use TypeScript to define two types:
type Signal<T> = () => T;
type WritableSignal<T> = Signal<T> & {
set: (value: T) => void;
};
A Signal<T>
is just a getter function. A WritableSignal<T>
is one that also has a set() method.
This is a signal that holds a value, lets you read it
function signal<T>(initial: T): WritableSignal<T> {
let value = initial;
const getValue = () => value;
getValue.set = (newValue: T) => {
value = newValue;
};
return getValue
}
getValue
is just a function that returns the current value.set
method to it so it can also update the value.getValue.set
is just a property we add to the function.With the above code, the following example will work
const count = signal(0);
function logCount() {
console.log('Count is:', count());
}
logCount(); // Count is: 0
count.set(3);
logCount(); // Count is: 3
Here is your cleaned up and reflowed Step 2, with duplications removed and the structure improved for easier reading:
A computed signal derives its value from other signals. It is a read-only signal whose value is calculated by a function you provide.
function computed<T>(fn: () => T): Signal<T> {
let value: T = fn();
const getValue = () => value;
return getValue;
}
In this implementation:
We invoke fn()
once during creation:
let value: T = fn();
This means the computed value is calculated only once when computed()
is called, and the result is cached in value
.
The returned getValue()
function always returns this cached value.
You might wonder: why not write const getValue = () => fn();
and let it recalculate every time?
Here is why we do not:
fn()
inside getValue()
, it will re-run on every access, even if the underlying signals have not changed.Here is an example:
const count = signal(1);
const doubleCount = computed(() => count() * 2);
console.log(doubleCount()); // 2
count.set(5);
console.log(doubleCount()); // 2 (still old value!)
Why does doubleCount()
still return 2 after updating count
?
fn()
ran only once, and we are always returning the cached value.count
changed — signals do not notify anyone yet.To fix this, we need to:
That is what we will address in Step 3.
In Step 2, we saw that even after updating the original signal, the computed value does not change. To make this reactive, we need to:
Let update our signal
and computed
implementations to support that.
First, we define a global currentSubscriber
variable. When a computed function runs, it registers itself as the currentSubscriber
. Then when a signal is read, it stores this subscriber in its list.
let currentSubscriber: (() => void) | null = null;
Update signal()
:
function signal<T>(initial: T): WritableSignal<T> {
let value = initial;
const subscribers = new Set<() => void>();
const getValue = () => {
+ if (currentSubscriber) {
+ subscribers.add(currentSubscriber);
+ }
return value;
};
getValue.set = (newValue: T) => {
value = newValue;
+ for (const sub of subscribers) {
+ sub();
+ }
};
return getValue as WritableSignal<T>;
}
Now update computed()
:
function computed<T>(fn: () => T): Signal<T> {
let value: T;
+ const recompute = () => {
+ currentSubscriber = recompute;
+ value = fn();
+ currentSubscriber = null;
+ };
+ recompute();
const getValue = () => value;
return getValue;
}
Usage:
const count = signal(1);
const doubleCount = computed(() => count() * 2);
console.log(doubleCount()); // 2
count.set(5);
console.log(doubleCount()); // 10
computed(fn)
runs, it sets currentSubscriber
to its recompute
function.fn()
will store recompute
as a subscriber.count.set(5)
is called, the count
signal notifies its subscribers, which includes recompute
.recompute()
is called, and value
is updated.doubleCount()
will return the updated value.This is the minimal reactive system: signals track dependencies via subscriptions, and computed values get updated automatically.