Developing Signals�(For Stage 1)
Daniel Ehrenberg (Bloomberg)�Jatin Ramanathan (Google)
Yehuda Katz (Heroku/Salesforce)
TC39 April 2024
Agenda
What are Signals?
Signals are a reactivity primitive
Manually tracking variables
Problems with this approach
let counter = 0;
const setCounter = (value) => {
counter = value;
render();
};
const isEven = () => (counter & 1) == 0;
const parity = () => isEven() ? "even" : "odd";
const render = () => � element.innerText = parity();
// Simulate external updates to counter…
setInterval(� () => setCounter(counter + 1),� 1000
);
Visualizing the data dependencies
counter
isEven
parity
(counter & 1) == 0
isEven() ? "even" : "odd"
State
Computed
Text
Effect
Render “even” or “odd” in the text node
Visualizing the data dependencies
S3
C2
E1
S2
C1
S1
E2
State signals API
// A read-write Signal
class Signal.State<T> {
// Create a state Signal starting with the value t
constructor(t: T, options?: SignalOptions<T>);
// Get the value of the signal
get(): T;
// Set the state Signal value to t
set(t: T): void;
}
Computed signals API
// A Signal which is a formula based on other Signals
class Signal.Computed<T> {
// Evaluates to the value returned by the callback.
// Callback is called with this signal as the this value.
constructor(
cb: (this: Computed<T>) => T,
options?: SignalOptions<T>
);
// Get the value of the signal
get(): T;
}
Subtle APIs
Visualizing the API
State API
Computed API
subtle.Watcher
Effect API
Proposed
native APIs
Effect represents a JS-level library that uses the Watcher to execute callbacks using an appropriate scheduling strategy
😀app developer
uses
How Signals help organize UIs
A simple reactive data structure
function Counter() {
const counter = new Signal.State(0);
const isEven = new Signal.Computed(() => (counter.get() & 1) === 0);
const parity = new Signal.Computed(() => isEven.get() ? "even" : "odd");
return {
get parity() { return parity.get(); }
get count() { return counter.get(); },
increment(by = 1) { counter.set(counter.get() + by); },
};
}
In Frameworks (hypothetical)
function ShowCounter() {
const counter = useMemo(Counter, []);
return <>
<p>
{counter.count}
({counter.parity})
</p>
<button onClick={counter.increment}>
+1
</button>
</>
}
<script setup>
const counter = Counter();
</script>
<template>
<p>
{{counter.count}}
({{counter.parity}})
</p>
<button @click="counter.increment">� +1� </button>
</template>
Count: 0 (even)
+1
Both code snippets create this UI
These examples illustrate how users would experience "Effects" from the earlier slide.
In Frameworks (hypothetical)
function ShowCounter() {
const counter = useMemo(Counter, []);
return <>
<p>
{counter.count}
({counter.parity})
</p>
<button onClick={counter.increment}>
+1
</button>
</>
}
function Counter() {
const counter = new Signal.State(0);�
const isEven =
new Signal.Computed(() => (counter.get() & 1) === 0);
const parity =
new Signal.Computed(() =>
isEven.get() ? "even" : "odd");
return {� get parity() { return parity.get(); }
get count() { return counter.get(); },
increment(by = 1) {
counter.set(counter.get() + by);
},
};
}
State signal
JS Getter
Count: 0 (even)
+1
In Frameworks (hypothetical)
function ShowCounter() {
const counter = useMemo(Counter, []);
return <>
<p>
{counter.count}
({counter.parity})
</p>
<button onClick={counter.increment}>
+1
</button>
</>
}
function Counter() {
const counter = new Signal.State(0);�
const isEven =
new Signal.Computed(() => (counter.get() & 1) === 0);
const parity =
new Signal.Computed(() =>
isEven.get() ? "even" : "odd");
return {
get parity() { return parity.get(); }� get count() { return counter.get(); },� increment(by = 1) {
counter.set(counter.get() + by);
},
};
}
State signal
Computed signal
JS Getter
Count: 0 (even)
+1
After rendering, the output is up to date
function Counter() {
const counter = new Signal.State(0);�
const isEven =
new Signal.Computed(() => (counter.get() & 1) === 0);
const parity =
new Signal.Computed(() =>
isEven.get() ? "even" : "odd");
return {
get parity() { return parity.get(); }� get count() { return counter.get(); },
increment(by = 1) {
counter.set(counter.get() + by);
},
};
}
function ShowCounter() {
const counter = useMemo(Counter, []);
return <>
<p>
{counter.count}
({counter.parity})
</p>
<button onClick={counter.increment}>
+1
</button>
</>
}
Fresh
Idle
// 0
Computeds use an auto-tracking model to determine their dependencies.
Count: 0 (even)
+1
Clicking the increment button...
function ShowCounter() {
const counter = useMemo(Counter, []);
return <>
<p>
{counter.count}
({counter.parity})
</p>
<button onClick={counter.increment}>
+1
</button>
</>
}
function Counter() {
const counter = new Signal.State(0);�
const isEven =
new Signal.Computed(() => (counter.get() & 1) === 0);
const parity =
new Signal.Computed(() =>
isEven.get() ? "even" : "odd");
return {
get parity() { return parity.get(); }� get count() { return counter.get(); },
increment(by = 1) {
counter.set(counter.get() + by);
},
};
}
Count: 0 (even)
+1
invalidates the state
// 0->1
Fresh
Idle�Stale
This notifies output nodes
function ShowCounter() {
const counter = useMemo(Counter, []);
return <>
<p>
{counter.count}
({counter.parity})
</p>
<button onClick={counter.increment}>
+1
</button>
</>
}
function Counter() {
const counter = new Signal.State(0);�
const isEven =
new Signal.Computed(() => (counter.get() & 1) === 0);
const parity =
new Signal.Computed(() =>
isEven.get() ? "even" : "odd");
return {
get parity() { return parity.get(); }� get count() { return counter.get(); },
increment(by = 1) {
counter.set(counter.get() + by);
},
};
}
Count: 0 (even)
+1
// 0->1
Fresh
Idle�Stale
the most common name for this notification in frameworks is "effect"
which invalidates the output nodes
The output invalidation schedules a render
function Counter() {
const counter = new Signal.State(0);�
const isEven =
new Signal.Computed(() => (counter.get() & 1) === 0);
const parity =
new Signal.Computed(() =>
isEven.get() ? "even" : "odd");
return {
get parity() { return parity.get(); }� get count() { return counter.get(); },
increment(by = 1) {
counter.set(counter.get() + by);
},
};
}
function ShowCounter() {
const counter = useMemo(Counter, []);
return <>
<p>
{counter.count}
({counter.parity})
</p>
<button onClick={counter.increment}>
+1
</button>
</>
}
Count: 0 (even)
+1
// 0->1
Fresh
Idle�Stale
which propagates the invalidation
Which causes the output to update
function Counter() {
const counter = new Signal.State(0);�
const isEven =
new Signal.Computed(() => (counter.get() & 1) === 0);
const parity =
new Signal.Computed(() =>
isEven.get() ? "even" : "odd");
return {
get parity() { return parity.get(); }� get count() { return counter.get(); },
increment(by = 1) {
counter.set(counter.get() + by);
},
};
}
function ShowCounter() {
const counter = useMemo(Counter, []);
return <>
<p>
{counter.count}
({counter.parity})
</p>
<button onClick={counter.increment}>
+1
</button>
</>
}
Count: 1 (odd)
+1
// 1
Fresh
Idle�Stale
Updated
and brings us back to a steady state
Invalidation is granular
function ShowCounter() {
const counter = useMemo(Counter, []);
return <>
<p>
{counter.count}
({counter.parity})
</p>
<button onClick={counter.increment}>
+1
</button>�+ <button
+ onClick={() => counter.increment(2)}
+ >
+ +2
+ </button>
</>
}
function Counter() {
const counter = new Signal.State(0);�
const isEven =
new Signal.Computed(() => (counter.get() & 1) === 0);
const parity =
new Signal.Computed(() =>
isEven.get() ? "even" : "odd");
return {
get parity() { return parity.get(); }� get count() { return counter.get(); },
increment(by = 1) {
counter.set(counter.get() + by);
},
};
}
Count: 1 (odd)
+1
what if we increment by 2?
+2
No changes here
// 1
Starting again from a steady state
function ShowCounter() {
const counter = useMemo(Counter, []);
return <>
<p>
{counter.count}
({counter.parity})
</p>
<button onClick={counter.increment}>
+1
</button>� <button
onClick={() => counter.increment(2)}
>
+2
</button>
</>
}
function Counter() {
const counter = new Signal.State(0);�
const isEven =
new Signal.Computed(() => (counter.get() & 1) === 0);
const parity =
new Signal.Computed(() =>
isEven.get() ? "even" : "odd");
return {
get parity() { return parity.get(); }� get count() { return counter.get(); },
increment(by = 1) {
counter.set(counter.get() + by);
},
};
}
Count: 1 (odd)
+1
+2
// 1
Increment the counter by 2
function ShowCounter() {
const counter = useMemo(Counter, []);
return <>
<p>
{counter.count}
({counter.parity})
</p>
<button onClick={counter.increment}>
+1
</button>� <button
onClick={() => counter.increment(2)}
>
+2
</button>
</>
}
function Counter() {
const counter = new Signal.State(0);�
const isEven =
new Signal.Computed(() => (counter.get() & 1) === 0);
const parity =
new Signal.Computed(() =>
isEven.get() ? "even" : "odd");
return {
get parity() { return parity.get(); }� get count() { return counter.get(); },
increment(by = 1) {
counter.set(counter.get() + by);
},
};
}
Count: 1 (odd)
+1
+2
shouldn't change parity, right?
// 1
Fast-forward the invalidation process
function ShowCounter() {
const counter = useMemo(Counter, []);
return <>
<p>
{counter.count}
({counter.parity})
</p>
<button onClick={counter.increment}>
+1
</button>� <button
onClick={() => counter.increment(2)}
>
+2
</button>
</>
}
function Counter() {
const counter = new Signal.State(0);�
const isEven =
new Signal.Computed(() => (counter.get() & 1) === 0);
const parity =
new Signal.Computed(() =>
isEven.get() ? "even" : "odd");
return {
get parity() { return parity.get(); }� get count() { return counter.get(); },
increment(by = 1) {
counter.set(counter.get() + by);
},
};
}
Count: 1 (odd)
+1
// 1->3
and we do indeed avoid updating parity
+2
No update!
The output updates again
function ShowCounter() {
const counter = useMemo(Counter, []);
return <>
<p>
{counter.count}
({counter.parity})
</p>
<button onClick={counter.increment}>
+1
</button>� <button
onClick={() => counter.increment(2)}
>
+2
</button>
</>
}
function Counter() {
const counter = new Signal.State(0);�
const isEven =
new Signal.Computed(() => (counter.get() & 1) === 0);
const parity =
new Signal.Computed(() =>
isEven.get() ? "even" : "odd");
return {
get parity() { return parity.get(); }� get count() { return counter.get(); },
increment() {
counter.set(counter.get() + 1);
},
};
}
Count: 3 (odd)
+1
+2
and we're back to a steady state
// 3
We promised this would work in other frameworks
Let's see how that earlier Vue example works. But just the final step.
Let's replay the last change on Vue
<script setup>
const counter = Counter();
</script>
<template>
<p>
{{counter.count}} ({{counter.parity}})
</p>
<button @click="counter.increment()">� +1� </button>
<button @click="() => counter.increment(2)">� +2� </button>
</template>
function Counter() {
const counter = new Signal.State(0);�
const isEven =
new Signal.Computed(() => (counter.get() & 1) === 0);
const parity =
new Signal.Computed(() =>
isEven.get() ? "even" : "odd");
return {
get parity() { return parity.get(); }� get count() { return counter.get(); },
increment(by = 1) {
counter.set(counter.get() + by);
},
};
}
Count: 1 (odd)
+1
+2
// 1
spoiler alert: pretty much the same
Increment the counter by 2
<script setup>
const counter = Counter();
</script>
<template>
<p>
{{counter.count}} ({{counter.parity}})
</p>
<button @click="counter.increment()">� +1� </button>
<button @click="() => counter.increment(2)">� +2� </button>
</template>
function Counter() {
const counter = new Signal.State(0);�
const isEven =
new Signal.Computed(() => (counter.get() & 1) === 0);
const parity =
new Signal.Computed(() =>
isEven.get() ? "even" : "odd");
return {
get parity() { return parity.get(); }� get count() { return counter.get(); },
increment(by = 1) {
counter.set(counter.get() + by);
},
};
}
shouldn't change parity, right?
// 1
Count: 1 (odd)
+1
+2
Fast-forward validation again
<script setup>
const counter = Counter();
</script>
<template>
<p>
{{counter.count}} ({{counter.parity}})
</p>
<button @click="counter.increment()">� +1� </button>
<button @click="() => counter.increment(2)">� +2� </button>
</template>
function Counter() {
const counter = new Signal.State(0);�
const isEven =
new Signal.Computed(() => (counter.get() & 1) === 0);
const parity =
new Signal.Computed(() =>
isEven.get() ? "even" : "odd");
return {
get parity() { return parity.get(); }� get count() { return counter.get(); },
increment(by = 1) {
counter.set(counter.get() + by);
},
};
}
// 1->3
and the results are the same
No update!
Count: 1 (odd)
+1
+2
This time, Vue handles the update
<script setup>
const counter = Counter();
</script>
<template>
<p>
{{counter.count}} ({{counter.parity}})
</p>
<button @click="counter.increment()">� +1� </button>
<button @click="() => counter.increment(2)">� +2� </button>
</template>
function Counter() {
const counter = new Signal.State(0);�
const isEven =
new Signal.Computed(() => (counter.get() & 1) === 0);
const parity =
new Signal.Computed(() =>
isEven.get() ? "even" : "odd");
return {
get parity() { return parity.get(); }� get count() { return counter.get(); },
increment() {
counter.set(counter.get() + 1);
},
};
}
and we're back to our familiar steady state
// 3
Count: 3 (odd)
+1
+2
Motivations for standardization
What do we mean by standardizing Signals?
Interoperability
There are some surprising real-world issues
For example: npm sometimes duplicates libraries due to versioning
This makes it impossible to have a reliably shared global clock or tracking context, which is needed for real-world interoperability.
Implementation strategies
Native implementations aren't magic
We will probably get a constant factor improvement, and it might be small.��Native implementations might be more efficient because they have more flexibility in data structures and benefit from less indirection within C++.
Secondary Standardization Motivations
Better Together: An Aspirational Devtools Story
Development plan
Prototyping goals to develop during Stage 1
Continue iterating on proposal based on experience
⚠️�POLYFILL IS UNSTABLE and v0.x
�UNSUITABLE FOR PRODUCTION USE�⚠️
Community collaboration
The current proposal is a very early draft
Everything is up for discussion and change
Stage 1?
Bonus slides: FAQs
Why not leave Signals for a JS library, no standard?
Why develop Signals in TC39 rather than DOM?
None of these are absolute reasons why the proposal has to be done in TC39
We could move this proposal to be a web standard, if we have reason later
Are frameworks really so aligned?
Could signals be eager? Lossless? Aligned with Observables or Events?