@spearwolf/signalize - A lightweight JavaScript library for signals & effects.
Reactive programming, made simple. Works in Browser & Node.js.
Type-safe. Fast. No framework lock-in.
@spearwolf/signalize is a javascript library for creating fine-grained reactivity through signals and effects.
- a standalone javascript library that is framework agnostic
- without side-effects and targets
ES2023based environments - written in typescript v5 and uses the new tc39 decorators ๐
- however, it is optional and not necessary to use the decorators
Note
Reactivity is the secret sauce to building modern, dynamic web apps.
@spearwolf/signalize makes it easy. No frameworks, no boilerplate, just pure reactivity.
Important
The documentation is a work in progress. Although every effort is made to ensure completeness and logical structure, there is always room for improvement, and some topics are not fully explained. Therefore, it is advisable to review the test specifications as well. The API itself is almost stable and is already being used successfully in several internal projects.
- Introduction
- Getting Started
- API Reference
- Contributing
- License
@spearwolf/signalize brings the power of fine-grained reactivity to any JavaScript or TypeScript project.
It's a lightweight, standalone library that helps you manage state and build data flows that automatically update when your data changes.
Forget about imperative DOM updates or complex state management logic. With signals, you create reactive values, and with effects, you create functions that automatically run whenever those values change. It's that simple.
-
Signals: Think of them as reactive variables. When a signal's value changes, it automatically notifies everything that depends on it. It's like a spreadsheet cell that magically updates all formulas that use it.
-
Effects: These are the functions that "listen" to signals. An effect subscribes to one or more signals and re-executes automatically whenever any of its dependencies change, keeping your app perfectly in sync.
-
Memos: These are special signals whose values are computed from other signals. The library caches their result and only re-evaluates them when one of their dependencies changes, giving you performance for free.
This library offers both a clean functional API and a convenient class-based API using decorators.
First, install the package using your favorite package manager:
npm install @spearwolf/signalizeNow, let's see it in action. Hereโs a simple example that automatically logs the signal value to the console whenever it changes.
import { createSignal, createEffect } from '@spearwolf/signalize';
// Create a signal with an initial value of 0.
const count = createSignal(0);
// Create an effect that runs whenever `count` changes.
createEffect(() => {
// By calling count.get(), we establish a dependency on the `count` signal.
// The effect will re-run whenever its value changes.
console.log(`The count is now: ${count.get()}`);
});
// Console output: The count is now: 0
// Update the signal's value.
console.log('Setting count to 5...');
count.set(5);
// Console output: The count is now: 5
count.set(5);
// (no-console output here since the value didn't change)
console.log('Setting count to 10...');
count.set(10);
// Console output: The count is now: 10That's it! No extra boilerplate, no framework dependencies. Just pure, simple reactivity.
This section provides a detailed overview of the @spearwolf/signalize API.
Signals are the heart of the library. They hold state and allow you to create reactive data flows.
Creates a new signal containing a value.
createSignal<T>(initialValue?: T, options?: SignalParams<T>): Signal<T>initialValue: The starting value of the signal.options:compare: A custom function to compare old and new values to decide if a change should trigger effects. Defaults to strict equality (===).lazy: A boolean that, if true, treats theinitialValueas a function to be executed lazily on the first read.attach: Attaches the signal to aSignalGroupfor easier lifecycle management.
createSignal returns a Signal object with the following properties:
get(): A function to read the signal's value. Usingget()inside an effect creates a subscription.set(newValue): A function to write a new value to the signal.value: A getter/setter to read or write the signal's value. Reading via.valuedoes not track dependencies in effects. Writing will trigger effects.onChange(callback): A simple way to create a static effect that runs when the signal changes. Returns a function to destroy the subscription.touch(): Triggers all dependent effects without changing the signal's value.destroy(): Destroys the signal and cleans up all its dependencies.muted: A boolean property that indicates whether the signal is currently muted. Muted signals do not trigger effects when their value changes.
Example:
import { createSignal } from '@spearwolf/signalize';
// A signal holding a vector
const v3 = createSignal([1, 2, 3], {
// A custom compare function
compare: (a, b) => a == b || a?.every((val, index) => val === b[index]),
});
v3.onChange((v) => {
console.log('Vector changed:', v);
});
console.log(v3.value); // => [1, 2, 3]
// Update the signal's value
v3.value = [4, 5, 6];
// => Vector changed: [4, 5, 6]
// This update will NOT trigger effects because the custom compare function returns true
v3.value = [4, 5, 6];It's important to understand the difference between dependency-tracking reads and non-tracking reads.
signal.get(): This is the primary way to read a signal's value and have an effect subscribe to its changes.signal.value: This property provides direct access to the signal's value without creating a dependency. An effect that reads.valuewill not re-run when that signal changes.value(signal): This is a utility function that behaves identically to thesignal.valueproperty, providing a non-tracking read of the signal's value.
Choose wisely: Use .get() when you want reactivity. Use .value or value() when you need to peek at a value without creating a subscription.
import { createSignal, createEffect } from '@spearwolf/signalize';
const name = createSignal('John');
const age = createSignal(30);
createEffect(() => {
// This effect depends on `name` (using .get()) but NOT on `age` (using .value)
console.log(`Name: ${name.get()}, Age: ${age.value}`);
});
// Console output: Name: John, Age: 30
name.value = 'Jane'; // Triggers the effect because we used .get()
// Console output: Name: Jane, Age: 30
age.value = 31; // Does NOT trigger the effect, because we read it with .value inside the effect
console.log(`Updated Age: ${age.value}`); // Outputs: Updated Age: 31
name.touch(); // This will trigger the effect without changing the value
// Console output: Name: Jane, Age: 31signal.value = newValue: The most direct way to set a new value.signal.set(newValue): The functional equivalent.touch(signal): Triggers effects without changing the value. Useful for forcing re-renders or re-evaluations.
You can temporarily prevent a signal from triggering effects using muteSignal and unmuteSignal.
import { createSignal, createEffect, muteSignal, unmuteSignal } from '@spearwolf/signalize';
const sig = createSignal('hello');
createEffect(() => console.log(sig.get()));
// => "hello"
muteSignal(sig);
sig.value = 'world'; // Nothing is logged
unmuteSignal(sig); // Nothing is logged
sig.value = 'world again';
// => "world again"Note
The Signal object also has a .muted property: sig.muted = true.
To clean up a signal and all its associated effects and dependencies, use destroySignal.
destroySignal(mySignal, anotherSignal);You can also call the .destroy() method on the signal object itself.
TODO add an advanced section about signal objects, getter and setter functions.
Effects are where the magic happens. They are self-managing functions that react to changes in your signals.
Creates a new effect.
createEffect(callback: () => void | (() => void), options?: EffectOptions): Effectcallback: The function to execute. It can optionally return a cleanup function, which runs before the next effect execution or on destruction.options:dependencies: An array of signals to subscribe to, creating a static effect. If omitted, the effect is dynamic.autorun: Iffalse, the effect will not run automatically. You must call.run()manually.attach: Attaches the effect to aSignalGroup.priority: Effects with higher priority are executed before others. Default is0.
createEffect returns an Effect object with two methods:
run(): Manually triggers the effect, respecting dependencies.destroy(): Stops and cleans up the effect.
-
Dynamic (Default): The effect automatically tracks which signals are read during its execution and re-subscribes on each run. This is great for conditional logic.
const show = createSignal(false); const data = createSignal('A'); createEffect(() => { console.log('Effect running...'); if (show.get()) { console.log(data.get()); // `data` is only a dependency when `show` is true } }); show.set(true); // Effect re-runs data.set('B'); // Effect re-runs show.set(false); // Effect re-runs data.set('C'); // Effect does NOT re-run
-
Static: You provide an explicit array of dependencies. The effect only runs when one of those signals changes, regardless of what's read inside.
const a = createSignal(1); const b = createSignal(2); // This effect ONLY depends on `a`, even though it reads `b`. createEffect(() => { console.log(`a=${a.get()}, b=${b.get()}`); }, [a]); // Static dependency on `a` b.set(99); // Does NOT trigger the effect a.set(10); // Triggers the effect
An effect can return a function that will be executed to "clean up" its last run. This is perfect for managing subscriptions, timers, or other side effects.
const milliseconds = createSignal(1000);
createEffect(() => {
console.log(`Create timer with', ${milliseconds.get()}ms interval`);
const timerId = setInterval(() => {
console.log('tick');
}, milliseconds.get());
// This cleanup function runs when the effect is destroyed
return () => {
clearInterval(timerId);
console.log('Previous timer cleared!');
};
});
// => "Create timer with 1000ms interval"
milliseconds.set(5000); // Set interval to 5 seconds
// => "Previous timer cleared!"
// => "Create timer with 5000ms interval"
// => . . "tick" ...Set autorun: false to create an effect that you control. It will only track dependencies and run when you explicitly call its run() method.
const val = createSignal(0);
const effect = createEffect(() => console.log(val.get()), { autorun: false });
// No output yet, since autorun is false
console.log('Effect created, but not run.');
effect.run();
// => Console output: 0
val.set(1); // Does nothing, since we have deactivated autorun
val.set(10); // same
effect.run();
// => Console output: 10
val.set(10); // Does nothing
effect.run(); // Does nothing, because the value didn't changeEffects can be nested, allowing you to create complex reactive flows. However, be cautious of circular dependencies, as they can lead to infinite loops.
TODO Add more details about nested effects and circular dependencies.
Memos are signals whose values are derived from other signals. They are lazy and only recompute when a dependency changes.
Creates a new memoized signal.
The default behavior of a memo is that of a computed signal. If dependencies change, the memo value is recalculated and can in turn trigger dependent effects.
Alternatively, a lazy memo can be created by using the lazy: true option.
A lazy memo works in the same way, with the difference that the memo value is only calculated when the memo is read. This means that effects dependent on the memo are also only executed when the memo has been read.
createMemo<T>(computer: () => T, options?: CreateMemoOptions): SignalReader<T>computer: The function that computes the value.options:lazy: Iftrue, the memo will be lazy and only compute when accessed. Default isfalse. A non-lazy memo computes immediately and works like a computed signal.attach: Attaches the memo to aSignalGroup.priority: Memos with higher priority are executed before others effects. Default is1000.name: Gives the memo a name within its group.
It returns a signal reader function, which you call to get the memo's current value.
Tip
Choose wisely:
- A non-lazy memo, aka computed signal, is the standard behavior and is most likely what users expect.
- A lazy memo, on the other hand, is more efficient: the calculation is only performed when it is read. However, a lazy effect does not automatically update dependent effects, but only after they are read, which can lead to unexpected behavior.
Example:
import {createSignal, createMemo} from '@spearwolf/signalize';
const firstName = createSignal('John');
const lastName = createSignal('Doe');
const fullName = createMemo(
() => {
console.log('Computing full name...');
// We use .get() to establish dependencies inside the memo
return `${firstName.get()} ${lastName.get()}`;
},
{
lazy: true,
},
);
// Nothing is logged
console.log('hello');
// => "hello"
console.log(fullName());
// => "Computing full name..."
// => "John Doe"
console.log(fullName());
// => "John Doe"
firstName.set('Jane'); // Nothing is logged
// NOTE A _non-lazy_ memo (`lazy: false` or no options at all)
// would now trigger the recalculation at this point, generating the output => "Computing full name..."
console.log('after change');
console.log(fullName());
// But since it's a lazy memo, the memo hook is only executed now => "Computing full name..."
// => "Jane Doe"For those who prefer object-oriented patterns, @spearwolf/signalize provides decorators for creating signals and memos within classes.
Import decorators from the separate entry point:
import { signal, memo } from '@spearwolf/signalize/decorators';Important
The decorator API is still in the early stages of development and is not yet complete. It only uses the new JavaScript standard decorators, not the legacy or experimental TypeScript ones.
A class accessor decorator that turns a property into a signal.
class User {
@signal() accessor name = 'Anonymous';
@signal() accessor age = 0;
}
const user = new User();
console.log(user.name); // => "Anonymous"
createEffect(() => {
console.log(`User is ${user.name}, age ${user.age}`);
});
// => "User is Anonymous, age 0"
user.name = 'Alice'; // Triggers the effect
// => "User is Alice, age 0"A class method decorator that turns a getter-like method into a memoized signal.
Important
A memo created by this decorator is always lazy and never autorun!
class User {
@signal() accessor firstName = 'John';
@signal() accessor lastName = 'Doe';
@memo()
fullName() {
console.log('Computing full name...');
return `${this.firstName} ${this.lastName}`;
}
}
const user = new User();
console.log(user.fullName()); // "Computing full name..." -> "John Doe"
console.log(user.fullName()); // (no log) -> "John Doe"The batch() function allows you to apply multiple signal updates at once, but only trigger dependent effects a single time after all updates are complete.
This is a powerful optimization to prevent unnecessary re-renders or computations.
Caution
batch() is a hint not a guarantee to run all effects in just one strike!
import { createSignal, createEffect, batch } from '@spearwolf/signalize';
const a = createSignal(1);
const b = createSignal(2);
createEffect(() => console.log(`a=${a.get()}, b=${b.get()}`));
// => a=1, b=2
batch(() => {
a.set(10); // Effect does not run yet
b.set(20); // Effect does not run yet
}); // Effect runs once at the end
// => a=10, b=20beQuiet() executes a function without creating any signal dependencies within it.
Note
isQuiet() can be used to check if you are currently inside a beQuiet call.
import { createSignal, createEffect, beQuiet, isQuiet } from '@spearwolf/signalize';
const a = createSignal(1);
const b = createSignal(2);
createEffect(() => console.log(`a=${a.get()}, b=${b.get()}`));
// => a=1, b=2
beQuiet(() => {
a.set(100); // Effect does not run
b.set(200); // Effect does not run
console.log('Inside beQuiet, isQuiet=', isQuiet());
// => Inside beQuiet, isQuiet= true
}); // Effect does not run at all
console.log('a:', a.value, 'b:', b.value, 'isQuiet:', isQuiet());
// => a: 100 b: 200 isQuiet: falselink() creates a one-way binding from a source signal to a target signal or a callback function.
The target will be automatically updated whenever the source changes. unlink() removes this connection.
import {createSignal, link, unlink} from '@spearwolf/signalize';
const source = createSignal('A');
const target = createSignal('');
const connection = link(source, target);
console.log(target.value); // => "A" (value is synced on link)
console.log(connection.lastValue); // => "A"
source.value = 'B';
console.log(target.value); // => "B"
// Stop the connection
unlink(source, target); // or connection.destroy()
source.value = 'C';
console.log(target.value); // => "B" (no longer updates)link() returns a connection object with the following properties:
lastValue: The last value that was set on the target when the source changed or the link was created.nextValue(): Promise<ValueType>: The next value that will be set on the target when the source changes.*asyncValues(stopAction?: (value, index) => boolean): An async generator that yields the next values from the source signal until the connection is destroyed or stopped or thestopActionreturnstrue.touch(): Triggers the next value immediately, without waiting for the source to change.isMuted: A boolean indicating whether the link is currently muted.mute(): A method to temporarily stop the link from updating the target.unmute(): A method to resume the link after it has been muted.toggle(): A method to toggle the muted state of the link.attach(signalGroup): TheSignalGroupto which the link is attached.destroy(): A method to remove the link and clean up resources.
A SignalGroup is a helpful utility for managing the lifecycle of a collection of signals, effects, and links, typically associated with a class instance or component.
When you use decorators, a SignalGroup is automatically created. You can destroy all reactive elements in a group with a single call to group.clear().
TODO add more details about SignalGroup and its methods.
A Map-like class that automatically creates a Signal for any key that is accessed but doesn't yet exist. This is useful for managing dynamic collections of reactive state.
import {SignalAutoMap, createEffect} from '@spearwolf/signalize';
const autoMap = SignalAutoMap.fromProps({bar: 'bar'});
// Accessing 'foo' for the first time creates a signal for it
autoMap.get('foo').value = 'hello';
createEffect(() => {
console.log(autoMap.get('foo').get(), autoMap.get('bar').get());
});
// => "hello bar"
autoMap.get('bar').value = 'world';
// => "hello world"
autoMap.updateFromProps({foo: 'hallo'});
// => "hallo world"Contributions are welcome! If you find a bug or have a feature request, please open an issue. If you want to contribute code or documentation, please open a pull request.
This project is licensed under the Apache-2.0 License. See the LICENSE file for details.
The hero image above was created at the request of spearwolf using OpenAI's DALL-E and guided by ChatGPT. It was then animated by KLING AI and converted by Ezgif.com.
