JS Signals

adamlbarrett.bsky.social

BigAB

Use this QR Code to let me know what you thought of this talk!

JS Signals?

What are JS Signals?

A not-so-new,

reactive state primitive

poorly named,

 🙄 ...It's just a monoid in the category of endofunctors, what's the problem?

- Anon Functional Programmer Bro

What makes then so special?

# JS Signals

 

🧑🏻‍💻

Developer Experience

🏃🏽‍♀️

Performance

What are JS Signals?

A not-so-new, poorly named, reactive state primitive

reactive state primitive

A not-so-new, poorly named, reactive state primitive

What do we mean by "reactive"?

We are not talking about:

  • Systems that are Responsive, Resilient, Elastic and Message Driven (reactivemanifesto.org)
  • Programming with asynchronous data streams
  • An event-based model where data is pushed to the consumer, as it becomes available

Reactive

  • We have a value
  • When that value changes...
  • Anything "using" that value also changes   ...automatically

An Exploration of Reactvity in JavaScript

const button = document.querySelector('button');
const output = document.querySelector('output');

let count = 1;
let double = count * 2;

button.addEventListener('click', () => {
  count++;
});

const updateUI = () => {
  output.innerHTML = `
    <h1>${count} * 2 = ${double}</h1>
  `;
};
updateUI();
# Reactivity
const button = document.querySelector('button');
const output = document.querySelector('output');

let count = 1;
let double = count * 2;

button.addEventListener('click', () => {
  count++;
});

const updateUI = () => {
  output.innerHTML = `
    <h1>${count} * 2 = ${double}</h1>
  `;
};
updateUI();
# Reactivity
const button = document.querySelector('button');
const output = document.querySelector('output');

let count = 1;
let double = count * 2;

button.addEventListener('click', () => {
  count++;
  double = count * 2;
  updateUI();
});

const updateUI = () => {
  output.innerHTML = `
    <h1>${count} * 2 = ${double}</h1>
  `;
};
updateUI();
# Reactivity
export const Observable = (fn) => ({ subscribe: fn });

export const observe = (element, event) => {
  return Observable((observer) => {
    element.addEventListener(event, observer);
    return () => {
      element.removeEventListener(event, observer);
    };
  });
};
# Reactivity
export const map = (observable, fn) => {
  return Observable((observer) => {
    return observable.subscribe((value) => {
      observer(fn(value));
    });
  });
};

export const scan = (observable, fn, initialState) => {
  let state = initialState;
  let unsub;
  const observers = new Set();
  return Observable((observer) => {
    observers.add(observer);
    observer(state);
    if (observers.size === 1) {
      unsub = observable.subscribe((value) => {
        state = fn(state, value);
        observers.forEach((obs) => obs(state));
      });
    }
    return () => {
      observers.delete(observer);
      if (observers.size === 0) {
        unsub();
      }
    };
  });
};

export const combine = (...observables) => {
  return Observable((observer) => {
    const values = new Array(observables.length);

    const subscriptions = observables.map(
      (observable, index) =>
        observable.subscribe((value) => {
          values[index] = value;
          if (
            values.every((value) => value !== undefined)
          ) {
            observer(values);
          }
        })
    );

    return () => {
      subscriptions.forEach((unsubscribe) => unsubscribe());
    };
  });
};

export const pipe =
  (...fns) =>
  (value) =>
    fns.reduce((acc, fn) => fn(acc), value);
# Reactivity
import { observe, scan, map, combine } from './observable';

const button = document.querySelector('button');
const output = document.querySelector('output');

const clicks = observe(button, 'click');

const count = scan(clicks, (v) => v + 1, 1);
const double = map(count, (n) => n * 2);

combine(count, double).subscribe(([n, dbl]) => {
  output.innerHTML = `
    <h1>${n} * 2 = ${dbl}</h1>
  `;
});
# Reactivity
import { fromEvent, startWith, scan, map, zip } from 'rxjs';

const button = document.querySelector('button');
const output = document.querySelector('output');

const clicks = fromEvent(button, 'click');

const count = clicks.pipe(
  startWith(1),
  scan((count) => count + 1)
);
const double = count.pipe(map((count) => count * 2));

zip(count, double).subscribe(([count, double]) => {
  output.innerHTML = `
    <h1>${count} * 2 = ${double}</h1>
  `;
});
# Reactivity
export const createStore = (state) => {
  let listeners = new Set();

  return {
    get: () => state,
    set: (updater = (v) => v) => {
      state = updater(state);
      listeners.forEach((listener) => listener(state));
    },
    subscribe: (listener) => {
      listeners.add(listener);
      listener(state);
      return () => {
        listeners.delete(listener);
      };
    },
  };
};
# Reactivity
import { createStore, derived } from './store.js';

const button = document.querySelector('button');
const output = document.querySelector('output');

const count = createStore(1);
const double = derived([count], ([count]) => count * 2);

button.addEventListener('click', () =>
  count.set((count) => count + 1)
);

derived([count, double]).subscribe(([count, double]) => {
  output.innerHTML = `
    <h1>${count} * 2 = ${double}</h1>
  `;
});
# Reactivity
import { createStore, derived } from './store.js';

const button = document.querySelector('button');
const output = document.querySelector('output');

const count = createStore(1);
const double = derived([count], ([count]) => count * 2);

button.addEventListener('click', () =>
  count.set((count) => count + 1)
);

derived([count, double]).subscribe(([count, double]) => {
  output.innerHTML = `
    <h1>${count} * 2 = ${double}</h1>
  `;
});
# Reactivity
import { useSyncExternalStore } from 'react';
import { createStore, derived } from './store.js';

const count = createStore(1);
const double = derived([count], ([count]) => count * 2);

const syncExternalStore = derived([count, double]);
const handleClick = () => count.set((v) => v + 1);

export default function App() {
  const [cnt, dbl] = useSyncExternalStore(
    syncExternalStore.subscribe,
    syncExternalStore.get
  );
  return (
    <>
      <output>
        <h1>
          {cnt} * 2 = {dbl}
        </h1>
      </output>
      <button onClick={handleClick}>+1</button>
    </>
  );
}
# Reactivity
<script>
  import { writable, derived } from 'svelte/store';

  const count = writable(1);
  const double = derived(count, (n) => n * 2);

  const handleClick = () => $count += 1;
</script>

<output>
	<h1>
		{$count} * 2 = {$double}
	</h1>
</output>
<button on:click={handleClick}>+1</button>
# Reactivity
import { signal, computed, effect } from './signals';

const button = document.querySelector('button');
const output = document.querySelector('output');

let count = signal(1);
let double = computed(() => count.value * 2);

button.addEventListener('click', () => count.value++);

effect(() => {
  output.innerHTML = `
    <h1>${count.value} * 2 = ${double.value}</h1>
  `;
});
# Reactivity
import { useSignal, useComputed } from '@preact/signals-react';

export default function App() {
  const count = useSignal(0);
  const double = useComputed(() => count.value * 2);

  const handleClick = () => count.value++;
  return (
    <>
      <output>
        <h1>
          {count} * 2 = {double}
        </h1>
      </output>
      <button onClick={handleClick}>+1</button>
    </>
  );
}
# Reactivity

So what do we mean by "reactive"?

reactive state primitive

A not-so-new, poorly named, reactive state primitive

State

Primitive

A not-so-new, poorly named, reactive state primitive

A not-so-new, 

Framework since Reactive State Derived Values Side Effect
Knockout 2010 observable pureComputed computed
CanJS 2012 observable compute compute.on()
Svelte 2016 let variable = ? $: (Reactive Values) $: (reactive statements)
Solid 2018 createSignal createMemo createEffect
Vue 2020 ref/reactive computed watch/watchEffect
Preact 2022 signal computed effect
Qwik 2022 useSignal/useStore useComputed$ / useResource$ useTask$
Angular 2023 signal computed effect
Svelte 5 2024 $state $derived $effect

What are JS Signals?

import { signal, computed, effect } from './signals';

const button = document.querySelector('button');
const output = document.querySelector('output');

let count = signal(1);
let double = computed(() => count.value * 2);

button.addEventListener('click', () => count.value++);

effect(() => {
  output.innerHTML = `
    <h1>${count.value} * 2 = ${double.value}</h1>
  `;
});
# What are JS Signals

JS Signals

State

Derived

Effect

JS Signals

Auto Tracking

Acyclic

Push-then-Pull

Incremental Improvement

EventTarget

Observable

Stores

Signals

So how are they better?

(Better than stores)

JS Signals

State

Derived

Effect

JS Signals

Auto Tracking

Acyclic

Push-then-Pull

🧑🏻‍💻

Developer Experience

Auto Tracking

import { signal, computed, effect } from './signals';

const button = document.querySelector('button');
const output = document.querySelector('output');

let count = signal(1);
let double = computed(() => count.value * 2);

button.addEventListener('click', () => {
  count.value++;
});

effect(() => {
  output.innerHTML = `
    <h1>${count.value} * 2 = ${double.value}</h1>
  `;
});
# What are JS Signals
import { signal, computed, effect } from './signals';

const button = document.querySelector('button');
const output = document.querySelector('output');

let count = signal(1);
let double = computed(() => count() * 2);

button.addEventListener('click', () => {
  count.update((n) => n + 1)
});

effect(() => {
  output.innerHTML = `
    <h1>${count()} * 2 = ${double()}</h1>
  `;
});
# What are JS Signals
import { signal, computed, effect } from './signals';

const button = document.querySelector('button');
const output = document.querySelector('output');

let count = signal(1);
let double = computed(() => count.get() * 2);

button.addEventListener('click', () => {
  count.set(count.get() + 1)
});

effect(() => {
  output.innerHTML = `
    <h1>${count.get()} * 2 = ${double.get()}</h1>
  `;
});
# What are JS Signals
<script>
  let count = $state(1);
  let double = $derived(count * 2);

  const handleClick = () => count++;
</script>

<output>
	<h1>
		{count} * 2 = {double}
	</h1>
</output>
<button onclick={handleClick}>+1</button>
# Reactivity

🏃🏽‍♀️

Performance

Push-then-Pull

import { signal, computed, effect } from './signals';

const button = document.querySelector('button');
const output = document.querySelector('output');

let count = signal(1);
let double = computed(() => count.value * 2);

button.addEventListener('click', () => {
  count.value++;
});

effect(() => {
  output.innerHTML = `
    <h1>${count.value} * 2 = ${double.value}</h1>
  `;
});
# What are JS Signals

Count

1

Count

1

import { signal, computed, effect } from './signals';

const button = document.querySelector('button');
const output = document.querySelector('output');

let count = signal(1);
let double = computed(() => count.value * 2);

button.addEventListener('click', () => {
  count.value++;
});

effect(() => {
  output.innerHTML = `
    <h1>${count.value} * 2 = ${double.value}</h1>
  `;
});
# What are JS Signals

Count

1

Double

import { signal, computed, effect } from './signals';

const button = document.querySelector('button');
const output = document.querySelector('output');

let count = signal(1);
let double = computed(() => count.value * 2);

button.addEventListener('click', () => {
  count.value++;
});

effect(() => {
  output.innerHTML = `
    <h1>${count.value} * 2 = ${double.value}</h1>
  `;
});
# What are JS Signals

Count

1

Double

Effect

Count

1

Double

Effect

Count

1

Double

Effect

Count

1

Double

Effect

Count

1

Double

Effect

Count

1

Double

Effect

Count

1

Double

Effect

Count

1

Double

Effect

Count

1

Double

Effect

Count

1

Double

Effect

Count

1

Double

Effect

2

Count

1

Double

Effect

2

Count

1

Double

Effect

2

import { signal, computed, effect } from './signals';

const button = document.querySelector('button');
const output = document.querySelector('output');

let count = signal(1);
let double = computed(() => count.value * 2);

button.addEventListener('click', () => {
  count.value++;
});

effect(() => {
  output.innerHTML = `
    <h1>${count.value} * 2 = ${double.value}</h1>
  `;
});
# What are JS Signals

Count

2

Double

Effect

2

Count

2

Double

Effect

2

Count

2

Double

Effect

2

Count

2

Double

Effect

2

Count

2

Double

Effect

2

Count

2

Double

Effect

2

import { signal, computed, effect } from './signals';

const button = document.querySelector('button');
const output = document.querySelector('output');

let count = signal(1);
let double = computed(() => count.value * 2);

button.addEventListener('click', () => {
  count.value++;
});

effect(() => {
  output.innerHTML = `
    <h1>${count.value} * 2 = ${double.value}</h1>
  `;
});
# What are JS Signals

Count

2

Double

Effect

2

Count

2

Double

Effect

2

Count

2

Double

Effect

2

Count

2

Double

Effect

2

Count

2

Double

Effect

2

Count

2

Double

Effect

2

Count

2

Double

Effect

2

Count

2

Double

Effect

2

Count

2

Double

Effect

2

Count

2

Double

Effect

2

Count

2

Double

Effect

2

Count

2

Double

Effect

4

Count

2

Double

Effect

4

# What are JS Signals

The Key Takeaways

  • the "reads" set up the singal trees sink/source relationships
  • there are 2 stages, the notify stage and the read
  • everything is read lazily and only if it might have been changed in the notify stage

The Diamond Problem

The Diamond Problem

Value

* 2

2

* -2

$ + $

1

-2

0

log

0

The Diamond Problem

Value

* 2

2

* -2

$ + $

2

-2

0

log

0

The Diamond Problem

Value

* 2

4

* -2

$ + $

2

-2

0

log

0

The Diamond Problem

Value

* 2

4

* -2

$ + $

2

-2

2

log

2

The Diamond Problem

Value

* 2

4

* -2

$ + $

2

-2

2

log

2

The Diamond Problem

Value

* 2

4

* -2

$ + $

2

-4

2

log

2

The Diamond Problem

Value

* 2

4

* -2

$ + $

2

-4

0

log

0

The Diamond Problem

Value

* 2

4

* -2

$ + $

2

-4

0

log

0

A not-so-new, poorly named, reactive state primitive

poorly named,

Developer Hapiness

let currentEffect = null;

export const signal = (val) => {
  return {
    sinks: new Set(),
    get value() {
      if (currentEffect) {
        this.sinks.add(currentEffect);
        currentEffect.sources.add(this);
      }
      return val;
    },
    set value(newValue) {
      val = newValue;
      [...this.sinks].forEach((sink) => sink.run());
    },
  };
};

export const effect = (fn) => {
  const _effect = {
    sources: new Set(),
    run() {
      this.sources.forEach((source) => {
        source.sinks.delete(this);
      });
      this.sources.clear();

      let prev = currentEffect;
      currentEffect = this;
      fn();
      currentEffect = prev;
    },
  };
  _effect.run();
};

export const computed = (fn) => {
  const s = signal();
  effect(() => (s.value = fn()));
  return s;
};
# Live Coding Result

?

Questions?

adamlbarrett.bsky.social

BigAB

Use this QR Code to let me know what you thought of this talk!

JS Signals