Skip to content

spearwolf/signalize

Repository files navigation

@spearwolf/signalize

signals and effects for all 📢

signalize hero image


npm (scoped) GitHub Workflow Status (with event) GitHub

@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

⚙️ Install

npm install @spearwolf/signalize

Packaged as ES2022 and exported as unbundled ESM-only javascript modules. Type definitions and source maps also included.

🔎 Since v0.5 there is also a CHANGELOG

Since v0.7 commonjs modules are no longer exported ❗

Overview 👀

The whole API of @spearwolf/signalize is about ..

  • signals
    • like state variables with hidden superpowers
    • when the value of a signal changes, all subscribers are automatically informed
  • effects
    • are callback functions that are automatically executed when one or more signals — which are read within the effect function — change
    • just think of it as a standalone useEffect() hook (but without react 😉)
  • connections
    • which are basically links between signals and functions
    • like the geometry node connections in blender or the node connections in blueprints of the unreal engine

A functional API is provided, as well as a class-based API that uses decorators.

🔎 Under the hood the event-driven library @spearwolf/eventize is used!

📖 Usage

⚠️ While the library / API is already quite stable and almost completely tested, this documentation is still in an early state ... 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.

API Overview

  • Signals
    • create
      • [λ, setλ] = createSignal()
      • @signal() accessor α
      • @signalReader() accessor β
    • read
      • λ()
      • λ(effect)
      • value(λ)
      • beQuiet(callback)
    • write
      • setλ(value)
      • touch(λ)
      • batch(callback)
    • destroy
      • destroySignal(λ)
  • Effects
    • static
      • [run, destroy] = createEffect(callback, [...dependencies])
      • [run, destroy] = createEffect(callback, options)
      • λ(effectCallback)
    • dynamic
      • [run, destroy] = createEffect(callback)
      • [run, destroy] = createEffect(callback, options)
    • object decorator
      • @effect(options) foo() { .. }
  • Memo
    • λ = createMemo(callback)
    • @memo() compute() { .. }
  • Connections
    • γ = connect()
      • γ.nextValue(): Promise
      • γ.touch()
      • γ.mute()
      • γ.unmute()
      • γ.toggle()
      • γ.isMuted
      • γ.destroy()
      • γ.isDestroyed
    • unconnect(γ)
  • utils
    • isSignal(λ)
    • muteSignal(λ)
    • unmuteSignal(λ)
    • getSignalsCount()
    • getEffectsCount()
    • objects
      • queryObjectSignal(Ω, name)
      • queryObjectSignals(Ω)
      • getObjectSignalKeys(Ω)
      • destroyEffects(...Ω)
      • destroySignals(...Ω)
      • destroySignalsAndEffects(...Ω)

📖 Signals

Signals are mutable states that can trigger effects when changed.

A standalone signal A class with a signal
A standalone signal A class with a signal

Create a signal

API

[λ, setλ] = createSignal()

[λ, setλ] = createSignal(initialValue)

[λ, setλ] = createSignal(initialValue, options)
Return value

createSignal()[signalReader, signalWriter] returns a tuple with two functions. the first function is the signal reader, the second is the signal writer.

If the signal reader is called as a function, it returns the current signal value as the return value.

If the signal writer is called with a value, this value is set as the new signal value. 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.

You can destroy the reactivity of a signal with destroySignal(signalReader). 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.

Options
option type description
compareFn (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.
beforeReadFn () => 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.

Create a signal using decorators

import {signal, signalReader} from '@spearwolf/signalize/decorators';

class Foo {
  @signal() accessor foo = 'bar';

  @signal({readAsValue: true}) accessor xyz = 123;
  @signalReader() accessor xyz$;
}

const obj = new Foo();

obj.foo;             // => 'bar'
obj.foo = 'plah';    // set value to 'plah'

obj.xyz;             // => 123
obj.xyz$();          // => 123
obj.xyz = 456;       // set value to 456

🔎 The use of $ or $$ as postfixes to variable names is optional and a matter of personal preference. However, signalize mostly uses the convention that anything with a $ prefix represents a signal reader and not the value directly. Similarly, a $$ postfix on the variable name indicates that it is a tuple of signal reader and signal writer (which is what createSignal() returns). By the way, signal readers are often represented in this documentation as λ, β, γ or other greek letters.

API

@signal
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 queryObjectSignal() 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 reader without the help of @signalReader() or queryObjectSignal().
@signalReader
class {

  @signalReader() accessor Λ$

  @signalReader(options) accessor Λ$

}
option type description
name string | symbol Creates a readable object accessor that does not contain the signal value but the signal reader (function). the name of the signal is optional. if not specified, then the internal signal name is assumed to be the same as the accessor name. if the accessor name ends with a $, then the $ is stripped from the signal name.

Read signal value

λ(): 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

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.

Write signal value

setλ(value) 

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(λ)

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.

Destroy signal

destroySignal(λ)

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

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.

Dynamic vs. Static effects

A dynamic effect function A class with a dynamic effect
A standalone effect function A class with an effect method

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.

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

API

Static Effects

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

🔎 By the way, you cannot directly destroy an effect created in this way, this happens automatically when the signal is destroyed.

Dynamic Effects

[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 return values of createEffect()

The call to createEffect() returns a tuple with two functions.

The first function is 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 second function is the destroy callback, which destroys the effect when called.

Create an effect using decorators

class {
  
  @effect() foo() { .. }

  @effect(options) foo() { .. }

}

IMPORTANT NOTE: If a class method is declared as an effect, this effect is not automatically activated when the object is instantiated. To activate the effect, the user must call it once, for example in the constructor of the class.

The effect does not become active until it has been called once.

With a dynamic effect, this is absolutely necessary to determine the dependent signals.

With a static effect, the dependencies are known in advance: but again, the effect is only active after it has been called once. If you do not want a static effect to be executed on the first call, i.e. before it is activated, you can use the autostart: false option.

option type description
deps Array< λ | string | symbol > these are the signal dependencies that mark this as a static effect. otherwise it is a dynamic effect
signal λ | string | symbol is a shortcut that can be used when there is only one signal dependency
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).
autostart boolean applies to static effects only: use this to control whether the effect should be invoked the first time it is called (activated)

The effect can optionally return a cleanup function

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.

🔎 Does this behaviour look familiar? probably because this feature was inspired by react's useEffect hook

Example: Use an effect cleanup function

const [getSelector, 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!!