signals and effects for all 📢
Image created in response to a request from spearwolf, using OpenAI's DALL-E, guided by ChatGPT.
@spearwolf/signalize
is a javascript library for creating signals and effects.
- a standalone javascript library that is framework agnostic
- without side-effects and targets
ES2022
based environments - written in typescript v5 and uses the new tc39 decorators 🚀
- however, it is optional and not necessary to use the decorators
npm install @spearwolf/signalize
Packaged as ES2022
and exported as unbundled ESM-only javascript modules.
Type definitions and source maps also included.
[!NOTE] Since
v0.5
there is also a CHANGELOG 🎉
[!CAUTION] Since
v0.7
commonjs modules are no longer exported❗
The whole API of @spearwolf/signalize
is about ..
-
Signals
- like state variables with hidden superpowers
- when the value of a signal changes, all observers are automatically informed
-
Effects
- are functions that are automatically executed when one or more signals change
- just think of it as a next-gen and independent
useEffect()
hook (but without the limitations imposed by react 😉)
A functional API is provided, as well as a class-based API that uses decorators.
[!NOTE] Under the hood the event-driven library @spearwolf/eventize is used!
[!WARNING] The core of the library is stable and fully tested, although the API is still partially evolving, and the same goes for the documentation ... there are some features that are not documented in detail here. The adventurous developer is encouraged to explore the source code and tests directly at this point.
-
Signals
-
create
🦋 = {get: λ, set: setλ} = createSignal()
@signal() accessor α
-
read
🦋.get()
λ()
🦋.onChange(callback)
λ(callback)
🦋.value
value(λ)
beQuiet(callback)
-
write
🦋.set(value)
setλ(value)
🦋.touch()
touch(λ)
batch(callback)
🦋.muted
muteSignal(λ)
unmuteSignal(λ)
-
destroy
🦋.destroy()
destroySignal(λ)
-
object helpers
findObjectSignalByName(🦋, name)
findObjectSignalNames(🦋)
findObjectSignals(🦋)
destroyObjectSignals(🦋)
-
create
-
Effects
-
create
-
dynamic
🦄 = createEffect(callback)
🦄 = createEffect(callback, options)
-
static
🦄 = createEffect(callback, [...dependencies])
🦄 = createEffect(callback, options)
🦋.onChange(callback)
λ(callback)
-
dynamic
-
api
🦄.run()
🦄.destroy()
-
create
-
Memo
λ = createMemo(callback)
@memo() compute() { .. }
-
Building Blocks
-
connections between signals
-
γ = link(src, trgt)
γ.nextValue(): Promise
γ.asyncValues(): yield*
γ.touch()
γ.mute()
γ.unmute()
γ.toggle()
γ.isMuted
γ.destroy()
γ.isDestroyed
unlink()
-
-
signal groups
-
SignalGroup.get(obj)
→ group -
SignalGroup.findOrCreate(obj)
→ group SignalGroup.destroy(obj)
SignalGroup.clear()
SignalGroup#attachGroup(group)
SignalGroup#detachGroup(group)
SignalGroup#attachSignal(🦋|λ)
SignalGroup#detachSignal(🦋|λ)
SignalGroup#attachSignalByName(name, 🦋|λ)
-
SignalGroup#hasSignal(name)
→ boolean -
SignalGroup#signal(name)
→🦋
SignalGroup#attachEffect(🦄)
SignalGroup#runEffects()
SignalGroup#attachLink(link)
SignalGroup#detachLink(link)
SignalGroup#destroy()
-
-
connections between signals
-
utils
isSignal(🦋|λ)
muteSignal(🦋|λ)
unmuteSignal(🦋|λ)
-
testing
getSignalsCount()
getEffectsCount()
getLinksCount()
Signals are mutable states that can trigger effects when changed.
A standalone signal | A class with a signal |
---|---|
|
|
🦋 = {get: λ, set: setλ} = createSignal()
⋯ = createSignal(initialValue)
⋯ = createSignal(initialValue, options)
createSignal()
→ 🦋 | {get: signalReader, set: signalWriter}
returns the signal object (🦋), which contains the signal reader and the signal writer functions.
If the signal reader is called as a function, it returns the current signal value as the return value: λ(): value
If the signal writer is called with a value, this value is set as the new signal value: setλ(nextValue)
When the signal value changes, any effects that depend on it will be executed.
Reading and writing is always immediate. Any effects are called synchronously. However, it is possible to change this behavior using batch()
, beQuiet()
, value()
or other methods of this library.
The signal object (🦋) is a wrapper around it, providing a signal API beyond read and write:
🦋-Methods | Description |
---|---|
.get() → value |
The signal reader returns the value. If the method is called during a dynamic effect, the effect is informed of this and the next time the value changes, the effect is automatically repeated. |
.set(value) |
The signal writer sets the new value and informs the observers of the new value. |
.value |
Just return the value. This is done without noticing any effect, as opposed to using .get()
|
.onChange((value) → void) |
... |
.muted |
... |
.touch() |
... |
.destroy() |
... |
[!NOTE] You can destroy the reactivity of a signal with
🦋.destroy()
ordestroySignal(λ)
. A destroyed signal will no longer trigger any effects. But both the signal reader and the signal writer are still usable and will read and write the signal value.
option | type | description |
---|---|---|
compare |
(a, b) => boolean |
Normally, the equality of two values is checked with the strict equality operator === . If you want to go a different way here, you can pass a function that does this. |
lazy |
boolean |
If this flag is set, it is assumed that the value is a function that returns the current value. This function is then executed lazy, i.e. only when the signal is read for the first time. At this point, however, it should be noted that the signal value is initially only lazy. once resolved, it is no longer lazy. |
beforeRead |
() => void |
the name says it all: a callback that is executed before the signal value is read. not intended for everyday use, but quite useful for edge cases and testing. |
import {signal} from '@spearwolf/signalize/decorators';
import {findObjectSignalByName} from '@spearwolf/signalize';
class Foo {
@signal() accessor foo = 'bar';
@signal({readAsValue: true}) accessor xyz = 123;
}
const obj = new Foo();
obj.foo; // => 'bar'
obj.foo = 'plah'; // set value to 'plah'
obj.xyz; // => 123
obj.xyz = 456; // set value to 456
findObjectSignalByName.get(obj, 'xyz').value // => 456
class {
@signal() accessor Λ = initialValue
@signal(options) accessor Λ = initialValue
}
option | type | description |
---|---|---|
name |
string | symbol
|
The name of the signal. setting a name is optional, the signal name is usually the same as the accessor name. each object has an internal map of its signals, where the key is the signal name. the name is used later, for example, for findObjectSignalByName() or destroySignal()
|
readAsValue |
boolean |
If enabled, the value of the signal will be read without informing the dependencies, just like the value(λ) helper does. However, if the signal was defined as an object accessor using the decorator, it is not possible to access the signal object without the findObjectSignalByName() helper. |
λ(): val
🦋.get(): val
Calling the signal reader without arguments returns the value of the signal. If this is called up within a dynamic effect, the effect remembers this signal and marks it as a dependent signal.
value(λ|🦋): val
🦋.value
returns the value of the signal. in contrast to the previous variant, however, no effect is notified here. it really only returns the value, there are no side effects.
beQuiet(callback)
executes the callback immediately. if a signal is read out within the callback, this is done without notifying an active dynamic effect. it does not matter whether the signal is read out directly or with the value()
helper.
setλ(value)
🦋.set(val)
Calling the signal writer sets a new signal value. if the value changes (this is normally simply checked using the ===
operator), all effects that have marked this signal as a dependency are executed immediately.
touch(λ|🦋)
🦋.touch()
does not change the value of the signal. however, all dependent effects are still notified and executed.
batch(callback)
executes the callback immediately. if values are changed within the callback signal, the values are changed immediately - but any dependent effects are only executed once after the end of the callback. this prevents effects with multiple dependencies from being triggered multiple times if several signals are written.
See The difference between the standard behavior of effects and the use of batching for more informations on this.
destroySignal(λ|🦋)
🦋.destroy()
Destroys the reactivity of the signal. This signal will no longer be able to cause any effects. However, the signal reader and signal writer functions will continue to work as expected.
Effects are functions that react to changes in signals and are executed automatically.
Without effects, signals are nothing more than ordinary variables.
With effects, you can easily control behavior changes in your application without having to write complex dependency or monitoring logic.
A dynamic effect function | A class with a dynamic effect |
---|---|
|
|
Dynamic effects are always executed the first time. During the execution of an effect callback function, the read signals are tracked. If one of the signals is changed afterwards, the effect is (automatically) called again.
[!NOTE] The signals used are re-recorded each time the effect runs again. This is why they are called dynamic effects.
Static effects do not track signals; instead, dependencies are defined in advance during effect creation:
createEffect(() => {
const sum = a() + b();
console.log('sum of', a(), 'and', b(), 'is', sum);
}, [a, b]);
It doesn't matter which signals are used within the effect function, the effect will be re-run whenever a signal in the signal dependencies list changes.
🦄 = {run, destroy} = createEffect(callback, [...dependencies])
🦄 = {run, destroy} = createEffect(callback, options)
option | type | description |
---|---|---|
dependencies |
Array< λ | string | symbol >
|
these are the signal dependencies that mark this as a static effect. otherwise it is a dynamic effect. the effect is only executed when the dependent signals change. in contrast to the dynamic effects, it does not matter which signals are used within the effect. |
autorun |
boolean |
if autorun is set to false , the effect callback will not be called automatically at any time! to call the effect, you must explicitly call the run() function. everything else behaves as expected for an effect. when run() is called, the effect is only executed when the signals have changed (or on the very first call). |
λ(effectCallback)
alternatively, the signal reader can also be called with an effect callback. this creates a static effect that is called whenever the signal value changes. important here: the callback is not called automatically the first time, but only when the signal value changes afterwards.
[!NOTE] By the way, you cannot directly destroy an effect created in this way, this happens automatically when the signal is destroyed.
🦄 = {run, destroy} = createEffect(callback)
🦄 = {run, destroy} = createEffect(callback, options)
option | type | description |
---|---|---|
autorun |
boolean |
if autorun is set to false , the effect callback will not be called automatically at any time! to call the effect, you must explicitly call the run() function. everything else behaves as expected for an effect. when run() is called, the effect is only executed when the signals have changed (or on the very first call). |
The call to createEffect()
returns an effect object.
Here you can find the run()
function. When the run function is called, the effect is executed, but only if the dependent signals have changed.
So this function is not really useful unless you use the autorun: false
feature, which prevents the effect from being executed automatically.
This is where the run()
comes in, which explicitly executes the effect: for example, do you want to execute an effect only at a certain time (e.g. within a setInterval()
or requestAnimationFrame()
callback)? then run()
is the way to go!
The effect object also contains the destroy callback, which destroys the effect when called.
Your effect callback (which is your function that you pass to the effect as parameter) may also optionally return a cleanup function.
Before calling an effect, a previously set cleanup function is executed.
The effect cleanup function is reset each time the effect is executed. If the effect does not return a function, nothing will be called the next time the effect is called.
[!NOTE] Does this behavior look familiar? probably because this feature was inspired by react's useEffect hook
const {get: getSelector, set: makeInteractive} = createSignal();
function onClick(event) {
console.log('click! selector=', getSelector(), 'element=', event.target);
}
createEffect(() => {
if (getSelector()) {
const el = document.querySelector(getSelector());
el.addEventListener('click', onClick, false);
return () => {
el.removeEventListener('click', onClick, false);
};
}
})
makeInteractive('#foo'); // foo is now interactive
makeInteractive('.bar'); // bar is now interactive, but foo is not
more docs coming!!