Utils for the Signal's Proposal.
npm add signal-utils
Note
All examples either use JavaScript or a mixed-language psuedocode1 to convey the reactive intention of using Signals. These utilities can be used in any framework that wires up Signals to their rendering implementation.
- data structures
- general utilities
- class utilities
- subtle utilities
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>
This utility decorator can also be used for caching getters in classes. Useful for caching expensive computations.
import { signal } from 'signal-utils';
class State {
@signal accessor #value = 3;
// NOTE: read-only because there is no setter, and a setter is not allowed.
@signal
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.
A utility decorator for recursively, deeply, and lazily auto-tracking JSON-serializable structures at any depth or size.
import { deepSignal } from 'signal-utils/deep';
class Foo {
@deepSignal accessor obj = {};
}
let instance = new Foo();
let setData = () => instance.obj.foo = { bar: 3 };
let inc = () => instance.obj.foo.bar++;
<template>
{{instance.obj.foo.bar}}
<button onclick={{setData}}>Set initial data</button>
<button> onclick={{inc}}>increment</button>
</template>
Note that this can be memory intensive, and should not be the default way to reach for reactivity. Due to the nature of nested proxies, it's also much harder to inspect.
Inspiration for deep reactivity comes from:
A utility decorator for recursively, deeply, and lazily auto-tracking JSON-serializable structures at any depth or size.
import { deep } from 'signal-utils/deep';
let obj = deep({});
let setData = () => obj.foo = { bar: 3 };
let inc = () => obj.foo.bar++;
<template>
{{obj.foo.bar}}
<button onclick={{setData}}>Set initial data</button>
<button> onclick={{inc}}>increment</button>
</template>
Note that this can be memory intensive, and should not be the default way to reach for reactivity. Due to the nature of nested proxies, it's also much harder to inspect.
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.
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:
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.
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.
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>
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>
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>
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>
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
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()
wip
utilities for the dedupe pattern.
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
Utilities that can easily lead to subtle bugs and edge cases.
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
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).
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!!
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.
tracked-built-ins
tracked-toolbox
ember-async-data
reactiveweb
tracked-draft
Footnotes
-
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. ↩