1 of 48

Developing Signals�(For Stage 1)

Daniel Ehrenberg (Bloomberg)�Jatin Ramanathan (Google)

Yehuda Katz (Heroku/Salesforce)

TC39 April 2024

2 of 48

Agenda

  • What are Signals?
  • How Signals help organize UIs
  • Motivations for standardization
  • Development plan

3 of 48

What are Signals?

4 of 48

Signals are a reactivity primitive

  • Signals are variables with auto-tracking of where they are used
  • Computations that depend on the variable are invalidated when the variable changes
  • Side-effects can depend on computations such that they only run if the computations are invalidated.

5 of 48

Manually tracking variables

Problems with this approach

  • Data code needs to know about rendering
  • parity re-runs even if isEven() doesn’t change
    • What if parity is expensive to calculate?
  • What if UI depends on two different signals?
  • What if UI no longer depends on a particular signal?

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

);

6 of 48

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

7 of 48

Visualizing the data dependencies

S3

C2

E1

S2

C1

S1

E2

8 of 48

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;

}

9 of 48

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;

}

10 of 48

Subtle APIs

  • Reacting to modifications ("Watcher")
    • Basis for building "effects"
  • Introspecting the signal graph
  • Executing operations with tracking turned off
  • etc.
  • Intended to be kept minimal, just what is necessary for realistic usage

11 of 48

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

12 of 48

How Signals help organize UIs

13 of 48

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); },

};

}

14 of 48

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.

15 of 48

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

16 of 48

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

17 of 48

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

18 of 48

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

19 of 48

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

20 of 48

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

21 of 48

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

22 of 48

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

23 of 48

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

24 of 48

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

25 of 48

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!

26 of 48

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

27 of 48

We promised this would work in other frameworks

Let's see how that earlier Vue example works. But just the final step.

28 of 48

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

29 of 48

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

30 of 48

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

31 of 48

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

32 of 48

Motivations for standardization

33 of 48

What do we mean by standardizing Signals?

  • Model: Community Convergence�(Like the Promises/A+ ⇒ TC39 Promises standard)
  • Similarity: Community-driven approach, with the goal of finding common semantics
    • Maybe someday they'll be built-in, but we're starting with an investigation of what can be made common in the ecosystem
  • Difference: Common ergonomics is a non-goal for now
    • Frameworks are expected to wrap Signals to provide their ergonomics

34 of 48

Interoperability

  • Multiple frameworks have independently reinvented an auto-tracking primitive
  • Driven by: view = fn(model)
    • Smaller changes to the model cause incremental recalculation of view
  • A separate "model" enables sharing between separate rendering contexts
    • E.g., "islands" in different frameworks
    • Embedding widgets from one framework in another
  • Standards give a common reference point and basis for fine-tuning convergence

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.

35 of 48

Implementation strategies

  • JS engines might be able to implement signals faster natively, but this isn't the only motivation for standardization.�
  • Frameworks are collaborating on a polyfill in JavaScript for their own exploration.

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++.

36 of 48

Secondary Standardization Motivations

  • Plausible future HTML/DOM integration (not yet designed)�
  • It's super-common, so let's stop shipping it all the time.�Batteries-included standard library! (?)�
  • Empower developers in the ecosystem to focus on what excites them
    • frameworks can focus on browser-specific quality concerns
    • library authors can use reactivity superpowers and focus on the data side�
  • Standards are a good way to bring the ecosystem together!

37 of 48

Better Together: An Aspirational Devtools Story

  • Some frameworks have built reactive devtools, but the tooling ecosystem is fragmented and each tool is coupled to a single framework's reactivity system.�
  • Frameworks are interested in the potential for consolidating their effort on shared reactivity tooling.�
  • Shared tooling also allows visualizing a single callstack or reference graph without coupling reactive libraries to a single framework.�
  • Could native DevTools expose things better? Let's investigate.

38 of 48

Development plan

39 of 48

Prototyping goals to develop during Stage 1

  • Correct, fully-functioning polyfill(s)
    • Nothing in Signals requires special powers from the engine
  • Complete correctness tests (+ integration and stress tests)
  • Improved reference and introductory documentation
  • Integration into several frameworks (in branches)
  • Framework-independent state management abstractions
  • Experimentation in applications
  • Performance measurements at various layers
  • Developer tools

Continue iterating on proposal based on experience

40 of 48

⚠️�POLYFILL IS UNSTABLE and v0.x

�UNSUITABLE FOR PRODUCTION USE�⚠️

41 of 48

Community collaboration

  • First draft was developed by engineers in Angular, Bubble, Ember, FAST, MobX, Preact, Qwik, RxJS, Solid, Starbeam, Svelte, Vue, and Wiz.
    • Optimism about potential for underlying usage
  • Since releasing, very broad community interest
  • New participants from various new and old reactive systems
  • Community discord server attracted 100+ participants; active discussion
  • 72 issues filed (mostly since publishing), rigorous debate on all aspects
  • Regular community calls planned following plenary

42 of 48

The current proposal is a very early draft

Everything is up for discussion and change

43 of 48

Stage 1?

44 of 48

Bonus slides: FAQs

45 of 48

Why not leave Signals for a JS library, no standard?

  • We do plan to develop a JS library at first and see how it goes
  • In npm, very hard to reliably de-duplicate libraries across versions
    • So, very hard to have a global tracking context, generation number
  • Signals might be higher performance if native
    • To be proven out in a prototype
  • Allows other constructs to be built on top, e.g., signal-backed objects and arrays, which may have even more overhead reduced if built-in.

46 of 48

Why develop Signals in TC39 rather than DOM?

None of these are absolute reasons why the proposal has to be done in TC39

  • We're good at community involvement, which is essential to get the design right
  • JS engines may be able to support higher-quality implementations than DOM
  • Signals are useful outside of UIs too, e.g., in build systems
  • Nothing in Signals should depend on DOM – pure computation, no events, etc.
  • TC39 people did the work so they have influence on initial venue choice
  • Later web integration proposals can take place in web standards venues
  • Ultimately, TC39 is competing to be the most productive, inclusive, etc

We could move this proposal to be a web standard, if we have reason later

47 of 48

Are frameworks really so aligned?

  • Everyone seems to have come around to one-way dataflow
  • The DOM shown on screen is a function of the state
    • Or, the view is a function of the model
  • Common notion of "correctness": from-scratch consistency
  • Push-based reactivity systems encountered similar problems and are moving towards a consistent solution
  • Effects are encouraged to be async, but frameworks can work around that and poll (e.g., after each set) to make them happen basically synchronously
    • Many frameworks are migrating from legacy immediate reactivity towards scheduled effects; signals enable compat.

48 of 48

Could signals be eager? Lossless? Aligned with Observables or Events?

  • A core property of signals is to be "lazy" or "pull-based"
  • Only computeds which are actually used get recalculated
  • Events/Observables push data, making it harder avoid unused computations
  • Worse, push-based reactivity causes "glitches" – inconsistencies because some parts have received the push-based update and some haven't yet.
    • Signals are always consistent and always reflect the "current" value