Skip to content

spearwolf/signalize

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@spearwolf/signalize

signals and effects for all 📢

signalize hero image Image created in response to a request from spearwolf, using OpenAI's DALL-E, guided by ChatGPT.


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.

Note

Since v0.5 there is also a CHANGELOG 🎉

Caution

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 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 micro-library @spearwolf/eventize is used 😍

📖 Usage

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.

API Overview

  • 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(🦋)
  • Effects
    • create
      • dynamic
        • 🦄 = createEffect(callback)
        • 🦄 = createEffect(callback, options)
      • static
        • 🦄 = createEffect(callback, [...dependencies])
        • 🦄 = createEffect(callback, options)
        • 🦋.onChange(callback)
        • λ(callback)
    • api
      • 🦄.run()
      • 🦄.destroy()
  • 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()
  • utils
    • isSignal(🦋|λ)
    • muteSignal(🦋|λ)
    • unmuteSignal(🦋|λ)
  • testing
    • getSignalsCount()
    • getEffectsCount()
    • getLinksCount()

📖 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

🦋 = {get: λ, set: setλ} = createSignal()

 = createSignal(initialValue)
 = createSignal(initialValue, options)
Return value

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() or destroySignal(λ). 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.

createSignal() Options
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.

Create a signal using decorators

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

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

Read signal value

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

Write signal value

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.

Destroy signal

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

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.

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.

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.

Note

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 value of createEffect()

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.

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.

Note

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

Example: Use an effect cleanup function

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!!