signal-utils - v0.12.2

signal-utils

Utils for the Signal's Proposal.

APIs

[!NOTE] All examples either use JavaScript or a mixed-language psuedocode[^syntax-based-off] to convey the reactive intention of using Signals. These utilities can be used in any framework that wires up Signals to their rendering implementation.

[^syntax-based-off]: The syntax is based of a mix of Glimmer-flavored Javascript and Svelte. The main thing being focused around JavaScript without having a custom file format. The <template>...</template> blocks may as well be HTML, and {{ }} escapes out to JS. I don't have a strong preference on {{ }} vs { }, the important thing is only to be consistent within an ecosystem.

@signal

A utility decorator for easily creating signals

import { signal } from 'signal-utils';

class State {
@signal accessor #value = 3;

get doubled() {
return this.#value * 2;
}

increment = () => this.#value++;
}

let state = new State();


// output: 6
// button clicked
// output: 8
<template>
<output>{{state.doubled}}</output>
<button onclick={{state.increment}}>+</button>
</template>

@cached

A utility decorator for caching getters in classes. Useful for caching expensive computations.

import { signal } from 'signal-utils';
import { cached } from 'signal-utils/cached';

class State {
@signal accessor #value = 3;

@cached
get doubled() {
// imagine an expensive operation
return this.#value * 2;
}

increment = () => this.#value++;
}

let state = new State();


// output: 6
// button clicked
// output: 8
<template>
<output>{{state.doubled}}</output>
<button onclick={{state.increment}}>+</button>
</template>

Note that the impact of maintaining a cache is often more expensive than re-deriving the data in the getter. Use sparingly, or to return non-primitive values and maintain referential integrity between repeat accesses.

@localCopy

A utility decorator for maintaining local state that gets re-set to a "remote" value when it changes. Useful for editable controlled fields with an initial remote data that can also change.

import { signal } from 'signal-utils';
import { localCopy } from 'signal-utils/local-copy';

class Remote {
@signal accessor value = 3;
}

class Demo {
// pretend this data is from a parent component
remote = new Remote();

@localCopy('remote.value') localValue;

updateLocalValue = (inputEvent) => this.localValue = inputEvent.target.value;

// A controlled input
<template>
<label>
Edit Name:
<input value={{this.localValue}} oninput={{this.updateLocalValue}} />
</label>
</template>
}

In this demo, the localValue can fork from the remote value, but the localValue property will re-set to the remote value if it changes.

localCopy function

import { Signal } from 'signal-polyfill';
import { localCopy } from 'signal-utils/local-copy';

const remote = new Signal.State(3);

const local = localCopy(() => remote.get());
const updateLocal = (inputEvent) => local.set(inputEvent.target.value);

// A controlled input
<template>
<label>
Edit Name:
<input value={{local.get()}} oninput={{updateLocal}} />
</label>
</template>

Live, interactive demos of this concept:

Array

A reactive Array. This API mimics the built-in APIs and behaviors of Array.

import { SignalArray } from 'signal-utils/array';

let arr = new SignalArray([1, 2, 3]);

// output: 3
// button clicked
// output: 2
<template>
<output>{{arr.at(-1)}}</output>
<button onclick={{() => arr.pop()}}>pop</button>
</template>

Other ways of constructing an array:

import { SignalArray, signalArray } from 'signal-utils/array';

SignalArray.from([1, 2, 3]);
signalArray([1, 2, 3]);

Note that .from gives you more options of how to create your new array structure.

Object

A reactive Object. This API mimics the built-in APIs and behaviors of Object.

import { SignalObject } from 'signal-utils/object';

let obj = new SignalObject({
isLoading: true,
error: null,
result: null,
});

// output: true
// button clicked
// output: false
<template>
<output>{{obj.isLoading}}</output>
<button onclick={{() => obj.isLoading = false}}>finish</button>
</template>

In this example, we could use a reactive object for quickly and dynamically creating an object of signals -- useful for when we don't know all the keys boforehand, or if we want a shorthand to creating many named signals.

Other ways of constructing an object:

import { SignalObject, signalObject } from 'signal-utils/object';

SignalObject.fromEntries([ /* ... */ ]);
signalObject({ /* ... */ } );

Note that .fromEntries gives you more options of how to create your new object structure.

Map

A reactive Map

import { SignalMap } from 'signal-utils/map';

let map = new SignalMap();

map.set('isLoading', true);

// output: true
// button clicked
// output: false
<template>
<output>{{map.get('isLoading')}}</output>
<button onclick={{() => map.set('isLoading', false)}}>finish</button>
</template>

WeakMap

A reactive WeakMap

import { SignalWeakMap } from 'signal-utils/weak-map';

let map = new SignalWeakMap();

let obj = { greeting: 'hello' };

map.set(obj, true);

// output: true
// button clicked
// output: false
<template>
<output>{{map.get(obj)}}</output>
<button onclick={{() => map.set(obj, false)}}>finish</button>
</template>

Set

A reactive Set

import { SignalSet } from 'signal-utils/set';

let set = new SignalSet();

set.add(123);

// output: true
// button clicked
// output: false
<template>
<output>{{set.has(123)}}</output>
<button onclick={{() => set.delete(123)}}>finish</button>
</template>

WeakSet

A reactive WeakSet

import { SignalWeakSet } from 'signal-utils/weak-set';

let set = new SignalWeakSet();

let obj = { greeting: 'hello' };

set.add(obj);

// output: true
// button clicked
// output: false
<template>
<output>{{set.has(obj)}}</output>
<button onclick={{() => set.delete(obj)}}>finish</button>
</template>

Promise

A reactive Promise handler that gives your reactive properties for when the promise resolves or rejects.

import { SignalAsyncData } from 'signal-utils/async-data';

const response = fetch('...');
const signalResponse = new SignalAsyncData(response);

// output: true
// after the fetch finishes
// output: false
<template>
<output>{{signalResponse.isLoading}}</output>
</template>

There is also a load export which does the construction for you.

import { load } from 'signal-utils/async-data';

const response = fetch('...');
const signalResponse = load(response);

// output: true
// after the fetch finishes
// output: false
<template>
<output>{{signalResponse.isLoading}}</output>
</template>

the signalResponse object has familiar properties on it:

  • value
  • error
  • state
  • isResolved
  • isPending
  • isRejected

The important thing to note about using load / SignalAsyncData, is that you must already have a PromiseLike. For reactive-invocation of async functions, see the section below on signalFunction

async function

A reactive async function with pending/error state handling

import { Signal } from 'signal-polyfill';
import { signalFunction } from 'signal-utils/async-function';

const url = new Signal.State('...');
const signalResponse = signalFunction(async () => {
const response = await fetch(url.get()); // entangles with `url`
// after an away, you've detatched from the signal-auto-tracking
return response.json();
});

// output: true
// after the fetch finishes
// output: false
<template>
<output>{{signalResponse.isLoading}}</output>
</template>

the signalResponse object has familiar properties on it:

  • value
  • error
  • state
  • isResolved
  • isPending
  • isRejected
  • isError (alias)
  • isSettled (alias)
  • isLoading (alias)
  • isFinished (alias)
  • retry()

dedupe + @dedupe

wip

utilities for the dedupe pattern.

Draft

wip

Forking a reactive tree and optionally sync it back to the original -- useful for forms / fields where you want to edit the state, but don't want to mutate the reactive root right away.

Inspo: https://github.com/chriskrycho/tracked-draft

subtle utilities

Utilities that can easily lead to subtle bugs and edge cases.

Leaky Effect via queueMicrotask

import { Signal } from 'signal-polyfill';
import { effect } from 'signal-utils/subtle/microtask-effect';

let count = new Signal.State(0);

let callCount = 0;

effect(() => console.log(count.get());
// => 0 logs

count.set(1);
// => 1 logs

Contributing

Starting dev

pnpm install
pnpm start

This will start a concurrently command that runs the vite build and vitest tests in parallel.

Vitest isn't being used within the package, because we want to exercise the public API, generated types, etc (through package.json#exports and all that).

Is this bug free?

Likely not, code (and tests!) are copied from pre-existing implementations, and those implementations change over time. If you find a bug, please file an issue or open a PR, thanks!!

Credits and Inspiration

This library could not have been developed so quickly without borrowing from existing libraries that already built these patterns. This library, signal-utils, is an adaptation and aggregation of utilities found throughout the community.

Related Projects